##// END OF EJS Templates
Use FasterCSV or ruby1.9 CSV instead of ruby1.8 builtin CSV....
Jean-Philippe Lang -
r2893:27e3fa2bed6b
parent child
Show More
This diff has been collapsed as it changes many lines, (1984 lines changed) Show them Hide them
@@ -0,0 +1,1984
1 #!/usr/local/bin/ruby -w
2
3 # = faster_csv.rb -- Faster CSV Reading and Writing
4 #
5 # Created by James Edward Gray II on 2005-10-31.
6 # Copyright 2005 Gray Productions. All rights reserved.
7 #
8 # See FasterCSV for documentation.
9
10 if RUBY_VERSION >= "1.9"
11 abort <<-VERSION_WARNING.gsub(/^\s+/, "")
12 Please switch to Ruby 1.9's standard CSV library. It's FasterCSV plus
13 support for Ruby 1.9's m17n encoding engine.
14 VERSION_WARNING
15 end
16
17 require "forwardable"
18 require "English"
19 require "enumerator"
20 require "date"
21 require "stringio"
22
23 #
24 # This class provides a complete interface to CSV files and data. It offers
25 # tools to enable you to read and write to and from Strings or IO objects, as
26 # needed.
27 #
28 # == Reading
29 #
30 # === From a File
31 #
32 # ==== A Line at a Time
33 #
34 # FasterCSV.foreach("path/to/file.csv") do |row|
35 # # use row here...
36 # end
37 #
38 # ==== All at Once
39 #
40 # arr_of_arrs = FasterCSV.read("path/to/file.csv")
41 #
42 # === From a String
43 #
44 # ==== A Line at a Time
45 #
46 # FasterCSV.parse("CSV,data,String") do |row|
47 # # use row here...
48 # end
49 #
50 # ==== All at Once
51 #
52 # arr_of_arrs = FasterCSV.parse("CSV,data,String")
53 #
54 # == Writing
55 #
56 # === To a File
57 #
58 # FasterCSV.open("path/to/file.csv", "w") do |csv|
59 # csv << ["row", "of", "CSV", "data"]
60 # csv << ["another", "row"]
61 # # ...
62 # end
63 #
64 # === To a String
65 #
66 # csv_string = FasterCSV.generate do |csv|
67 # csv << ["row", "of", "CSV", "data"]
68 # csv << ["another", "row"]
69 # # ...
70 # end
71 #
72 # == Convert a Single Line
73 #
74 # csv_string = ["CSV", "data"].to_csv # to CSV
75 # csv_array = "CSV,String".parse_csv # from CSV
76 #
77 # == Shortcut Interface
78 #
79 # FCSV { |csv_out| csv_out << %w{my data here} } # to $stdout
80 # FCSV(csv = "") { |csv_str| csv_str << %w{my data here} } # to a String
81 # FCSV($stderr) { |csv_err| csv_err << %w{my data here} } # to $stderr
82 #
83 class FasterCSV
84 # The version of the installed library.
85 VERSION = "1.5.0".freeze
86
87 #
88 # A FasterCSV::Row is part Array and part Hash. It retains an order for the
89 # fields and allows duplicates just as an Array would, but also allows you to
90 # access fields by name just as you could if they were in a Hash.
91 #
92 # All rows returned by FasterCSV will be constructed from this class, if
93 # header row processing is activated.
94 #
95 class Row
96 #
97 # Construct a new FasterCSV::Row from +headers+ and +fields+, which are
98 # expected to be Arrays. If one Array is shorter than the other, it will be
99 # padded with +nil+ objects.
100 #
101 # The optional +header_row+ parameter can be set to +true+ to indicate, via
102 # FasterCSV::Row.header_row?() and FasterCSV::Row.field_row?(), that this is
103 # a header row. Otherwise, the row is assumes to be a field row.
104 #
105 # A FasterCSV::Row object supports the following Array methods through
106 # delegation:
107 #
108 # * empty?()
109 # * length()
110 # * size()
111 #
112 def initialize(headers, fields, header_row = false)
113 @header_row = header_row
114
115 # handle extra headers or fields
116 @row = if headers.size > fields.size
117 headers.zip(fields)
118 else
119 fields.zip(headers).map { |pair| pair.reverse }
120 end
121 end
122
123 # Internal data format used to compare equality.
124 attr_reader :row
125 protected :row
126
127 ### Array Delegation ###
128
129 extend Forwardable
130 def_delegators :@row, :empty?, :length, :size
131
132 # Returns +true+ if this is a header row.
133 def header_row?
134 @header_row
135 end
136
137 # Returns +true+ if this is a field row.
138 def field_row?
139 not header_row?
140 end
141
142 # Returns the headers of this row.
143 def headers
144 @row.map { |pair| pair.first }
145 end
146
147 #
148 # :call-seq:
149 # field( header )
150 # field( header, offset )
151 # field( index )
152 #
153 # This method will fetch the field value by +header+ or +index+. If a field
154 # is not found, +nil+ is returned.
155 #
156 # When provided, +offset+ ensures that a header match occurrs on or later
157 # than the +offset+ index. You can use this to find duplicate headers,
158 # without resorting to hard-coding exact indices.
159 #
160 def field(header_or_index, minimum_index = 0)
161 # locate the pair
162 finder = header_or_index.is_a?(Integer) ? :[] : :assoc
163 pair = @row[minimum_index..-1].send(finder, header_or_index)
164
165 # return the field if we have a pair
166 pair.nil? ? nil : pair.last
167 end
168 alias_method :[], :field
169
170 #
171 # :call-seq:
172 # []=( header, value )
173 # []=( header, offset, value )
174 # []=( index, value )
175 #
176 # Looks up the field by the semantics described in FasterCSV::Row.field()
177 # and assigns the +value+.
178 #
179 # Assigning past the end of the row with an index will set all pairs between
180 # to <tt>[nil, nil]</tt>. Assigning to an unused header appends the new
181 # pair.
182 #
183 def []=(*args)
184 value = args.pop
185
186 if args.first.is_a? Integer
187 if @row[args.first].nil? # extending past the end with index
188 @row[args.first] = [nil, value]
189 @row.map! { |pair| pair.nil? ? [nil, nil] : pair }
190 else # normal index assignment
191 @row[args.first][1] = value
192 end
193 else
194 index = index(*args)
195 if index.nil? # appending a field
196 self << [args.first, value]
197 else # normal header assignment
198 @row[index][1] = value
199 end
200 end
201 end
202
203 #
204 # :call-seq:
205 # <<( field )
206 # <<( header_and_field_array )
207 # <<( header_and_field_hash )
208 #
209 # If a two-element Array is provided, it is assumed to be a header and field
210 # and the pair is appended. A Hash works the same way with the key being
211 # the header and the value being the field. Anything else is assumed to be
212 # a lone field which is appended with a +nil+ header.
213 #
214 # This method returns the row for chaining.
215 #
216 def <<(arg)
217 if arg.is_a?(Array) and arg.size == 2 # appending a header and name
218 @row << arg
219 elsif arg.is_a?(Hash) # append header and name pairs
220 arg.each { |pair| @row << pair }
221 else # append field value
222 @row << [nil, arg]
223 end
224
225 self # for chaining
226 end
227
228 #
229 # A shortcut for appending multiple fields. Equivalent to:
230 #
231 # args.each { |arg| faster_csv_row << arg }
232 #
233 # This method returns the row for chaining.
234 #
235 def push(*args)
236 args.each { |arg| self << arg }
237
238 self # for chaining
239 end
240
241 #
242 # :call-seq:
243 # delete( header )
244 # delete( header, offset )
245 # delete( index )
246 #
247 # Used to remove a pair from the row by +header+ or +index+. The pair is
248 # located as described in FasterCSV::Row.field(). The deleted pair is
249 # returned, or +nil+ if a pair could not be found.
250 #
251 def delete(header_or_index, minimum_index = 0)
252 if header_or_index.is_a? Integer # by index
253 @row.delete_at(header_or_index)
254 else # by header
255 @row.delete_at(index(header_or_index, minimum_index))
256 end
257 end
258
259 #
260 # The provided +block+ is passed a header and field for each pair in the row
261 # and expected to return +true+ or +false+, depending on whether the pair
262 # should be deleted.
263 #
264 # This method returns the row for chaining.
265 #
266 def delete_if(&block)
267 @row.delete_if(&block)
268
269 self # for chaining
270 end
271
272 #
273 # This method accepts any number of arguments which can be headers, indices,
274 # Ranges of either, or two-element Arrays containing a header and offset.
275 # Each argument will be replaced with a field lookup as described in
276 # FasterCSV::Row.field().
277 #
278 # If called with no arguments, all fields are returned.
279 #
280 def fields(*headers_and_or_indices)
281 if headers_and_or_indices.empty? # return all fields--no arguments
282 @row.map { |pair| pair.last }
283 else # or work like values_at()
284 headers_and_or_indices.inject(Array.new) do |all, h_or_i|
285 all + if h_or_i.is_a? Range
286 index_begin = h_or_i.begin.is_a?(Integer) ? h_or_i.begin :
287 index(h_or_i.begin)
288 index_end = h_or_i.end.is_a?(Integer) ? h_or_i.end :
289 index(h_or_i.end)
290 new_range = h_or_i.exclude_end? ? (index_begin...index_end) :
291 (index_begin..index_end)
292 fields.values_at(new_range)
293 else
294 [field(*Array(h_or_i))]
295 end
296 end
297 end
298 end
299 alias_method :values_at, :fields
300
301 #
302 # :call-seq:
303 # index( header )
304 # index( header, offset )
305 #
306 # This method will return the index of a field with the provided +header+.
307 # The +offset+ can be used to locate duplicate header names, as described in
308 # FasterCSV::Row.field().
309 #
310 def index(header, minimum_index = 0)
311 # find the pair
312 index = headers[minimum_index..-1].index(header)
313 # return the index at the right offset, if we found one
314 index.nil? ? nil : index + minimum_index
315 end
316
317 # Returns +true+ if +name+ is a header for this row, and +false+ otherwise.
318 def header?(name)
319 headers.include? name
320 end
321 alias_method :include?, :header?
322
323 #
324 # Returns +true+ if +data+ matches a field in this row, and +false+
325 # otherwise.
326 #
327 def field?(data)
328 fields.include? data
329 end
330
331 include Enumerable
332
333 #
334 # Yields each pair of the row as header and field tuples (much like
335 # iterating over a Hash).
336 #
337 # Support for Enumerable.
338 #
339 # This method returns the row for chaining.
340 #
341 def each(&block)
342 @row.each(&block)
343
344 self # for chaining
345 end
346
347 #
348 # Returns +true+ if this row contains the same headers and fields in the
349 # same order as +other+.
350 #
351 def ==(other)
352 @row == other.row
353 end
354
355 #
356 # Collapses the row into a simple Hash. Be warning that this discards field
357 # order and clobbers duplicate fields.
358 #
359 def to_hash
360 # flatten just one level of the internal Array
361 Hash[*@row.inject(Array.new) { |ary, pair| ary.push(*pair) }]
362 end
363
364 #
365 # Returns the row as a CSV String. Headers are not used. Equivalent to:
366 #
367 # faster_csv_row.fields.to_csv( options )
368 #
369 def to_csv(options = Hash.new)
370 fields.to_csv(options)
371 end
372 alias_method :to_s, :to_csv
373
374 # A summary of fields, by header.
375 def inspect
376 str = "#<#{self.class}"
377 each do |header, field|
378 str << " #{header.is_a?(Symbol) ? header.to_s : header.inspect}:" <<
379 field.inspect
380 end
381 str << ">"
382 end
383 end
384
385 #
386 # A FasterCSV::Table is a two-dimensional data structure for representing CSV
387 # documents. Tables allow you to work with the data by row or column,
388 # manipulate the data, and even convert the results back to CSV, if needed.
389 #
390 # All tables returned by FasterCSV will be constructed from this class, if
391 # header row processing is activated.
392 #
393 class Table
394 #
395 # Construct a new FasterCSV::Table from +array_of_rows+, which are expected
396 # to be FasterCSV::Row objects. All rows are assumed to have the same
397 # headers.
398 #
399 # A FasterCSV::Table object supports the following Array methods through
400 # delegation:
401 #
402 # * empty?()
403 # * length()
404 # * size()
405 #
406 def initialize(array_of_rows)
407 @table = array_of_rows
408 @mode = :col_or_row
409 end
410
411 # The current access mode for indexing and iteration.
412 attr_reader :mode
413
414 # Internal data format used to compare equality.
415 attr_reader :table
416 protected :table
417
418 ### Array Delegation ###
419
420 extend Forwardable
421 def_delegators :@table, :empty?, :length, :size
422
423 #
424 # Returns a duplicate table object, in column mode. This is handy for
425 # chaining in a single call without changing the table mode, but be aware
426 # that this method can consume a fair amount of memory for bigger data sets.
427 #
428 # This method returns the duplicate table for chaining. Don't chain
429 # destructive methods (like []=()) this way though, since you are working
430 # with a duplicate.
431 #
432 def by_col
433 self.class.new(@table.dup).by_col!
434 end
435
436 #
437 # Switches the mode of this table to column mode. All calls to indexing and
438 # iteration methods will work with columns until the mode is changed again.
439 #
440 # This method returns the table and is safe to chain.
441 #
442 def by_col!
443 @mode = :col
444
445 self
446 end
447
448 #
449 # Returns a duplicate table object, in mixed mode. This is handy for
450 # chaining in a single call without changing the table mode, but be aware
451 # that this method can consume a fair amount of memory for bigger data sets.
452 #
453 # This method returns the duplicate table for chaining. Don't chain
454 # destructive methods (like []=()) this way though, since you are working
455 # with a duplicate.
456 #
457 def by_col_or_row
458 self.class.new(@table.dup).by_col_or_row!
459 end
460
461 #
462 # Switches the mode of this table to mixed mode. All calls to indexing and
463 # iteration methods will use the default intelligent indexing system until
464 # the mode is changed again. In mixed mode an index is assumed to be a row
465 # reference while anything else is assumed to be column access by headers.
466 #
467 # This method returns the table and is safe to chain.
468 #
469 def by_col_or_row!
470 @mode = :col_or_row
471
472 self
473 end
474
475 #
476 # Returns a duplicate table object, in row mode. This is handy for chaining
477 # in a single call without changing the table mode, but be aware that this
478 # method can consume a fair amount of memory for bigger data sets.
479 #
480 # This method returns the duplicate table for chaining. Don't chain
481 # destructive methods (like []=()) this way though, since you are working
482 # with a duplicate.
483 #
484 def by_row
485 self.class.new(@table.dup).by_row!
486 end
487
488 #
489 # Switches the mode of this table to row mode. All calls to indexing and
490 # iteration methods will work with rows until the mode is changed again.
491 #
492 # This method returns the table and is safe to chain.
493 #
494 def by_row!
495 @mode = :row
496
497 self
498 end
499
500 #
501 # Returns the headers for the first row of this table (assumed to match all
502 # other rows). An empty Array is returned for empty tables.
503 #
504 def headers
505 if @table.empty?
506 Array.new
507 else
508 @table.first.headers
509 end
510 end
511
512 #
513 # In the default mixed mode, this method returns rows for index access and
514 # columns for header access. You can force the index association by first
515 # calling by_col!() or by_row!().
516 #
517 # Columns are returned as an Array of values. Altering that Array has no
518 # effect on the table.
519 #
520 def [](index_or_header)
521 if @mode == :row or # by index
522 (@mode == :col_or_row and index_or_header.is_a? Integer)
523 @table[index_or_header]
524 else # by header
525 @table.map { |row| row[index_or_header] }
526 end
527 end
528
529 #
530 # In the default mixed mode, this method assigns rows for index access and
531 # columns for header access. You can force the index association by first
532 # calling by_col!() or by_row!().
533 #
534 # Rows may be set to an Array of values (which will inherit the table's
535 # headers()) or a FasterCSV::Row.
536 #
537 # Columns may be set to a single value, which is copied to each row of the
538 # column, or an Array of values. Arrays of values are assigned to rows top
539 # to bottom in row major order. Excess values are ignored and if the Array
540 # does not have a value for each row the extra rows will receive a +nil+.
541 #
542 # Assigning to an existing column or row clobbers the data. Assigning to
543 # new columns creates them at the right end of the table.
544 #
545 def []=(index_or_header, value)
546 if @mode == :row or # by index
547 (@mode == :col_or_row and index_or_header.is_a? Integer)
548 if value.is_a? Array
549 @table[index_or_header] = Row.new(headers, value)
550 else
551 @table[index_or_header] = value
552 end
553 else # set column
554 if value.is_a? Array # multiple values
555 @table.each_with_index do |row, i|
556 if row.header_row?
557 row[index_or_header] = index_or_header
558 else
559 row[index_or_header] = value[i]
560 end
561 end
562 else # repeated value
563 @table.each do |row|
564 if row.header_row?
565 row[index_or_header] = index_or_header
566 else
567 row[index_or_header] = value
568 end
569 end
570 end
571 end
572 end
573
574 #
575 # The mixed mode default is to treat a list of indices as row access,
576 # returning the rows indicated. Anything else is considered columnar
577 # access. For columnar access, the return set has an Array for each row
578 # with the values indicated by the headers in each Array. You can force
579 # column or row mode using by_col!() or by_row!().
580 #
581 # You cannot mix column and row access.
582 #
583 def values_at(*indices_or_headers)
584 if @mode == :row or # by indices
585 ( @mode == :col_or_row and indices_or_headers.all? do |index|
586 index.is_a?(Integer) or
587 ( index.is_a?(Range) and
588 index.first.is_a?(Integer) and
589 index.last.is_a?(Integer) )
590 end )
591 @table.values_at(*indices_or_headers)
592 else # by headers
593 @table.map { |row| row.values_at(*indices_or_headers) }
594 end
595 end
596
597 #
598 # Adds a new row to the bottom end of this table. You can provide an Array,
599 # which will be converted to a FasterCSV::Row (inheriting the table's
600 # headers()), or a FasterCSV::Row.
601 #
602 # This method returns the table for chaining.
603 #
604 def <<(row_or_array)
605 if row_or_array.is_a? Array # append Array
606 @table << Row.new(headers, row_or_array)
607 else # append Row
608 @table << row_or_array
609 end
610
611 self # for chaining
612 end
613
614 #
615 # A shortcut for appending multiple rows. Equivalent to:
616 #
617 # rows.each { |row| self << row }
618 #
619 # This method returns the table for chaining.
620 #
621 def push(*rows)
622 rows.each { |row| self << row }
623
624 self # for chaining
625 end
626
627 #
628 # Removes and returns the indicated column or row. In the default mixed
629 # mode indices refer to rows and everything else is assumed to be a column
630 # header. Use by_col!() or by_row!() to force the lookup.
631 #
632 def delete(index_or_header)
633 if @mode == :row or # by index
634 (@mode == :col_or_row and index_or_header.is_a? Integer)
635 @table.delete_at(index_or_header)
636 else # by header
637 @table.map { |row| row.delete(index_or_header).last }
638 end
639 end
640
641 #
642 # Removes any column or row for which the block returns +true+. In the
643 # default mixed mode or row mode, iteration is the standard row major
644 # walking of rows. In column mode, interation will +yield+ two element
645 # tuples containing the column name and an Array of values for that column.
646 #
647 # This method returns the table for chaining.
648 #
649 def delete_if(&block)
650 if @mode == :row or @mode == :col_or_row # by index
651 @table.delete_if(&block)
652 else # by header
653 to_delete = Array.new
654 headers.each_with_index do |header, i|
655 to_delete << header if block[[header, self[header]]]
656 end
657 to_delete.map { |header| delete(header) }
658 end
659
660 self # for chaining
661 end
662
663 include Enumerable
664
665 #
666 # In the default mixed mode or row mode, iteration is the standard row major
667 # walking of rows. In column mode, interation will +yield+ two element
668 # tuples containing the column name and an Array of values for that column.
669 #
670 # This method returns the table for chaining.
671 #
672 def each(&block)
673 if @mode == :col
674 headers.each { |header| block[[header, self[header]]] }
675 else
676 @table.each(&block)
677 end
678
679 self # for chaining
680 end
681
682 # Returns +true+ if all rows of this table ==() +other+'s rows.
683 def ==(other)
684 @table == other.table
685 end
686
687 #
688 # Returns the table as an Array of Arrays. Headers will be the first row,
689 # then all of the field rows will follow.
690 #
691 def to_a
692 @table.inject([headers]) do |array, row|
693 if row.header_row?
694 array
695 else
696 array + [row.fields]
697 end
698 end
699 end
700
701 #
702 # Returns the table as a complete CSV String. Headers will be listed first,
703 # then all of the field rows.
704 #
705 def to_csv(options = Hash.new)
706 @table.inject([headers.to_csv(options)]) do |rows, row|
707 if row.header_row?
708 rows
709 else
710 rows + [row.fields.to_csv(options)]
711 end
712 end.join
713 end
714 alias_method :to_s, :to_csv
715
716 def inspect
717 "#<#{self.class} mode:#{@mode} row_count:#{to_a.size}>"
718 end
719 end
720
721 # The error thrown when the parser encounters illegal CSV formatting.
722 class MalformedCSVError < RuntimeError; end
723
724 #
725 # A FieldInfo Struct contains details about a field's position in the data
726 # source it was read from. FasterCSV will pass this Struct to some blocks
727 # that make decisions based on field structure. See
728 # FasterCSV.convert_fields() for an example.
729 #
730 # <b><tt>index</tt></b>:: The zero-based index of the field in its row.
731 # <b><tt>line</tt></b>:: The line of the data source this row is from.
732 # <b><tt>header</tt></b>:: The header for the column, when available.
733 #
734 FieldInfo = Struct.new(:index, :line, :header)
735
736 # A Regexp used to find and convert some common Date formats.
737 DateMatcher = / \A(?: (\w+,?\s+)?\w+\s+\d{1,2},?\s+\d{2,4} |
738 \d{4}-\d{2}-\d{2} )\z /x
739 # A Regexp used to find and convert some common DateTime formats.
740 DateTimeMatcher =
741 / \A(?: (\w+,?\s+)?\w+\s+\d{1,2}\s+\d{1,2}:\d{1,2}:\d{1,2},?\s+\d{2,4} |
742 \d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2} )\z /x
743 #
744 # This Hash holds the built-in converters of FasterCSV that can be accessed by
745 # name. You can select Converters with FasterCSV.convert() or through the
746 # +options+ Hash passed to FasterCSV::new().
747 #
748 # <b><tt>:integer</tt></b>:: Converts any field Integer() accepts.
749 # <b><tt>:float</tt></b>:: Converts any field Float() accepts.
750 # <b><tt>:numeric</tt></b>:: A combination of <tt>:integer</tt>
751 # and <tt>:float</tt>.
752 # <b><tt>:date</tt></b>:: Converts any field Date::parse() accepts.
753 # <b><tt>:date_time</tt></b>:: Converts any field DateTime::parse() accepts.
754 # <b><tt>:all</tt></b>:: All built-in converters. A combination of
755 # <tt>:date_time</tt> and <tt>:numeric</tt>.
756 #
757 # This Hash is intetionally left unfrozen and users should feel free to add
758 # values to it that can be accessed by all FasterCSV objects.
759 #
760 # To add a combo field, the value should be an Array of names. Combo fields
761 # can be nested with other combo fields.
762 #
763 Converters = { :integer => lambda { |f| Integer(f) rescue f },
764 :float => lambda { |f| Float(f) rescue f },
765 :numeric => [:integer, :float],
766 :date => lambda { |f|
767 f =~ DateMatcher ? (Date.parse(f) rescue f) : f
768 },
769 :date_time => lambda { |f|
770 f =~ DateTimeMatcher ? (DateTime.parse(f) rescue f) : f
771 },
772 :all => [:date_time, :numeric] }
773
774 #
775 # This Hash holds the built-in header converters of FasterCSV that can be
776 # accessed by name. You can select HeaderConverters with
777 # FasterCSV.header_convert() or through the +options+ Hash passed to
778 # FasterCSV::new().
779 #
780 # <b><tt>:downcase</tt></b>:: Calls downcase() on the header String.
781 # <b><tt>:symbol</tt></b>:: The header String is downcased, spaces are
782 # replaced with underscores, non-word characters
783 # are dropped, and finally to_sym() is called.
784 #
785 # This Hash is intetionally left unfrozen and users should feel free to add
786 # values to it that can be accessed by all FasterCSV objects.
787 #
788 # To add a combo field, the value should be an Array of names. Combo fields
789 # can be nested with other combo fields.
790 #
791 HeaderConverters = {
792 :downcase => lambda { |h| h.downcase },
793 :symbol => lambda { |h|
794 h.downcase.tr(" ", "_").delete("^a-z0-9_").to_sym
795 }
796 }
797
798 #
799 # The options used when no overrides are given by calling code. They are:
800 #
801 # <b><tt>:col_sep</tt></b>:: <tt>","</tt>
802 # <b><tt>:row_sep</tt></b>:: <tt>:auto</tt>
803 # <b><tt>:quote_char</tt></b>:: <tt>'"'</tt>
804 # <b><tt>:converters</tt></b>:: +nil+
805 # <b><tt>:unconverted_fields</tt></b>:: +nil+
806 # <b><tt>:headers</tt></b>:: +false+
807 # <b><tt>:return_headers</tt></b>:: +false+
808 # <b><tt>:header_converters</tt></b>:: +nil+
809 # <b><tt>:skip_blanks</tt></b>:: +false+
810 # <b><tt>:force_quotes</tt></b>:: +false+
811 #
812 DEFAULT_OPTIONS = { :col_sep => ",",
813 :row_sep => :auto,
814 :quote_char => '"',
815 :converters => nil,
816 :unconverted_fields => nil,
817 :headers => false,
818 :return_headers => false,
819 :header_converters => nil,
820 :skip_blanks => false,
821 :force_quotes => false }.freeze
822
823 #
824 # This method will build a drop-in replacement for many of the standard CSV
825 # methods. It allows you to write code like:
826 #
827 # begin
828 # require "faster_csv"
829 # FasterCSV.build_csv_interface
830 # rescue LoadError
831 # require "csv"
832 # end
833 # # ... use CSV here ...
834 #
835 # This is not a complete interface with completely identical behavior.
836 # However, it is intended to be close enough that you won't notice the
837 # difference in most cases. CSV methods supported are:
838 #
839 # * foreach()
840 # * generate_line()
841 # * open()
842 # * parse()
843 # * parse_line()
844 # * readlines()
845 #
846 # Be warned that this interface is slower than vanilla FasterCSV due to the
847 # extra layer of method calls. Depending on usage, this can slow it down to
848 # near CSV speeds.
849 #
850 def self.build_csv_interface
851 Object.const_set(:CSV, Class.new).class_eval do
852 def self.foreach(path, rs = :auto, &block) # :nodoc:
853 FasterCSV.foreach(path, :row_sep => rs, &block)
854 end
855
856 def self.generate_line(row, fs = ",", rs = "") # :nodoc:
857 FasterCSV.generate_line(row, :col_sep => fs, :row_sep => rs)
858 end
859
860 def self.open(path, mode, fs = ",", rs = :auto, &block) # :nodoc:
861 if block and mode.include? "r"
862 FasterCSV.open(path, mode, :col_sep => fs, :row_sep => rs) do |csv|
863 csv.each(&block)
864 end
865 else
866 FasterCSV.open(path, mode, :col_sep => fs, :row_sep => rs, &block)
867 end
868 end
869
870 def self.parse(str_or_readable, fs = ",", rs = :auto, &block) # :nodoc:
871 FasterCSV.parse(str_or_readable, :col_sep => fs, :row_sep => rs, &block)
872 end
873
874 def self.parse_line(src, fs = ",", rs = :auto) # :nodoc:
875 FasterCSV.parse_line(src, :col_sep => fs, :row_sep => rs)
876 end
877
878 def self.readlines(path, rs = :auto) # :nodoc:
879 FasterCSV.readlines(path, :row_sep => rs)
880 end
881 end
882 end
883
884 #
885 # This method allows you to serialize an Array of Ruby objects to a String or
886 # File of CSV data. This is not as powerful as Marshal or YAML, but perhaps
887 # useful for spreadsheet and database interaction.
888 #
889 # Out of the box, this method is intended to work with simple data objects or
890 # Structs. It will serialize a list of instance variables and/or
891 # Struct.members().
892 #
893 # If you need need more complicated serialization, you can control the process
894 # by adding methods to the class to be serialized.
895 #
896 # A class method csv_meta() is responsible for returning the first row of the
897 # document (as an Array). This row is considered to be a Hash of the form
898 # key_1,value_1,key_2,value_2,... FasterCSV::load() expects to find a class
899 # key with a value of the stringified class name and FasterCSV::dump() will
900 # create this, if you do not define this method. This method is only called
901 # on the first object of the Array.
902 #
903 # The next method you can provide is an instance method called csv_headers().
904 # This method is expected to return the second line of the document (again as
905 # an Array), which is to be used to give each column a header. By default,
906 # FasterCSV::load() will set an instance variable if the field header starts
907 # with an @ character or call send() passing the header as the method name and
908 # the field value as an argument. This method is only called on the first
909 # object of the Array.
910 #
911 # Finally, you can provide an instance method called csv_dump(), which will
912 # be passed the headers. This should return an Array of fields that can be
913 # serialized for this object. This method is called once for every object in
914 # the Array.
915 #
916 # The +io+ parameter can be used to serialize to a File, and +options+ can be
917 # anything FasterCSV::new() accepts.
918 #
919 def self.dump(ary_of_objs, io = "", options = Hash.new)
920 obj_template = ary_of_objs.first
921
922 csv = FasterCSV.new(io, options)
923
924 # write meta information
925 begin
926 csv << obj_template.class.csv_meta
927 rescue NoMethodError
928 csv << [:class, obj_template.class]
929 end
930
931 # write headers
932 begin
933 headers = obj_template.csv_headers
934 rescue NoMethodError
935 headers = obj_template.instance_variables.sort
936 if obj_template.class.ancestors.find { |cls| cls.to_s =~ /\AStruct\b/ }
937 headers += obj_template.members.map { |mem| "#{mem}=" }.sort
938 end
939 end
940 csv << headers
941
942 # serialize each object
943 ary_of_objs.each do |obj|
944 begin
945 csv << obj.csv_dump(headers)
946 rescue NoMethodError
947 csv << headers.map do |var|
948 if var[0] == ?@
949 obj.instance_variable_get(var)
950 else
951 obj[var[0..-2]]
952 end
953 end
954 end
955 end
956
957 if io.is_a? String
958 csv.string
959 else
960 csv.close
961 end
962 end
963
964 #
965 # :call-seq:
966 # filter( options = Hash.new ) { |row| ... }
967 # filter( input, options = Hash.new ) { |row| ... }
968 # filter( input, output, options = Hash.new ) { |row| ... }
969 #
970 # This method is a convenience for building Unix-like filters for CSV data.
971 # Each row is yielded to the provided block which can alter it as needed.
972 # After the block returns, the row is appended to +output+ altered or not.
973 #
974 # The +input+ and +output+ arguments can be anything FasterCSV::new() accepts
975 # (generally String or IO objects). If not given, they default to
976 # <tt>ARGF</tt> and <tt>$stdout</tt>.
977 #
978 # The +options+ parameter is also filtered down to FasterCSV::new() after some
979 # clever key parsing. Any key beginning with <tt>:in_</tt> or
980 # <tt>:input_</tt> will have that leading identifier stripped and will only
981 # be used in the +options+ Hash for the +input+ object. Keys starting with
982 # <tt>:out_</tt> or <tt>:output_</tt> affect only +output+. All other keys
983 # are assigned to both objects.
984 #
985 # The <tt>:output_row_sep</tt> +option+ defaults to
986 # <tt>$INPUT_RECORD_SEPARATOR</tt> (<tt>$/</tt>).
987 #
988 def self.filter(*args)
989 # parse options for input, output, or both
990 in_options, out_options = Hash.new, {:row_sep => $INPUT_RECORD_SEPARATOR}
991 if args.last.is_a? Hash
992 args.pop.each do |key, value|
993 case key.to_s
994 when /\Ain(?:put)?_(.+)\Z/
995 in_options[$1.to_sym] = value
996 when /\Aout(?:put)?_(.+)\Z/
997 out_options[$1.to_sym] = value
998 else
999 in_options[key] = value
1000 out_options[key] = value
1001 end
1002 end
1003 end
1004 # build input and output wrappers
1005 input = FasterCSV.new(args.shift || ARGF, in_options)
1006 output = FasterCSV.new(args.shift || $stdout, out_options)
1007
1008 # read, yield, write
1009 input.each do |row|
1010 yield row
1011 output << row
1012 end
1013 end
1014
1015 #
1016 # This method is intended as the primary interface for reading CSV files. You
1017 # pass a +path+ and any +options+ you wish to set for the read. Each row of
1018 # file will be passed to the provided +block+ in turn.
1019 #
1020 # The +options+ parameter can be anything FasterCSV::new() understands.
1021 #
1022 def self.foreach(path, options = Hash.new, &block)
1023 open(path, "rb", options) do |csv|
1024 csv.each(&block)
1025 end
1026 end
1027
1028 #
1029 # :call-seq:
1030 # generate( str, options = Hash.new ) { |faster_csv| ... }
1031 # generate( options = Hash.new ) { |faster_csv| ... }
1032 #
1033 # This method wraps a String you provide, or an empty default String, in a
1034 # FasterCSV object which is passed to the provided block. You can use the
1035 # block to append CSV rows to the String and when the block exits, the
1036 # final String will be returned.
1037 #
1038 # Note that a passed String *is* modfied by this method. Call dup() before
1039 # passing if you need a new String.
1040 #
1041 # The +options+ parameter can be anthing FasterCSV::new() understands.
1042 #
1043 def self.generate(*args)
1044 # add a default empty String, if none was given
1045 if args.first.is_a? String
1046 io = StringIO.new(args.shift)
1047 io.seek(0, IO::SEEK_END)
1048 args.unshift(io)
1049 else
1050 args.unshift("")
1051 end
1052 faster_csv = new(*args) # wrap
1053 yield faster_csv # yield for appending
1054 faster_csv.string # return final String
1055 end
1056
1057 #
1058 # This method is a shortcut for converting a single row (Array) into a CSV
1059 # String.
1060 #
1061 # The +options+ parameter can be anthing FasterCSV::new() understands.
1062 #
1063 # The <tt>:row_sep</tt> +option+ defaults to <tt>$INPUT_RECORD_SEPARATOR</tt>
1064 # (<tt>$/</tt>) when calling this method.
1065 #
1066 def self.generate_line(row, options = Hash.new)
1067 options = {:row_sep => $INPUT_RECORD_SEPARATOR}.merge(options)
1068 (new("", options) << row).string
1069 end
1070
1071 #
1072 # This method will return a FasterCSV instance, just like FasterCSV::new(),
1073 # but the instance will be cached and returned for all future calls to this
1074 # method for the same +data+ object (tested by Object#object_id()) with the
1075 # same +options+.
1076 #
1077 # If a block is given, the instance is passed to the block and the return
1078 # value becomes the return value of the block.
1079 #
1080 def self.instance(data = $stdout, options = Hash.new)
1081 # create a _signature_ for this method call, data object and options
1082 sig = [data.object_id] +
1083 options.values_at(*DEFAULT_OPTIONS.keys.sort_by { |sym| sym.to_s })
1084
1085 # fetch or create the instance for this signature
1086 @@instances ||= Hash.new
1087 instance = (@@instances[sig] ||= new(data, options))
1088
1089 if block_given?
1090 yield instance # run block, if given, returning result
1091 else
1092 instance # or return the instance
1093 end
1094 end
1095
1096 #
1097 # This method is the reading counterpart to FasterCSV::dump(). See that
1098 # method for a detailed description of the process.
1099 #
1100 # You can customize loading by adding a class method called csv_load() which
1101 # will be passed a Hash of meta information, an Array of headers, and an Array
1102 # of fields for the object the method is expected to return.
1103 #
1104 # Remember that all fields will be Strings after this load. If you need
1105 # something else, use +options+ to setup converters or provide a custom
1106 # csv_load() implementation.
1107 #
1108 def self.load(io_or_str, options = Hash.new)
1109 csv = FasterCSV.new(io_or_str, options)
1110
1111 # load meta information
1112 meta = Hash[*csv.shift]
1113 cls = meta["class"].split("::").inject(Object) do |c, const|
1114 c.const_get(const)
1115 end
1116
1117 # load headers
1118 headers = csv.shift
1119
1120 # unserialize each object stored in the file
1121 results = csv.inject(Array.new) do |all, row|
1122 begin
1123 obj = cls.csv_load(meta, headers, row)
1124 rescue NoMethodError
1125 obj = cls.allocate
1126 headers.zip(row) do |name, value|
1127 if name[0] == ?@
1128 obj.instance_variable_set(name, value)
1129 else
1130 obj.send(name, value)
1131 end
1132 end
1133 end
1134 all << obj
1135 end
1136
1137 csv.close unless io_or_str.is_a? String
1138
1139 results
1140 end
1141
1142 #
1143 # :call-seq:
1144 # open( filename, mode="rb", options = Hash.new ) { |faster_csv| ... }
1145 # open( filename, mode="rb", options = Hash.new )
1146 #
1147 # This method opens an IO object, and wraps that with FasterCSV. This is
1148 # intended as the primary interface for writing a CSV file.
1149 #
1150 # You may pass any +args+ Ruby's open() understands followed by an optional
1151 # Hash containing any +options+ FasterCSV::new() understands.
1152 #
1153 # This method works like Ruby's open() call, in that it will pass a FasterCSV
1154 # object to a provided block and close it when the block termminates, or it
1155 # will return the FasterCSV object when no block is provided. (*Note*: This
1156 # is different from the standard CSV library which passes rows to the block.
1157 # Use FasterCSV::foreach() for that behavior.)
1158 #
1159 # An opened FasterCSV object will delegate to many IO methods, for
1160 # convenience. You may call:
1161 #
1162 # * binmode()
1163 # * close()
1164 # * close_read()
1165 # * close_write()
1166 # * closed?()
1167 # * eof()
1168 # * eof?()
1169 # * fcntl()
1170 # * fileno()
1171 # * flush()
1172 # * fsync()
1173 # * ioctl()
1174 # * isatty()
1175 # * pid()
1176 # * pos()
1177 # * reopen()
1178 # * seek()
1179 # * stat()
1180 # * sync()
1181 # * sync=()
1182 # * tell()
1183 # * to_i()
1184 # * to_io()
1185 # * tty?()
1186 #
1187 def self.open(*args)
1188 # find the +options+ Hash
1189 options = if args.last.is_a? Hash then args.pop else Hash.new end
1190 # default to a binary open mode
1191 args << "rb" if args.size == 1
1192 # wrap a File opened with the remaining +args+
1193 csv = new(File.open(*args), options)
1194
1195 # handle blocks like Ruby's open(), not like the CSV library
1196 if block_given?
1197 begin
1198 yield csv
1199 ensure
1200 csv.close
1201 end
1202 else
1203 csv
1204 end
1205 end
1206
1207 #
1208 # :call-seq:
1209 # parse( str, options = Hash.new ) { |row| ... }
1210 # parse( str, options = Hash.new )
1211 #
1212 # This method can be used to easily parse CSV out of a String. You may either
1213 # provide a +block+ which will be called with each row of the String in turn,
1214 # or just use the returned Array of Arrays (when no +block+ is given).
1215 #
1216 # You pass your +str+ to read from, and an optional +options+ Hash containing
1217 # anything FasterCSV::new() understands.
1218 #
1219 def self.parse(*args, &block)
1220 csv = new(*args)
1221 if block.nil? # slurp contents, if no block is given
1222 begin
1223 csv.read
1224 ensure
1225 csv.close
1226 end
1227 else # or pass each row to a provided block
1228 csv.each(&block)
1229 end
1230 end
1231
1232 #
1233 # This method is a shortcut for converting a single line of a CSV String into
1234 # a into an Array. Note that if +line+ contains multiple rows, anything
1235 # beyond the first row is ignored.
1236 #
1237 # The +options+ parameter can be anthing FasterCSV::new() understands.
1238 #
1239 def self.parse_line(line, options = Hash.new)
1240 new(line, options).shift
1241 end
1242
1243 #
1244 # Use to slurp a CSV file into an Array of Arrays. Pass the +path+ to the
1245 # file and any +options+ FasterCSV::new() understands.
1246 #
1247 def self.read(path, options = Hash.new)
1248 open(path, "rb", options) { |csv| csv.read }
1249 end
1250
1251 # Alias for FasterCSV::read().
1252 def self.readlines(*args)
1253 read(*args)
1254 end
1255
1256 #
1257 # A shortcut for:
1258 #
1259 # FasterCSV.read( path, { :headers => true,
1260 # :converters => :numeric,
1261 # :header_converters => :symbol }.merge(options) )
1262 #
1263 def self.table(path, options = Hash.new)
1264 read( path, { :headers => true,
1265 :converters => :numeric,
1266 :header_converters => :symbol }.merge(options) )
1267 end
1268
1269 #
1270 # This constructor will wrap either a String or IO object passed in +data+ for
1271 # reading and/or writing. In addition to the FasterCSV instance methods,
1272 # several IO methods are delegated. (See FasterCSV::open() for a complete
1273 # list.) If you pass a String for +data+, you can later retrieve it (after
1274 # writing to it, for example) with FasterCSV.string().
1275 #
1276 # Note that a wrapped String will be positioned at at the beginning (for
1277 # reading). If you want it at the end (for writing), use
1278 # FasterCSV::generate(). If you want any other positioning, pass a preset
1279 # StringIO object instead.
1280 #
1281 # You may set any reading and/or writing preferences in the +options+ Hash.
1282 # Available options are:
1283 #
1284 # <b><tt>:col_sep</tt></b>:: The String placed between each field.
1285 # <b><tt>:row_sep</tt></b>:: The String appended to the end of each
1286 # row. This can be set to the special
1287 # <tt>:auto</tt> setting, which requests
1288 # that FasterCSV automatically discover
1289 # this from the data. Auto-discovery
1290 # reads ahead in the data looking for
1291 # the next <tt>"\r\n"</tt>,
1292 # <tt>"\n"</tt>, or <tt>"\r"</tt>
1293 # sequence. A sequence will be selected
1294 # even if it occurs in a quoted field,
1295 # assuming that you would have the same
1296 # line endings there. If none of those
1297 # sequences is found, +data+ is
1298 # <tt>ARGF</tt>, <tt>STDIN</tt>,
1299 # <tt>STDOUT</tt>, or <tt>STDERR</tt>,
1300 # or the stream is only available for
1301 # output, the default
1302 # <tt>$INPUT_RECORD_SEPARATOR</tt>
1303 # (<tt>$/</tt>) is used. Obviously,
1304 # discovery takes a little time. Set
1305 # manually if speed is important. Also
1306 # note that IO objects should be opened
1307 # in binary mode on Windows if this
1308 # feature will be used as the
1309 # line-ending translation can cause
1310 # problems with resetting the document
1311 # position to where it was before the
1312 # read ahead.
1313 # <b><tt>:quote_char</tt></b>:: The character used to quote fields.
1314 # This has to be a single character
1315 # String. This is useful for
1316 # application that incorrectly use
1317 # <tt>'</tt> as the quote character
1318 # instead of the correct <tt>"</tt>.
1319 # FasterCSV will always consider a
1320 # double sequence this character to be
1321 # an escaped quote.
1322 # <b><tt>:encoding</tt></b>:: The encoding to use when parsing the
1323 # file. Defaults to your <tt>$KDOCE</tt>
1324 # setting. Valid values: <tt>`n’</tt> or
1325 # <tt>`N’</tt> for none, <tt>`e’</tt> or
1326 # <tt>`E’</tt> for EUC, <tt>`s’</tt> or
1327 # <tt>`S’</tt> for SJIS, and
1328 # <tt>`u’</tt> or <tt>`U’</tt> for UTF-8
1329 # (see Regexp.new()).
1330 # <b><tt>:field_size_limit</tt></b>:: This is a maximum size FasterCSV will
1331 # read ahead looking for the closing
1332 # quote for a field. (In truth, it
1333 # reads to the first line ending beyond
1334 # this size.) If a quote cannot be
1335 # found within the limit FasterCSV will
1336 # raise a MalformedCSVError, assuming
1337 # the data is faulty. You can use this
1338 # limit to prevent what are effectively
1339 # DoS attacks on the parser. However,
1340 # this limit can cause a legitimate
1341 # parse to fail and thus is set to
1342 # +nil+, or off, by default.
1343 # <b><tt>:converters</tt></b>:: An Array of names from the Converters
1344 # Hash and/or lambdas that handle custom
1345 # conversion. A single converter
1346 # doesn't have to be in an Array.
1347 # <b><tt>:unconverted_fields</tt></b>:: If set to +true+, an
1348 # unconverted_fields() method will be
1349 # added to all returned rows (Array or
1350 # FasterCSV::Row) that will return the
1351 # fields as they were before convertion.
1352 # Note that <tt>:headers</tt> supplied
1353 # by Array or String were not fields of
1354 # the document and thus will have an
1355 # empty Array attached.
1356 # <b><tt>:headers</tt></b>:: If set to <tt>:first_row</tt> or
1357 # +true+, the initial row of the CSV
1358 # file will be treated as a row of
1359 # headers. If set to an Array, the
1360 # contents will be used as the headers.
1361 # If set to a String, the String is run
1362 # through a call of
1363 # FasterCSV::parse_line() with the same
1364 # <tt>:col_sep</tt>, <tt>:row_sep</tt>,
1365 # and <tt>:quote_char</tt> as this
1366 # instance to produce an Array of
1367 # headers. This setting causes
1368 # FasterCSV.shift() to return rows as
1369 # FasterCSV::Row objects instead of
1370 # Arrays and FasterCSV.read() to return
1371 # FasterCSV::Table objects instead of
1372 # an Array of Arrays.
1373 # <b><tt>:return_headers</tt></b>:: When +false+, header rows are silently
1374 # swallowed. If set to +true+, header
1375 # rows are returned in a FasterCSV::Row
1376 # object with identical headers and
1377 # fields (save that the fields do not go
1378 # through the converters).
1379 # <b><tt>:write_headers</tt></b>:: When +true+ and <tt>:headers</tt> is
1380 # set, a header row will be added to the
1381 # output.
1382 # <b><tt>:header_converters</tt></b>:: Identical in functionality to
1383 # <tt>:converters</tt> save that the
1384 # conversions are only made to header
1385 # rows.
1386 # <b><tt>:skip_blanks</tt></b>:: When set to a +true+ value, FasterCSV
1387 # will skip over any rows with no
1388 # content.
1389 # <b><tt>:force_quotes</tt></b>:: When set to a +true+ value, FasterCSV
1390 # will quote all CSV fields it creates.
1391 #
1392 # See FasterCSV::DEFAULT_OPTIONS for the default settings.
1393 #
1394 # Options cannot be overriden in the instance methods for performance reasons,
1395 # so be sure to set what you want here.
1396 #
1397 def initialize(data, options = Hash.new)
1398 # build the options for this read/write
1399 options = DEFAULT_OPTIONS.merge(options)
1400
1401 # create the IO object we will read from
1402 @io = if data.is_a? String then StringIO.new(data) else data end
1403
1404 init_separators(options)
1405 init_parsers(options)
1406 init_converters(options)
1407 init_headers(options)
1408
1409 unless options.empty?
1410 raise ArgumentError, "Unknown options: #{options.keys.join(', ')}."
1411 end
1412
1413 # track our own lineno since IO gets confused about line-ends is CSV fields
1414 @lineno = 0
1415 end
1416
1417 #
1418 # The line number of the last row read from this file. Fields with nested
1419 # line-end characters will not affect this count.
1420 #
1421 attr_reader :lineno
1422
1423 ### IO and StringIO Delegation ###
1424
1425 extend Forwardable
1426 def_delegators :@io, :binmode, :close, :close_read, :close_write, :closed?,
1427 :eof, :eof?, :fcntl, :fileno, :flush, :fsync, :ioctl,
1428 :isatty, :pid, :pos, :reopen, :seek, :stat, :string,
1429 :sync, :sync=, :tell, :to_i, :to_io, :tty?
1430
1431 # Rewinds the underlying IO object and resets FasterCSV's lineno() counter.
1432 def rewind
1433 @headers = nil
1434 @lineno = 0
1435
1436 @io.rewind
1437 end
1438
1439 ### End Delegation ###
1440
1441 #
1442 # The primary write method for wrapped Strings and IOs, +row+ (an Array or
1443 # FasterCSV::Row) is converted to CSV and appended to the data source. When a
1444 # FasterCSV::Row is passed, only the row's fields() are appended to the
1445 # output.
1446 #
1447 # The data source must be open for writing.
1448 #
1449 def <<(row)
1450 # make sure headers have been assigned
1451 if header_row? and [Array, String].include? @use_headers.class
1452 parse_headers # won't read data for Array or String
1453 self << @headers if @write_headers
1454 end
1455
1456 # Handle FasterCSV::Row objects and Hashes
1457 row = case row
1458 when self.class::Row then row.fields
1459 when Hash then @headers.map { |header| row[header] }
1460 else row
1461 end
1462
1463 @headers = row if header_row?
1464 @lineno += 1
1465
1466 @io << row.map(&@quote).join(@col_sep) + @row_sep # quote and separate
1467
1468 self # for chaining
1469 end
1470 alias_method :add_row, :<<
1471 alias_method :puts, :<<
1472
1473 #
1474 # :call-seq:
1475 # convert( name )
1476 # convert { |field| ... }
1477 # convert { |field, field_info| ... }
1478 #
1479 # You can use this method to install a FasterCSV::Converters built-in, or
1480 # provide a block that handles a custom conversion.
1481 #
1482 # If you provide a block that takes one argument, it will be passed the field
1483 # and is expected to return the converted value or the field itself. If your
1484 # block takes two arguments, it will also be passed a FieldInfo Struct,
1485 # containing details about the field. Again, the block should return a
1486 # converted field or the field itself.
1487 #
1488 def convert(name = nil, &converter)
1489 add_converter(:converters, self.class::Converters, name, &converter)
1490 end
1491
1492 #
1493 # :call-seq:
1494 # header_convert( name )
1495 # header_convert { |field| ... }
1496 # header_convert { |field, field_info| ... }
1497 #
1498 # Identical to FasterCSV.convert(), but for header rows.
1499 #
1500 # Note that this method must be called before header rows are read to have any
1501 # effect.
1502 #
1503 def header_convert(name = nil, &converter)
1504 add_converter( :header_converters,
1505 self.class::HeaderConverters,
1506 name,
1507 &converter )
1508 end
1509
1510 include Enumerable
1511
1512 #
1513 # Yields each row of the data source in turn.
1514 #
1515 # Support for Enumerable.
1516 #
1517 # The data source must be open for reading.
1518 #
1519 def each
1520 while row = shift
1521 yield row
1522 end
1523 end
1524
1525 #
1526 # Slurps the remaining rows and returns an Array of Arrays.
1527 #
1528 # The data source must be open for reading.
1529 #
1530 def read
1531 rows = to_a
1532 if @use_headers
1533 Table.new(rows)
1534 else
1535 rows
1536 end
1537 end
1538 alias_method :readlines, :read
1539
1540 # Returns +true+ if the next row read will be a header row.
1541 def header_row?
1542 @use_headers and @headers.nil?
1543 end
1544
1545 #
1546 # The primary read method for wrapped Strings and IOs, a single row is pulled
1547 # from the data source, parsed and returned as an Array of fields (if header
1548 # rows are not used) or a FasterCSV::Row (when header rows are used).
1549 #
1550 # The data source must be open for reading.
1551 #
1552 def shift
1553 #########################################################################
1554 ### This method is purposefully kept a bit long as simple conditional ###
1555 ### checks are faster than numerous (expensive) method calls. ###
1556 #########################################################################
1557
1558 # handle headers not based on document content
1559 if header_row? and @return_headers and
1560 [Array, String].include? @use_headers.class
1561 if @unconverted_fields
1562 return add_unconverted_fields(parse_headers, Array.new)
1563 else
1564 return parse_headers
1565 end
1566 end
1567
1568 # begin with a blank line, so we can always add to it
1569 line = String.new
1570
1571 #
1572 # it can take multiple calls to <tt>@io.gets()</tt> to get a full line,
1573 # because of \r and/or \n characters embedded in quoted fields
1574 #
1575 loop do
1576 # add another read to the line
1577 begin
1578 line += @io.gets(@row_sep)
1579 rescue
1580 return nil
1581 end
1582 # copy the line so we can chop it up in parsing
1583 parse = line.dup
1584 parse.sub!(@parsers[:line_end], "")
1585
1586 #
1587 # I believe a blank line should be an <tt>Array.new</tt>, not
1588 # CSV's <tt>[nil]</tt>
1589 #
1590 if parse.empty?
1591 @lineno += 1
1592 if @skip_blanks
1593 line = ""
1594 next
1595 elsif @unconverted_fields
1596 return add_unconverted_fields(Array.new, Array.new)
1597 elsif @use_headers
1598 return FasterCSV::Row.new(Array.new, Array.new)
1599 else
1600 return Array.new
1601 end
1602 end
1603
1604 # parse the fields with a mix of String#split and regular expressions
1605 csv = Array.new
1606 current_field = String.new
1607 field_quotes = 0
1608 parse.split(@col_sep, -1).each do |match|
1609 if current_field.empty? && match.count(@quote_and_newlines).zero?
1610 csv << (match.empty? ? nil : match)
1611 elsif(current_field.empty? ? match[0] : current_field[0]) == @quote_char[0]
1612 current_field << match
1613 field_quotes += match.count(@quote_char)
1614 if field_quotes % 2 == 0
1615 in_quotes = current_field[@parsers[:quoted_field], 1]
1616 raise MalformedCSVError unless in_quotes
1617 current_field = in_quotes
1618 current_field.gsub!(@quote_char * 2, @quote_char) # unescape contents
1619 csv << current_field
1620 current_field = String.new
1621 field_quotes = 0
1622 else # we found a quoted field that spans multiple lines
1623 current_field << @col_sep
1624 end
1625 elsif match.count("\r\n").zero?
1626 raise MalformedCSVError, "Illegal quoting on line #{lineno + 1}."
1627 else
1628 raise MalformedCSVError, "Unquoted fields do not allow " +
1629 "\\r or \\n (line #{lineno + 1})."
1630 end
1631 end
1632
1633 # if parse is empty?(), we found all the fields on the line...
1634 if field_quotes % 2 == 0
1635 @lineno += 1
1636
1637 # save fields unconverted fields, if needed...
1638 unconverted = csv.dup if @unconverted_fields
1639
1640 # convert fields, if needed...
1641 csv = convert_fields(csv) unless @use_headers or @converters.empty?
1642 # parse out header rows and handle FasterCSV::Row conversions...
1643 csv = parse_headers(csv) if @use_headers
1644
1645 # inject unconverted fields and accessor, if requested...
1646 if @unconverted_fields and not csv.respond_to? :unconverted_fields
1647 add_unconverted_fields(csv, unconverted)
1648 end
1649
1650 # return the results
1651 break csv
1652 end
1653 # if we're not empty?() but at eof?(), a quoted field wasn't closed...
1654 if @io.eof?
1655 raise MalformedCSVError, "Unclosed quoted field on line #{lineno + 1}."
1656 elsif @field_size_limit and current_field.size >= @field_size_limit
1657 raise MalformedCSVError, "Field size exceeded on line #{lineno + 1}."
1658 end
1659 # otherwise, we need to loop and pull some more data to complete the row
1660 end
1661 end
1662 alias_method :gets, :shift
1663 alias_method :readline, :shift
1664
1665 # Returns a simplified description of the key FasterCSV attributes.
1666 def inspect
1667 str = "<##{self.class} io_type:"
1668 # show type of wrapped IO
1669 if @io == $stdout then str << "$stdout"
1670 elsif @io == $stdin then str << "$stdin"
1671 elsif @io == $stderr then str << "$stderr"
1672 else str << @io.class.to_s
1673 end
1674 # show IO.path(), if available
1675 if @io.respond_to?(:path) and (p = @io.path)
1676 str << " io_path:#{p.inspect}"
1677 end
1678 # show other attributes
1679 %w[ lineno col_sep row_sep
1680 quote_char skip_blanks encoding ].each do |attr_name|
1681 if a = instance_variable_get("@#{attr_name}")
1682 str << " #{attr_name}:#{a.inspect}"
1683 end
1684 end
1685 if @use_headers
1686 str << " headers:#{(@headers || true).inspect}"
1687 end
1688 str << ">"
1689 end
1690
1691 private
1692
1693 #
1694 # Stores the indicated separators for later use.
1695 #
1696 # If auto-discovery was requested for <tt>@row_sep</tt>, this method will read
1697 # ahead in the <tt>@io</tt> and try to find one. +ARGF+, +STDIN+, +STDOUT+,
1698 # +STDERR+ and any stream open for output only with a default
1699 # <tt>@row_sep</tt> of <tt>$INPUT_RECORD_SEPARATOR</tt> (<tt>$/</tt>).
1700 #
1701 # This method also establishes the quoting rules used for CSV output.
1702 #
1703 def init_separators(options)
1704 # store the selected separators
1705 @col_sep = options.delete(:col_sep)
1706 @row_sep = options.delete(:row_sep)
1707 @quote_char = options.delete(:quote_char)
1708 @quote_and_newlines = "#{@quote_char}\r\n"
1709
1710 if @quote_char.length != 1
1711 raise ArgumentError, ":quote_char has to be a single character String"
1712 end
1713
1714 # automatically discover row separator when requested
1715 if @row_sep == :auto
1716 if [ARGF, STDIN, STDOUT, STDERR].include?(@io) or
1717 (defined?(Zlib) and @io.class == Zlib::GzipWriter)
1718 @row_sep = $INPUT_RECORD_SEPARATOR
1719 else
1720 begin
1721 saved_pos = @io.pos # remember where we were
1722 while @row_sep == :auto
1723 #
1724 # if we run out of data, it's probably a single line
1725 # (use a sensible default)
1726 #
1727 if @io.eof?
1728 @row_sep = $INPUT_RECORD_SEPARATOR
1729 break
1730 end
1731
1732 # read ahead a bit
1733 sample = @io.read(1024)
1734 sample += @io.read(1) if sample[-1..-1] == "\r" and not @io.eof?
1735
1736 # try to find a standard separator
1737 if sample =~ /\r\n?|\n/
1738 @row_sep = $&
1739 break
1740 end
1741 end
1742 # tricky seek() clone to work around GzipReader's lack of seek()
1743 @io.rewind
1744 # reset back to the remembered position
1745 while saved_pos > 1024 # avoid loading a lot of data into memory
1746 @io.read(1024)
1747 saved_pos -= 1024
1748 end
1749 @io.read(saved_pos) if saved_pos.nonzero?
1750 rescue IOError # stream not opened for reading
1751 @row_sep = $INPUT_RECORD_SEPARATOR
1752 end
1753 end
1754 end
1755
1756 # establish quoting rules
1757 do_quote = lambda do |field|
1758 @quote_char +
1759 String(field).gsub(@quote_char, @quote_char * 2) +
1760 @quote_char
1761 end
1762 @quote = if options.delete(:force_quotes)
1763 do_quote
1764 else
1765 lambda do |field|
1766 if field.nil? # represent +nil+ fields as empty unquoted fields
1767 ""
1768 else
1769 field = String(field) # Stringify fields
1770 # represent empty fields as empty quoted fields
1771 if field.empty? or
1772 field.count("\r\n#{@col_sep}#{@quote_char}").nonzero?
1773 do_quote.call(field)
1774 else
1775 field # unquoted field
1776 end
1777 end
1778 end
1779 end
1780 end
1781
1782 # Pre-compiles parsers and stores them by name for access during reads.
1783 def init_parsers(options)
1784 # store the parser behaviors
1785 @skip_blanks = options.delete(:skip_blanks)
1786 @encoding = options.delete(:encoding) # nil will use $KCODE
1787 @field_size_limit = options.delete(:field_size_limit)
1788
1789 # prebuild Regexps for faster parsing
1790 esc_col_sep = Regexp.escape(@col_sep)
1791 esc_row_sep = Regexp.escape(@row_sep)
1792 esc_quote = Regexp.escape(@quote_char)
1793 @parsers = {
1794 :any_field => Regexp.new( "[^#{esc_col_sep}]+",
1795 Regexp::MULTILINE,
1796 @encoding ),
1797 :quoted_field => Regexp.new( "^#{esc_quote}(.*)#{esc_quote}$",
1798 Regexp::MULTILINE,
1799 @encoding ),
1800 # safer than chomp!()
1801 :line_end => Regexp.new("#{esc_row_sep}\\z", nil, @encoding)
1802 }
1803 end
1804
1805 #
1806 # Loads any converters requested during construction.
1807 #
1808 # If +field_name+ is set <tt>:converters</tt> (the default) field converters
1809 # are set. When +field_name+ is <tt>:header_converters</tt> header converters
1810 # are added instead.
1811 #
1812 # The <tt>:unconverted_fields</tt> option is also actived for
1813 # <tt>:converters</tt> calls, if requested.
1814 #
1815 def init_converters(options, field_name = :converters)
1816 if field_name == :converters
1817 @unconverted_fields = options.delete(:unconverted_fields)
1818 end
1819
1820 instance_variable_set("@#{field_name}", Array.new)
1821
1822 # find the correct method to add the coverters
1823 convert = method(field_name.to_s.sub(/ers\Z/, ""))
1824
1825 # load converters
1826 unless options[field_name].nil?
1827 # allow a single converter not wrapped in an Array
1828 unless options[field_name].is_a? Array
1829 options[field_name] = [options[field_name]]
1830 end
1831 # load each converter...
1832 options[field_name].each do |converter|
1833 if converter.is_a? Proc # custom code block
1834 convert.call(&converter)
1835 else # by name
1836 convert.call(converter)
1837 end
1838 end
1839 end
1840
1841 options.delete(field_name)
1842 end
1843
1844 # Stores header row settings and loads header converters, if needed.
1845 def init_headers(options)
1846 @use_headers = options.delete(:headers)
1847 @return_headers = options.delete(:return_headers)
1848 @write_headers = options.delete(:write_headers)
1849
1850 # headers must be delayed until shift(), in case they need a row of content
1851 @headers = nil
1852
1853 init_converters(options, :header_converters)
1854 end
1855
1856 #
1857 # The actual work method for adding converters, used by both
1858 # FasterCSV.convert() and FasterCSV.header_convert().
1859 #
1860 # This method requires the +var_name+ of the instance variable to place the
1861 # converters in, the +const+ Hash to lookup named converters in, and the
1862 # normal parameters of the FasterCSV.convert() and FasterCSV.header_convert()
1863 # methods.
1864 #
1865 def add_converter(var_name, const, name = nil, &converter)
1866 if name.nil? # custom converter
1867 instance_variable_get("@#{var_name}") << converter
1868 else # named converter
1869 combo = const[name]
1870 case combo
1871 when Array # combo converter
1872 combo.each do |converter_name|
1873 add_converter(var_name, const, converter_name)
1874 end
1875 else # individual named converter
1876 instance_variable_get("@#{var_name}") << combo
1877 end
1878 end
1879 end
1880
1881 #
1882 # Processes +fields+ with <tt>@converters</tt>, or <tt>@header_converters</tt>
1883 # if +headers+ is passed as +true+, returning the converted field set. Any
1884 # converter that changes the field into something other than a String halts
1885 # the pipeline of conversion for that field. This is primarily an efficiency
1886 # shortcut.
1887 #
1888 def convert_fields(fields, headers = false)
1889 # see if we are converting headers or fields
1890 converters = headers ? @header_converters : @converters
1891
1892 fields.enum_for(:each_with_index).map do |field, index| # map_with_index
1893 converters.each do |converter|
1894 field = if converter.arity == 1 # straight field converter
1895 converter[field]
1896 else # FieldInfo converter
1897 header = @use_headers && !headers ? @headers[index] : nil
1898 converter[field, FieldInfo.new(index, lineno, header)]
1899 end
1900 break unless field.is_a? String # short-curcuit pipeline for speed
1901 end
1902 field # return final state of each field, converted or original
1903 end
1904 end
1905
1906 #
1907 # This methods is used to turn a finished +row+ into a FasterCSV::Row. Header
1908 # rows are also dealt with here, either by returning a FasterCSV::Row with
1909 # identical headers and fields (save that the fields do not go through the
1910 # converters) or by reading past them to return a field row. Headers are also
1911 # saved in <tt>@headers</tt> for use in future rows.
1912 #
1913 # When +nil+, +row+ is assumed to be a header row not based on an actual row
1914 # of the stream.
1915 #
1916 def parse_headers(row = nil)
1917 if @headers.nil? # header row
1918 @headers = case @use_headers # save headers
1919 # Array of headers
1920 when Array then @use_headers
1921 # CSV header String
1922 when String
1923 self.class.parse_line( @use_headers,
1924 :col_sep => @col_sep,
1925 :row_sep => @row_sep,
1926 :quote_char => @quote_char )
1927 # first row is headers
1928 else row
1929 end
1930
1931 # prepare converted and unconverted copies
1932 row = @headers if row.nil?
1933 @headers = convert_fields(@headers, true)
1934
1935 if @return_headers # return headers
1936 return FasterCSV::Row.new(@headers, row, true)
1937 elsif not [Array, String].include? @use_headers.class # skip to field row
1938 return shift
1939 end
1940 end
1941
1942 FasterCSV::Row.new(@headers, convert_fields(row)) # field row
1943 end
1944
1945 #
1946 # Thiw methods injects an instance variable <tt>unconverted_fields</tt> into
1947 # +row+ and an accessor method for it called unconverted_fields(). The
1948 # variable is set to the contents of +fields+.
1949 #
1950 def add_unconverted_fields(row, fields)
1951 class << row
1952 attr_reader :unconverted_fields
1953 end
1954 row.instance_eval { @unconverted_fields = fields }
1955 row
1956 end
1957 end
1958
1959 # Another name for FasterCSV.
1960 FCSV = FasterCSV
1961
1962 # Another name for FasterCSV::instance().
1963 def FasterCSV(*args, &block)
1964 FasterCSV.instance(*args, &block)
1965 end
1966
1967 # Another name for FCSV::instance().
1968 def FCSV(*args, &block)
1969 FCSV.instance(*args, &block)
1970 end
1971
1972 class Array
1973 # Equivalent to <tt>FasterCSV::generate_line(self, options)</tt>.
1974 def to_csv(options = Hash.new)
1975 FasterCSV.generate_line(self, options)
1976 end
1977 end
1978
1979 class String
1980 # Equivalent to <tt>FasterCSV::parse_line(self, options)</tt>.
1981 def parse_csv(options = Hash.new)
1982 FasterCSV.parse_line(self, options)
1983 end
1984 end
@@ -1,513 +1,513
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2008 Jean-Philippe Lang
2 # Copyright (C) 2006-2008 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class IssuesController < ApplicationController
18 class IssuesController < ApplicationController
19 menu_item :new_issue, :only => :new
19 menu_item :new_issue, :only => :new
20 default_search_scope :issues
20 default_search_scope :issues
21
21
22 before_filter :find_issue, :only => [:show, :edit, :reply]
22 before_filter :find_issue, :only => [:show, :edit, :reply]
23 before_filter :find_issues, :only => [:bulk_edit, :move, :destroy]
23 before_filter :find_issues, :only => [:bulk_edit, :move, :destroy]
24 before_filter :find_project, :only => [:new, :update_form, :preview]
24 before_filter :find_project, :only => [:new, :update_form, :preview]
25 before_filter :authorize, :except => [:index, :changes, :gantt, :calendar, :preview, :update_form, :context_menu]
25 before_filter :authorize, :except => [:index, :changes, :gantt, :calendar, :preview, :update_form, :context_menu]
26 before_filter :find_optional_project, :only => [:index, :changes, :gantt, :calendar]
26 before_filter :find_optional_project, :only => [:index, :changes, :gantt, :calendar]
27 accept_key_auth :index, :show, :changes
27 accept_key_auth :index, :show, :changes
28
28
29 helper :journals
29 helper :journals
30 helper :projects
30 helper :projects
31 include ProjectsHelper
31 include ProjectsHelper
32 helper :custom_fields
32 helper :custom_fields
33 include CustomFieldsHelper
33 include CustomFieldsHelper
34 helper :issue_relations
34 helper :issue_relations
35 include IssueRelationsHelper
35 include IssueRelationsHelper
36 helper :watchers
36 helper :watchers
37 include WatchersHelper
37 include WatchersHelper
38 helper :attachments
38 helper :attachments
39 include AttachmentsHelper
39 include AttachmentsHelper
40 helper :queries
40 helper :queries
41 helper :sort
41 helper :sort
42 include SortHelper
42 include SortHelper
43 include IssuesHelper
43 include IssuesHelper
44 helper :timelog
44 helper :timelog
45 include Redmine::Export::PDF
45 include Redmine::Export::PDF
46
46
47 verify :method => :post,
47 verify :method => :post,
48 :only => :destroy,
48 :only => :destroy,
49 :render => { :nothing => true, :status => :method_not_allowed }
49 :render => { :nothing => true, :status => :method_not_allowed }
50
50
51 def index
51 def index
52 retrieve_query
52 retrieve_query
53 sort_init(@query.sort_criteria.empty? ? [['id', 'desc']] : @query.sort_criteria)
53 sort_init(@query.sort_criteria.empty? ? [['id', 'desc']] : @query.sort_criteria)
54 sort_update({'id' => "#{Issue.table_name}.id"}.merge(@query.available_columns.inject({}) {|h, c| h[c.name.to_s] = c.sortable; h}))
54 sort_update({'id' => "#{Issue.table_name}.id"}.merge(@query.available_columns.inject({}) {|h, c| h[c.name.to_s] = c.sortable; h}))
55
55
56 if @query.valid?
56 if @query.valid?
57 limit = per_page_option
57 limit = per_page_option
58 respond_to do |format|
58 respond_to do |format|
59 format.html { }
59 format.html { }
60 format.atom { limit = Setting.feeds_limit.to_i }
60 format.atom { limit = Setting.feeds_limit.to_i }
61 format.csv { limit = Setting.issues_export_limit.to_i }
61 format.csv { limit = Setting.issues_export_limit.to_i }
62 format.pdf { limit = Setting.issues_export_limit.to_i }
62 format.pdf { limit = Setting.issues_export_limit.to_i }
63 end
63 end
64 @issue_count = Issue.count(:include => [:status, :project], :conditions => @query.statement)
64 @issue_count = Issue.count(:include => [:status, :project], :conditions => @query.statement)
65 @issue_pages = Paginator.new self, @issue_count, limit, params['page']
65 @issue_pages = Paginator.new self, @issue_count, limit, params['page']
66 @issues = Issue.find :all, :order => [@query.group_by_sort_order, sort_clause].compact.join(','),
66 @issues = Issue.find :all, :order => [@query.group_by_sort_order, sort_clause].compact.join(','),
67 :include => [ :assigned_to, :status, :tracker, :project, :priority, :category, :fixed_version ],
67 :include => [ :assigned_to, :status, :tracker, :project, :priority, :category, :fixed_version ],
68 :conditions => @query.statement,
68 :conditions => @query.statement,
69 :limit => limit,
69 :limit => limit,
70 :offset => @issue_pages.current.offset
70 :offset => @issue_pages.current.offset
71 respond_to do |format|
71 respond_to do |format|
72 format.html {
72 format.html {
73 if @query.grouped?
73 if @query.grouped?
74 # Retrieve the issue count by group
74 # Retrieve the issue count by group
75 @issue_count_by_group = begin
75 @issue_count_by_group = begin
76 Issue.count(:group => @query.group_by, :include => [:status, :project], :conditions => @query.statement)
76 Issue.count(:group => @query.group_by, :include => [:status, :project], :conditions => @query.statement)
77 # Rails will raise an (unexpected) error if there's only a nil group value
77 # Rails will raise an (unexpected) error if there's only a nil group value
78 rescue ActiveRecord::RecordNotFound
78 rescue ActiveRecord::RecordNotFound
79 {nil => @issue_count}
79 {nil => @issue_count}
80 end
80 end
81 end
81 end
82 render :template => 'issues/index.rhtml', :layout => !request.xhr?
82 render :template => 'issues/index.rhtml', :layout => !request.xhr?
83 }
83 }
84 format.atom { render_feed(@issues, :title => "#{@project || Setting.app_title}: #{l(:label_issue_plural)}") }
84 format.atom { render_feed(@issues, :title => "#{@project || Setting.app_title}: #{l(:label_issue_plural)}") }
85 format.csv { send_data(issues_to_csv(@issues, @project).read, :type => 'text/csv; header=present', :filename => 'export.csv') }
85 format.csv { send_data(issues_to_csv(@issues, @project), :type => 'text/csv; header=present', :filename => 'export.csv') }
86 format.pdf { send_data(issues_to_pdf(@issues, @project, @query), :type => 'application/pdf', :filename => 'export.pdf') }
86 format.pdf { send_data(issues_to_pdf(@issues, @project, @query), :type => 'application/pdf', :filename => 'export.pdf') }
87 end
87 end
88 else
88 else
89 # Send html if the query is not valid
89 # Send html if the query is not valid
90 render(:template => 'issues/index.rhtml', :layout => !request.xhr?)
90 render(:template => 'issues/index.rhtml', :layout => !request.xhr?)
91 end
91 end
92 rescue ActiveRecord::RecordNotFound
92 rescue ActiveRecord::RecordNotFound
93 render_404
93 render_404
94 end
94 end
95
95
96 def changes
96 def changes
97 retrieve_query
97 retrieve_query
98 sort_init 'id', 'desc'
98 sort_init 'id', 'desc'
99 sort_update({'id' => "#{Issue.table_name}.id"}.merge(@query.available_columns.inject({}) {|h, c| h[c.name.to_s] = c.sortable; h}))
99 sort_update({'id' => "#{Issue.table_name}.id"}.merge(@query.available_columns.inject({}) {|h, c| h[c.name.to_s] = c.sortable; h}))
100
100
101 if @query.valid?
101 if @query.valid?
102 @journals = Journal.find :all, :include => [ :details, :user, {:issue => [:project, :author, :tracker, :status]} ],
102 @journals = Journal.find :all, :include => [ :details, :user, {:issue => [:project, :author, :tracker, :status]} ],
103 :conditions => @query.statement,
103 :conditions => @query.statement,
104 :limit => 25,
104 :limit => 25,
105 :order => "#{Journal.table_name}.created_on DESC"
105 :order => "#{Journal.table_name}.created_on DESC"
106 end
106 end
107 @title = (@project ? @project.name : Setting.app_title) + ": " + (@query.new_record? ? l(:label_changes_details) : @query.name)
107 @title = (@project ? @project.name : Setting.app_title) + ": " + (@query.new_record? ? l(:label_changes_details) : @query.name)
108 render :layout => false, :content_type => 'application/atom+xml'
108 render :layout => false, :content_type => 'application/atom+xml'
109 rescue ActiveRecord::RecordNotFound
109 rescue ActiveRecord::RecordNotFound
110 render_404
110 render_404
111 end
111 end
112
112
113 def show
113 def show
114 @journals = @issue.journals.find(:all, :include => [:user, :details], :order => "#{Journal.table_name}.created_on ASC")
114 @journals = @issue.journals.find(:all, :include => [:user, :details], :order => "#{Journal.table_name}.created_on ASC")
115 @journals.each_with_index {|j,i| j.indice = i+1}
115 @journals.each_with_index {|j,i| j.indice = i+1}
116 @journals.reverse! if User.current.wants_comments_in_reverse_order?
116 @journals.reverse! if User.current.wants_comments_in_reverse_order?
117 @changesets = @issue.changesets
117 @changesets = @issue.changesets
118 @changesets.reverse! if User.current.wants_comments_in_reverse_order?
118 @changesets.reverse! if User.current.wants_comments_in_reverse_order?
119 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
119 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
120 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
120 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
121 @priorities = IssuePriority.all
121 @priorities = IssuePriority.all
122 @time_entry = TimeEntry.new
122 @time_entry = TimeEntry.new
123 respond_to do |format|
123 respond_to do |format|
124 format.html { render :template => 'issues/show.rhtml' }
124 format.html { render :template => 'issues/show.rhtml' }
125 format.atom { render :action => 'changes', :layout => false, :content_type => 'application/atom+xml' }
125 format.atom { render :action => 'changes', :layout => false, :content_type => 'application/atom+xml' }
126 format.pdf { send_data(issue_to_pdf(@issue), :type => 'application/pdf', :filename => "#{@project.identifier}-#{@issue.id}.pdf") }
126 format.pdf { send_data(issue_to_pdf(@issue), :type => 'application/pdf', :filename => "#{@project.identifier}-#{@issue.id}.pdf") }
127 end
127 end
128 end
128 end
129
129
130 # Add a new issue
130 # Add a new issue
131 # The new issue will be created from an existing one if copy_from parameter is given
131 # The new issue will be created from an existing one if copy_from parameter is given
132 def new
132 def new
133 @issue = Issue.new
133 @issue = Issue.new
134 @issue.copy_from(params[:copy_from]) if params[:copy_from]
134 @issue.copy_from(params[:copy_from]) if params[:copy_from]
135 @issue.project = @project
135 @issue.project = @project
136 # Tracker must be set before custom field values
136 # Tracker must be set before custom field values
137 @issue.tracker ||= @project.trackers.find((params[:issue] && params[:issue][:tracker_id]) || params[:tracker_id] || :first)
137 @issue.tracker ||= @project.trackers.find((params[:issue] && params[:issue][:tracker_id]) || params[:tracker_id] || :first)
138 if @issue.tracker.nil?
138 if @issue.tracker.nil?
139 render_error l(:error_no_tracker_in_project)
139 render_error l(:error_no_tracker_in_project)
140 return
140 return
141 end
141 end
142 if params[:issue].is_a?(Hash)
142 if params[:issue].is_a?(Hash)
143 @issue.attributes = params[:issue]
143 @issue.attributes = params[:issue]
144 @issue.watcher_user_ids = params[:issue]['watcher_user_ids'] if User.current.allowed_to?(:add_issue_watchers, @project)
144 @issue.watcher_user_ids = params[:issue]['watcher_user_ids'] if User.current.allowed_to?(:add_issue_watchers, @project)
145 end
145 end
146 @issue.author = User.current
146 @issue.author = User.current
147
147
148 default_status = IssueStatus.default
148 default_status = IssueStatus.default
149 unless default_status
149 unless default_status
150 render_error l(:error_no_default_issue_status)
150 render_error l(:error_no_default_issue_status)
151 return
151 return
152 end
152 end
153 @issue.status = default_status
153 @issue.status = default_status
154 @allowed_statuses = ([default_status] + default_status.find_new_statuses_allowed_to(User.current.roles_for_project(@project), @issue.tracker)).uniq
154 @allowed_statuses = ([default_status] + default_status.find_new_statuses_allowed_to(User.current.roles_for_project(@project), @issue.tracker)).uniq
155
155
156 if request.get? || request.xhr?
156 if request.get? || request.xhr?
157 @issue.start_date ||= Date.today
157 @issue.start_date ||= Date.today
158 else
158 else
159 requested_status = IssueStatus.find_by_id(params[:issue][:status_id])
159 requested_status = IssueStatus.find_by_id(params[:issue][:status_id])
160 # Check that the user is allowed to apply the requested status
160 # Check that the user is allowed to apply the requested status
161 @issue.status = (@allowed_statuses.include? requested_status) ? requested_status : default_status
161 @issue.status = (@allowed_statuses.include? requested_status) ? requested_status : default_status
162 if @issue.save
162 if @issue.save
163 attach_files(@issue, params[:attachments])
163 attach_files(@issue, params[:attachments])
164 flash[:notice] = l(:notice_successful_create)
164 flash[:notice] = l(:notice_successful_create)
165 call_hook(:controller_issues_new_after_save, { :params => params, :issue => @issue})
165 call_hook(:controller_issues_new_after_save, { :params => params, :issue => @issue})
166 redirect_to(params[:continue] ? { :action => 'new', :tracker_id => @issue.tracker } :
166 redirect_to(params[:continue] ? { :action => 'new', :tracker_id => @issue.tracker } :
167 { :action => 'show', :id => @issue })
167 { :action => 'show', :id => @issue })
168 return
168 return
169 end
169 end
170 end
170 end
171 @priorities = IssuePriority.all
171 @priorities = IssuePriority.all
172 render :layout => !request.xhr?
172 render :layout => !request.xhr?
173 end
173 end
174
174
175 # Attributes that can be updated on workflow transition (without :edit permission)
175 # Attributes that can be updated on workflow transition (without :edit permission)
176 # TODO: make it configurable (at least per role)
176 # TODO: make it configurable (at least per role)
177 UPDATABLE_ATTRS_ON_TRANSITION = %w(status_id assigned_to_id fixed_version_id done_ratio) unless const_defined?(:UPDATABLE_ATTRS_ON_TRANSITION)
177 UPDATABLE_ATTRS_ON_TRANSITION = %w(status_id assigned_to_id fixed_version_id done_ratio) unless const_defined?(:UPDATABLE_ATTRS_ON_TRANSITION)
178
178
179 def edit
179 def edit
180 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
180 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
181 @priorities = IssuePriority.all
181 @priorities = IssuePriority.all
182 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
182 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
183 @time_entry = TimeEntry.new
183 @time_entry = TimeEntry.new
184
184
185 @notes = params[:notes]
185 @notes = params[:notes]
186 journal = @issue.init_journal(User.current, @notes)
186 journal = @issue.init_journal(User.current, @notes)
187 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
187 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
188 if (@edit_allowed || !@allowed_statuses.empty?) && params[:issue]
188 if (@edit_allowed || !@allowed_statuses.empty?) && params[:issue]
189 attrs = params[:issue].dup
189 attrs = params[:issue].dup
190 attrs.delete_if {|k,v| !UPDATABLE_ATTRS_ON_TRANSITION.include?(k) } unless @edit_allowed
190 attrs.delete_if {|k,v| !UPDATABLE_ATTRS_ON_TRANSITION.include?(k) } unless @edit_allowed
191 attrs.delete(:status_id) unless @allowed_statuses.detect {|s| s.id.to_s == attrs[:status_id].to_s}
191 attrs.delete(:status_id) unless @allowed_statuses.detect {|s| s.id.to_s == attrs[:status_id].to_s}
192 @issue.attributes = attrs
192 @issue.attributes = attrs
193 end
193 end
194
194
195 if request.post?
195 if request.post?
196 @time_entry = TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => Date.today)
196 @time_entry = TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => Date.today)
197 @time_entry.attributes = params[:time_entry]
197 @time_entry.attributes = params[:time_entry]
198 attachments = attach_files(@issue, params[:attachments])
198 attachments = attach_files(@issue, params[:attachments])
199 attachments.each {|a| journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
199 attachments.each {|a| journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
200
200
201 call_hook(:controller_issues_edit_before_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => journal})
201 call_hook(:controller_issues_edit_before_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => journal})
202
202
203 if (@time_entry.hours.nil? || @time_entry.valid?) && @issue.save
203 if (@time_entry.hours.nil? || @time_entry.valid?) && @issue.save
204 # Log spend time
204 # Log spend time
205 if User.current.allowed_to?(:log_time, @project)
205 if User.current.allowed_to?(:log_time, @project)
206 @time_entry.save
206 @time_entry.save
207 end
207 end
208 if !journal.new_record?
208 if !journal.new_record?
209 # Only send notification if something was actually changed
209 # Only send notification if something was actually changed
210 flash[:notice] = l(:notice_successful_update)
210 flash[:notice] = l(:notice_successful_update)
211 end
211 end
212 call_hook(:controller_issues_edit_after_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => journal})
212 call_hook(:controller_issues_edit_after_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => journal})
213 redirect_to(params[:back_to] || {:action => 'show', :id => @issue})
213 redirect_to(params[:back_to] || {:action => 'show', :id => @issue})
214 end
214 end
215 end
215 end
216 rescue ActiveRecord::StaleObjectError
216 rescue ActiveRecord::StaleObjectError
217 # Optimistic locking exception
217 # Optimistic locking exception
218 flash.now[:error] = l(:notice_locking_conflict)
218 flash.now[:error] = l(:notice_locking_conflict)
219 # Remove the previously added attachments if issue was not updated
219 # Remove the previously added attachments if issue was not updated
220 attachments.each(&:destroy)
220 attachments.each(&:destroy)
221 end
221 end
222
222
223 def reply
223 def reply
224 journal = Journal.find(params[:journal_id]) if params[:journal_id]
224 journal = Journal.find(params[:journal_id]) if params[:journal_id]
225 if journal
225 if journal
226 user = journal.user
226 user = journal.user
227 text = journal.notes
227 text = journal.notes
228 else
228 else
229 user = @issue.author
229 user = @issue.author
230 text = @issue.description
230 text = @issue.description
231 end
231 end
232 content = "#{ll(Setting.default_language, :text_user_wrote, user)}\\n> "
232 content = "#{ll(Setting.default_language, :text_user_wrote, user)}\\n> "
233 content << text.to_s.strip.gsub(%r{<pre>((.|\s)*?)</pre>}m, '[...]').gsub('"', '\"').gsub(/(\r?\n|\r\n?)/, "\\n> ") + "\\n\\n"
233 content << text.to_s.strip.gsub(%r{<pre>((.|\s)*?)</pre>}m, '[...]').gsub('"', '\"').gsub(/(\r?\n|\r\n?)/, "\\n> ") + "\\n\\n"
234 render(:update) { |page|
234 render(:update) { |page|
235 page.<< "$('notes').value = \"#{content}\";"
235 page.<< "$('notes').value = \"#{content}\";"
236 page.show 'update'
236 page.show 'update'
237 page << "Form.Element.focus('notes');"
237 page << "Form.Element.focus('notes');"
238 page << "Element.scrollTo('update');"
238 page << "Element.scrollTo('update');"
239 page << "$('notes').scrollTop = $('notes').scrollHeight - $('notes').clientHeight;"
239 page << "$('notes').scrollTop = $('notes').scrollHeight - $('notes').clientHeight;"
240 }
240 }
241 end
241 end
242
242
243 # Bulk edit a set of issues
243 # Bulk edit a set of issues
244 def bulk_edit
244 def bulk_edit
245 if request.post?
245 if request.post?
246 status = params[:status_id].blank? ? nil : IssueStatus.find_by_id(params[:status_id])
246 status = params[:status_id].blank? ? nil : IssueStatus.find_by_id(params[:status_id])
247 priority = params[:priority_id].blank? ? nil : IssuePriority.find_by_id(params[:priority_id])
247 priority = params[:priority_id].blank? ? nil : IssuePriority.find_by_id(params[:priority_id])
248 assigned_to = (params[:assigned_to_id].blank? || params[:assigned_to_id] == 'none') ? nil : User.find_by_id(params[:assigned_to_id])
248 assigned_to = (params[:assigned_to_id].blank? || params[:assigned_to_id] == 'none') ? nil : User.find_by_id(params[:assigned_to_id])
249 category = (params[:category_id].blank? || params[:category_id] == 'none') ? nil : @project.issue_categories.find_by_id(params[:category_id])
249 category = (params[:category_id].blank? || params[:category_id] == 'none') ? nil : @project.issue_categories.find_by_id(params[:category_id])
250 fixed_version = (params[:fixed_version_id].blank? || params[:fixed_version_id] == 'none') ? nil : @project.versions.find_by_id(params[:fixed_version_id])
250 fixed_version = (params[:fixed_version_id].blank? || params[:fixed_version_id] == 'none') ? nil : @project.versions.find_by_id(params[:fixed_version_id])
251 custom_field_values = params[:custom_field_values] ? params[:custom_field_values].reject {|k,v| v.blank?} : nil
251 custom_field_values = params[:custom_field_values] ? params[:custom_field_values].reject {|k,v| v.blank?} : nil
252
252
253 unsaved_issue_ids = []
253 unsaved_issue_ids = []
254 @issues.each do |issue|
254 @issues.each do |issue|
255 journal = issue.init_journal(User.current, params[:notes])
255 journal = issue.init_journal(User.current, params[:notes])
256 issue.priority = priority if priority
256 issue.priority = priority if priority
257 issue.assigned_to = assigned_to if assigned_to || params[:assigned_to_id] == 'none'
257 issue.assigned_to = assigned_to if assigned_to || params[:assigned_to_id] == 'none'
258 issue.category = category if category || params[:category_id] == 'none'
258 issue.category = category if category || params[:category_id] == 'none'
259 issue.fixed_version = fixed_version if fixed_version || params[:fixed_version_id] == 'none'
259 issue.fixed_version = fixed_version if fixed_version || params[:fixed_version_id] == 'none'
260 issue.start_date = params[:start_date] unless params[:start_date].blank?
260 issue.start_date = params[:start_date] unless params[:start_date].blank?
261 issue.due_date = params[:due_date] unless params[:due_date].blank?
261 issue.due_date = params[:due_date] unless params[:due_date].blank?
262 issue.done_ratio = params[:done_ratio] unless params[:done_ratio].blank?
262 issue.done_ratio = params[:done_ratio] unless params[:done_ratio].blank?
263 issue.custom_field_values = custom_field_values if custom_field_values && !custom_field_values.empty?
263 issue.custom_field_values = custom_field_values if custom_field_values && !custom_field_values.empty?
264 call_hook(:controller_issues_bulk_edit_before_save, { :params => params, :issue => issue })
264 call_hook(:controller_issues_bulk_edit_before_save, { :params => params, :issue => issue })
265 # Don't save any change to the issue if the user is not authorized to apply the requested status
265 # Don't save any change to the issue if the user is not authorized to apply the requested status
266 unless (status.nil? || (issue.new_statuses_allowed_to(User.current).include?(status) && issue.status = status)) && issue.save
266 unless (status.nil? || (issue.new_statuses_allowed_to(User.current).include?(status) && issue.status = status)) && issue.save
267 # Keep unsaved issue ids to display them in flash error
267 # Keep unsaved issue ids to display them in flash error
268 unsaved_issue_ids << issue.id
268 unsaved_issue_ids << issue.id
269 end
269 end
270 end
270 end
271 if unsaved_issue_ids.empty?
271 if unsaved_issue_ids.empty?
272 flash[:notice] = l(:notice_successful_update) unless @issues.empty?
272 flash[:notice] = l(:notice_successful_update) unless @issues.empty?
273 else
273 else
274 flash[:error] = l(:notice_failed_to_save_issues, :count => unsaved_issue_ids.size,
274 flash[:error] = l(:notice_failed_to_save_issues, :count => unsaved_issue_ids.size,
275 :total => @issues.size,
275 :total => @issues.size,
276 :ids => '#' + unsaved_issue_ids.join(', #'))
276 :ids => '#' + unsaved_issue_ids.join(', #'))
277 end
277 end
278 redirect_to(params[:back_to] || {:controller => 'issues', :action => 'index', :project_id => @project})
278 redirect_to(params[:back_to] || {:controller => 'issues', :action => 'index', :project_id => @project})
279 return
279 return
280 end
280 end
281 # Find potential statuses the user could be allowed to switch issues to
281 # Find potential statuses the user could be allowed to switch issues to
282 @available_statuses = Workflow.find(:all, :include => :new_status,
282 @available_statuses = Workflow.find(:all, :include => :new_status,
283 :conditions => {:role_id => User.current.roles_for_project(@project).collect(&:id)}).collect(&:new_status).compact.uniq.sort
283 :conditions => {:role_id => User.current.roles_for_project(@project).collect(&:id)}).collect(&:new_status).compact.uniq.sort
284 @custom_fields = @project.issue_custom_fields.select {|f| f.field_format == 'list'}
284 @custom_fields = @project.issue_custom_fields.select {|f| f.field_format == 'list'}
285 end
285 end
286
286
287 def move
287 def move
288 @allowed_projects = []
288 @allowed_projects = []
289 # find projects to which the user is allowed to move the issue
289 # find projects to which the user is allowed to move the issue
290 if User.current.admin?
290 if User.current.admin?
291 # admin is allowed to move issues to any active (visible) project
291 # admin is allowed to move issues to any active (visible) project
292 @allowed_projects = Project.find(:all, :conditions => Project.visible_by(User.current))
292 @allowed_projects = Project.find(:all, :conditions => Project.visible_by(User.current))
293 else
293 else
294 User.current.memberships.each {|m| @allowed_projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
294 User.current.memberships.each {|m| @allowed_projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
295 end
295 end
296 @target_project = @allowed_projects.detect {|p| p.id.to_s == params[:new_project_id]} if params[:new_project_id]
296 @target_project = @allowed_projects.detect {|p| p.id.to_s == params[:new_project_id]} if params[:new_project_id]
297 @target_project ||= @project
297 @target_project ||= @project
298 @trackers = @target_project.trackers
298 @trackers = @target_project.trackers
299 if request.post?
299 if request.post?
300 new_tracker = params[:new_tracker_id].blank? ? nil : @target_project.trackers.find_by_id(params[:new_tracker_id])
300 new_tracker = params[:new_tracker_id].blank? ? nil : @target_project.trackers.find_by_id(params[:new_tracker_id])
301 unsaved_issue_ids = []
301 unsaved_issue_ids = []
302 @issues.each do |issue|
302 @issues.each do |issue|
303 issue.init_journal(User.current)
303 issue.init_journal(User.current)
304 unsaved_issue_ids << issue.id unless issue.move_to(@target_project, new_tracker, params[:copy_options])
304 unsaved_issue_ids << issue.id unless issue.move_to(@target_project, new_tracker, params[:copy_options])
305 end
305 end
306 if unsaved_issue_ids.empty?
306 if unsaved_issue_ids.empty?
307 flash[:notice] = l(:notice_successful_update) unless @issues.empty?
307 flash[:notice] = l(:notice_successful_update) unless @issues.empty?
308 else
308 else
309 flash[:error] = l(:notice_failed_to_save_issues, :count => unsaved_issue_ids.size,
309 flash[:error] = l(:notice_failed_to_save_issues, :count => unsaved_issue_ids.size,
310 :total => @issues.size,
310 :total => @issues.size,
311 :ids => '#' + unsaved_issue_ids.join(', #'))
311 :ids => '#' + unsaved_issue_ids.join(', #'))
312 end
312 end
313 redirect_to :controller => 'issues', :action => 'index', :project_id => @project
313 redirect_to :controller => 'issues', :action => 'index', :project_id => @project
314 return
314 return
315 end
315 end
316 render :layout => false if request.xhr?
316 render :layout => false if request.xhr?
317 end
317 end
318
318
319 def destroy
319 def destroy
320 @hours = TimeEntry.sum(:hours, :conditions => ['issue_id IN (?)', @issues]).to_f
320 @hours = TimeEntry.sum(:hours, :conditions => ['issue_id IN (?)', @issues]).to_f
321 if @hours > 0
321 if @hours > 0
322 case params[:todo]
322 case params[:todo]
323 when 'destroy'
323 when 'destroy'
324 # nothing to do
324 # nothing to do
325 when 'nullify'
325 when 'nullify'
326 TimeEntry.update_all('issue_id = NULL', ['issue_id IN (?)', @issues])
326 TimeEntry.update_all('issue_id = NULL', ['issue_id IN (?)', @issues])
327 when 'reassign'
327 when 'reassign'
328 reassign_to = @project.issues.find_by_id(params[:reassign_to_id])
328 reassign_to = @project.issues.find_by_id(params[:reassign_to_id])
329 if reassign_to.nil?
329 if reassign_to.nil?
330 flash.now[:error] = l(:error_issue_not_found_in_project)
330 flash.now[:error] = l(:error_issue_not_found_in_project)
331 return
331 return
332 else
332 else
333 TimeEntry.update_all("issue_id = #{reassign_to.id}", ['issue_id IN (?)', @issues])
333 TimeEntry.update_all("issue_id = #{reassign_to.id}", ['issue_id IN (?)', @issues])
334 end
334 end
335 else
335 else
336 # display the destroy form
336 # display the destroy form
337 return
337 return
338 end
338 end
339 end
339 end
340 @issues.each(&:destroy)
340 @issues.each(&:destroy)
341 redirect_to :action => 'index', :project_id => @project
341 redirect_to :action => 'index', :project_id => @project
342 end
342 end
343
343
344 def gantt
344 def gantt
345 @gantt = Redmine::Helpers::Gantt.new(params)
345 @gantt = Redmine::Helpers::Gantt.new(params)
346 retrieve_query
346 retrieve_query
347 if @query.valid?
347 if @query.valid?
348 events = []
348 events = []
349 # Issues that have start and due dates
349 # Issues that have start and due dates
350 events += Issue.find(:all,
350 events += Issue.find(:all,
351 :order => "start_date, due_date",
351 :order => "start_date, due_date",
352 :include => [:tracker, :status, :assigned_to, :priority, :project],
352 :include => [:tracker, :status, :assigned_to, :priority, :project],
353 :conditions => ["(#{@query.statement}) AND (((start_date>=? and start_date<=?) or (due_date>=? and due_date<=?) or (start_date<? and due_date>?)) and start_date is not null and due_date is not null)", @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to]
353 :conditions => ["(#{@query.statement}) AND (((start_date>=? and start_date<=?) or (due_date>=? and due_date<=?) or (start_date<? and due_date>?)) and start_date is not null and due_date is not null)", @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to]
354 )
354 )
355 # Issues that don't have a due date but that are assigned to a version with a date
355 # Issues that don't have a due date but that are assigned to a version with a date
356 events += Issue.find(:all,
356 events += Issue.find(:all,
357 :order => "start_date, effective_date",
357 :order => "start_date, effective_date",
358 :include => [:tracker, :status, :assigned_to, :priority, :project, :fixed_version],
358 :include => [:tracker, :status, :assigned_to, :priority, :project, :fixed_version],
359 :conditions => ["(#{@query.statement}) AND (((start_date>=? and start_date<=?) or (effective_date>=? and effective_date<=?) or (start_date<? and effective_date>?)) and start_date is not null and due_date is null and effective_date is not null)", @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to]
359 :conditions => ["(#{@query.statement}) AND (((start_date>=? and start_date<=?) or (effective_date>=? and effective_date<=?) or (start_date<? and effective_date>?)) and start_date is not null and due_date is null and effective_date is not null)", @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to]
360 )
360 )
361 # Versions
361 # Versions
362 events += Version.find(:all, :include => :project,
362 events += Version.find(:all, :include => :project,
363 :conditions => ["(#{@query.project_statement}) AND effective_date BETWEEN ? AND ?", @gantt.date_from, @gantt.date_to])
363 :conditions => ["(#{@query.project_statement}) AND effective_date BETWEEN ? AND ?", @gantt.date_from, @gantt.date_to])
364
364
365 @gantt.events = events
365 @gantt.events = events
366 end
366 end
367
367
368 basename = (@project ? "#{@project.identifier}-" : '') + 'gantt'
368 basename = (@project ? "#{@project.identifier}-" : '') + 'gantt'
369
369
370 respond_to do |format|
370 respond_to do |format|
371 format.html { render :template => "issues/gantt.rhtml", :layout => !request.xhr? }
371 format.html { render :template => "issues/gantt.rhtml", :layout => !request.xhr? }
372 format.png { send_data(@gantt.to_image, :disposition => 'inline', :type => 'image/png', :filename => "#{basename}.png") } if @gantt.respond_to?('to_image')
372 format.png { send_data(@gantt.to_image, :disposition => 'inline', :type => 'image/png', :filename => "#{basename}.png") } if @gantt.respond_to?('to_image')
373 format.pdf { send_data(gantt_to_pdf(@gantt, @project), :type => 'application/pdf', :filename => "#{basename}.pdf") }
373 format.pdf { send_data(gantt_to_pdf(@gantt, @project), :type => 'application/pdf', :filename => "#{basename}.pdf") }
374 end
374 end
375 end
375 end
376
376
377 def calendar
377 def calendar
378 if params[:year] and params[:year].to_i > 1900
378 if params[:year] and params[:year].to_i > 1900
379 @year = params[:year].to_i
379 @year = params[:year].to_i
380 if params[:month] and params[:month].to_i > 0 and params[:month].to_i < 13
380 if params[:month] and params[:month].to_i > 0 and params[:month].to_i < 13
381 @month = params[:month].to_i
381 @month = params[:month].to_i
382 end
382 end
383 end
383 end
384 @year ||= Date.today.year
384 @year ||= Date.today.year
385 @month ||= Date.today.month
385 @month ||= Date.today.month
386
386
387 @calendar = Redmine::Helpers::Calendar.new(Date.civil(@year, @month, 1), current_language, :month)
387 @calendar = Redmine::Helpers::Calendar.new(Date.civil(@year, @month, 1), current_language, :month)
388 retrieve_query
388 retrieve_query
389 if @query.valid?
389 if @query.valid?
390 events = []
390 events = []
391 events += Issue.find(:all,
391 events += Issue.find(:all,
392 :include => [:tracker, :status, :assigned_to, :priority, :project],
392 :include => [:tracker, :status, :assigned_to, :priority, :project],
393 :conditions => ["(#{@query.statement}) AND ((start_date BETWEEN ? AND ?) OR (due_date BETWEEN ? AND ?))", @calendar.startdt, @calendar.enddt, @calendar.startdt, @calendar.enddt]
393 :conditions => ["(#{@query.statement}) AND ((start_date BETWEEN ? AND ?) OR (due_date BETWEEN ? AND ?))", @calendar.startdt, @calendar.enddt, @calendar.startdt, @calendar.enddt]
394 )
394 )
395 events += Version.find(:all, :include => :project,
395 events += Version.find(:all, :include => :project,
396 :conditions => ["(#{@query.project_statement}) AND effective_date BETWEEN ? AND ?", @calendar.startdt, @calendar.enddt])
396 :conditions => ["(#{@query.project_statement}) AND effective_date BETWEEN ? AND ?", @calendar.startdt, @calendar.enddt])
397
397
398 @calendar.events = events
398 @calendar.events = events
399 end
399 end
400
400
401 render :layout => false if request.xhr?
401 render :layout => false if request.xhr?
402 end
402 end
403
403
404 def context_menu
404 def context_menu
405 @issues = Issue.find_all_by_id(params[:ids], :include => :project)
405 @issues = Issue.find_all_by_id(params[:ids], :include => :project)
406 if (@issues.size == 1)
406 if (@issues.size == 1)
407 @issue = @issues.first
407 @issue = @issues.first
408 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
408 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
409 end
409 end
410 projects = @issues.collect(&:project).compact.uniq
410 projects = @issues.collect(&:project).compact.uniq
411 @project = projects.first if projects.size == 1
411 @project = projects.first if projects.size == 1
412
412
413 @can = {:edit => (@project && User.current.allowed_to?(:edit_issues, @project)),
413 @can = {:edit => (@project && User.current.allowed_to?(:edit_issues, @project)),
414 :log_time => (@project && User.current.allowed_to?(:log_time, @project)),
414 :log_time => (@project && User.current.allowed_to?(:log_time, @project)),
415 :update => (@project && (User.current.allowed_to?(:edit_issues, @project) || (User.current.allowed_to?(:change_status, @project) && @allowed_statuses && !@allowed_statuses.empty?))),
415 :update => (@project && (User.current.allowed_to?(:edit_issues, @project) || (User.current.allowed_to?(:change_status, @project) && @allowed_statuses && !@allowed_statuses.empty?))),
416 :move => (@project && User.current.allowed_to?(:move_issues, @project)),
416 :move => (@project && User.current.allowed_to?(:move_issues, @project)),
417 :copy => (@issue && @project.trackers.include?(@issue.tracker) && User.current.allowed_to?(:add_issues, @project)),
417 :copy => (@issue && @project.trackers.include?(@issue.tracker) && User.current.allowed_to?(:add_issues, @project)),
418 :delete => (@project && User.current.allowed_to?(:delete_issues, @project))
418 :delete => (@project && User.current.allowed_to?(:delete_issues, @project))
419 }
419 }
420 if @project
420 if @project
421 @assignables = @project.assignable_users
421 @assignables = @project.assignable_users
422 @assignables << @issue.assigned_to if @issue && @issue.assigned_to && !@assignables.include?(@issue.assigned_to)
422 @assignables << @issue.assigned_to if @issue && @issue.assigned_to && !@assignables.include?(@issue.assigned_to)
423 end
423 end
424
424
425 @priorities = IssuePriority.all.reverse
425 @priorities = IssuePriority.all.reverse
426 @statuses = IssueStatus.find(:all, :order => 'position')
426 @statuses = IssueStatus.find(:all, :order => 'position')
427 @back = request.env['HTTP_REFERER']
427 @back = request.env['HTTP_REFERER']
428
428
429 render :layout => false
429 render :layout => false
430 end
430 end
431
431
432 def update_form
432 def update_form
433 @issue = Issue.new(params[:issue])
433 @issue = Issue.new(params[:issue])
434 render :action => :new, :layout => false
434 render :action => :new, :layout => false
435 end
435 end
436
436
437 def preview
437 def preview
438 @issue = @project.issues.find_by_id(params[:id]) unless params[:id].blank?
438 @issue = @project.issues.find_by_id(params[:id]) unless params[:id].blank?
439 @attachements = @issue.attachments if @issue
439 @attachements = @issue.attachments if @issue
440 @text = params[:notes] || (params[:issue] ? params[:issue][:description] : nil)
440 @text = params[:notes] || (params[:issue] ? params[:issue][:description] : nil)
441 render :partial => 'common/preview'
441 render :partial => 'common/preview'
442 end
442 end
443
443
444 private
444 private
445 def find_issue
445 def find_issue
446 @issue = Issue.find(params[:id], :include => [:project, :tracker, :status, :author, :priority, :category])
446 @issue = Issue.find(params[:id], :include => [:project, :tracker, :status, :author, :priority, :category])
447 @project = @issue.project
447 @project = @issue.project
448 rescue ActiveRecord::RecordNotFound
448 rescue ActiveRecord::RecordNotFound
449 render_404
449 render_404
450 end
450 end
451
451
452 # Filter for bulk operations
452 # Filter for bulk operations
453 def find_issues
453 def find_issues
454 @issues = Issue.find_all_by_id(params[:id] || params[:ids])
454 @issues = Issue.find_all_by_id(params[:id] || params[:ids])
455 raise ActiveRecord::RecordNotFound if @issues.empty?
455 raise ActiveRecord::RecordNotFound if @issues.empty?
456 projects = @issues.collect(&:project).compact.uniq
456 projects = @issues.collect(&:project).compact.uniq
457 if projects.size == 1
457 if projects.size == 1
458 @project = projects.first
458 @project = projects.first
459 else
459 else
460 # TODO: let users bulk edit/move/destroy issues from different projects
460 # TODO: let users bulk edit/move/destroy issues from different projects
461 render_error 'Can not bulk edit/move/destroy issues from different projects' and return false
461 render_error 'Can not bulk edit/move/destroy issues from different projects' and return false
462 end
462 end
463 rescue ActiveRecord::RecordNotFound
463 rescue ActiveRecord::RecordNotFound
464 render_404
464 render_404
465 end
465 end
466
466
467 def find_project
467 def find_project
468 @project = Project.find(params[:project_id])
468 @project = Project.find(params[:project_id])
469 rescue ActiveRecord::RecordNotFound
469 rescue ActiveRecord::RecordNotFound
470 render_404
470 render_404
471 end
471 end
472
472
473 def find_optional_project
473 def find_optional_project
474 @project = Project.find(params[:project_id]) unless params[:project_id].blank?
474 @project = Project.find(params[:project_id]) unless params[:project_id].blank?
475 allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
475 allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
476 allowed ? true : deny_access
476 allowed ? true : deny_access
477 rescue ActiveRecord::RecordNotFound
477 rescue ActiveRecord::RecordNotFound
478 render_404
478 render_404
479 end
479 end
480
480
481 # Retrieve query from session or build a new query
481 # Retrieve query from session or build a new query
482 def retrieve_query
482 def retrieve_query
483 if !params[:query_id].blank?
483 if !params[:query_id].blank?
484 cond = "project_id IS NULL"
484 cond = "project_id IS NULL"
485 cond << " OR project_id = #{@project.id}" if @project
485 cond << " OR project_id = #{@project.id}" if @project
486 @query = Query.find(params[:query_id], :conditions => cond)
486 @query = Query.find(params[:query_id], :conditions => cond)
487 @query.project = @project
487 @query.project = @project
488 session[:query] = {:id => @query.id, :project_id => @query.project_id}
488 session[:query] = {:id => @query.id, :project_id => @query.project_id}
489 sort_clear
489 sort_clear
490 else
490 else
491 if params[:set_filter] || session[:query].nil? || session[:query][:project_id] != (@project ? @project.id : nil)
491 if params[:set_filter] || session[:query].nil? || session[:query][:project_id] != (@project ? @project.id : nil)
492 # Give it a name, required to be valid
492 # Give it a name, required to be valid
493 @query = Query.new(:name => "_")
493 @query = Query.new(:name => "_")
494 @query.project = @project
494 @query.project = @project
495 if params[:fields] and params[:fields].is_a? Array
495 if params[:fields] and params[:fields].is_a? Array
496 params[:fields].each do |field|
496 params[:fields].each do |field|
497 @query.add_filter(field, params[:operators][field], params[:values][field])
497 @query.add_filter(field, params[:operators][field], params[:values][field])
498 end
498 end
499 else
499 else
500 @query.available_filters.keys.each do |field|
500 @query.available_filters.keys.each do |field|
501 @query.add_short_filter(field, params[field]) if params[field]
501 @query.add_short_filter(field, params[field]) if params[field]
502 end
502 end
503 end
503 end
504 @query.group_by = params[:group_by]
504 @query.group_by = params[:group_by]
505 session[:query] = {:project_id => @query.project_id, :filters => @query.filters, :group_by => @query.group_by}
505 session[:query] = {:project_id => @query.project_id, :filters => @query.filters, :group_by => @query.group_by}
506 else
506 else
507 @query = Query.find_by_id(session[:query][:id]) if session[:query][:id]
507 @query = Query.find_by_id(session[:query][:id]) if session[:query][:id]
508 @query ||= Query.new(:name => "_", :project => @project, :filters => session[:query][:filters], :group_by => session[:query][:group_by])
508 @query ||= Query.new(:name => "_", :project => @project, :filters => session[:query][:filters], :group_by => session[:query][:group_by])
509 @query.project = @project
509 @query.project = @project
510 end
510 end
511 end
511 end
512 end
512 end
513 end
513 end
@@ -1,308 +1,308
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class TimelogController < ApplicationController
18 class TimelogController < ApplicationController
19 menu_item :issues
19 menu_item :issues
20 before_filter :find_project, :authorize, :only => [:edit, :destroy]
20 before_filter :find_project, :authorize, :only => [:edit, :destroy]
21 before_filter :find_optional_project, :only => [:report, :details]
21 before_filter :find_optional_project, :only => [:report, :details]
22
22
23 verify :method => :post, :only => :destroy, :redirect_to => { :action => :details }
23 verify :method => :post, :only => :destroy, :redirect_to => { :action => :details }
24
24
25 helper :sort
25 helper :sort
26 include SortHelper
26 include SortHelper
27 helper :issues
27 helper :issues
28 include TimelogHelper
28 include TimelogHelper
29 helper :custom_fields
29 helper :custom_fields
30 include CustomFieldsHelper
30 include CustomFieldsHelper
31
31
32 def report
32 def report
33 @available_criterias = { 'project' => {:sql => "#{TimeEntry.table_name}.project_id",
33 @available_criterias = { 'project' => {:sql => "#{TimeEntry.table_name}.project_id",
34 :klass => Project,
34 :klass => Project,
35 :label => :label_project},
35 :label => :label_project},
36 'version' => {:sql => "#{Issue.table_name}.fixed_version_id",
36 'version' => {:sql => "#{Issue.table_name}.fixed_version_id",
37 :klass => Version,
37 :klass => Version,
38 :label => :label_version},
38 :label => :label_version},
39 'category' => {:sql => "#{Issue.table_name}.category_id",
39 'category' => {:sql => "#{Issue.table_name}.category_id",
40 :klass => IssueCategory,
40 :klass => IssueCategory,
41 :label => :field_category},
41 :label => :field_category},
42 'member' => {:sql => "#{TimeEntry.table_name}.user_id",
42 'member' => {:sql => "#{TimeEntry.table_name}.user_id",
43 :klass => User,
43 :klass => User,
44 :label => :label_member},
44 :label => :label_member},
45 'tracker' => {:sql => "#{Issue.table_name}.tracker_id",
45 'tracker' => {:sql => "#{Issue.table_name}.tracker_id",
46 :klass => Tracker,
46 :klass => Tracker,
47 :label => :label_tracker},
47 :label => :label_tracker},
48 'activity' => {:sql => "#{TimeEntry.table_name}.activity_id",
48 'activity' => {:sql => "#{TimeEntry.table_name}.activity_id",
49 :klass => TimeEntryActivity,
49 :klass => TimeEntryActivity,
50 :label => :label_activity},
50 :label => :label_activity},
51 'issue' => {:sql => "#{TimeEntry.table_name}.issue_id",
51 'issue' => {:sql => "#{TimeEntry.table_name}.issue_id",
52 :klass => Issue,
52 :klass => Issue,
53 :label => :label_issue}
53 :label => :label_issue}
54 }
54 }
55
55
56 # Add list and boolean custom fields as available criterias
56 # Add list and boolean custom fields as available criterias
57 custom_fields = (@project.nil? ? IssueCustomField.for_all : @project.all_issue_custom_fields)
57 custom_fields = (@project.nil? ? IssueCustomField.for_all : @project.all_issue_custom_fields)
58 custom_fields.select {|cf| %w(list bool).include? cf.field_format }.each do |cf|
58 custom_fields.select {|cf| %w(list bool).include? cf.field_format }.each do |cf|
59 @available_criterias["cf_#{cf.id}"] = {:sql => "(SELECT c.value FROM #{CustomValue.table_name} c WHERE c.custom_field_id = #{cf.id} AND c.customized_type = 'Issue' AND c.customized_id = #{Issue.table_name}.id)",
59 @available_criterias["cf_#{cf.id}"] = {:sql => "(SELECT c.value FROM #{CustomValue.table_name} c WHERE c.custom_field_id = #{cf.id} AND c.customized_type = 'Issue' AND c.customized_id = #{Issue.table_name}.id)",
60 :format => cf.field_format,
60 :format => cf.field_format,
61 :label => cf.name}
61 :label => cf.name}
62 end if @project
62 end if @project
63
63
64 # Add list and boolean time entry custom fields
64 # Add list and boolean time entry custom fields
65 TimeEntryCustomField.find(:all).select {|cf| %w(list bool).include? cf.field_format }.each do |cf|
65 TimeEntryCustomField.find(:all).select {|cf| %w(list bool).include? cf.field_format }.each do |cf|
66 @available_criterias["cf_#{cf.id}"] = {:sql => "(SELECT c.value FROM #{CustomValue.table_name} c WHERE c.custom_field_id = #{cf.id} AND c.customized_type = 'TimeEntry' AND c.customized_id = #{TimeEntry.table_name}.id)",
66 @available_criterias["cf_#{cf.id}"] = {:sql => "(SELECT c.value FROM #{CustomValue.table_name} c WHERE c.custom_field_id = #{cf.id} AND c.customized_type = 'TimeEntry' AND c.customized_id = #{TimeEntry.table_name}.id)",
67 :format => cf.field_format,
67 :format => cf.field_format,
68 :label => cf.name}
68 :label => cf.name}
69 end
69 end
70
70
71 # Add list and boolean time entry activity custom fields
71 # Add list and boolean time entry activity custom fields
72 TimeEntryActivityCustomField.find(:all).select {|cf| %w(list bool).include? cf.field_format }.each do |cf|
72 TimeEntryActivityCustomField.find(:all).select {|cf| %w(list bool).include? cf.field_format }.each do |cf|
73 @available_criterias["cf_#{cf.id}"] = {:sql => "(SELECT c.value FROM #{CustomValue.table_name} c WHERE c.custom_field_id = #{cf.id} AND c.customized_type = 'Enumeration' AND c.customized_id = #{TimeEntry.table_name}.activity_id)",
73 @available_criterias["cf_#{cf.id}"] = {:sql => "(SELECT c.value FROM #{CustomValue.table_name} c WHERE c.custom_field_id = #{cf.id} AND c.customized_type = 'Enumeration' AND c.customized_id = #{TimeEntry.table_name}.activity_id)",
74 :format => cf.field_format,
74 :format => cf.field_format,
75 :label => cf.name}
75 :label => cf.name}
76 end
76 end
77
77
78 @criterias = params[:criterias] || []
78 @criterias = params[:criterias] || []
79 @criterias = @criterias.select{|criteria| @available_criterias.has_key? criteria}
79 @criterias = @criterias.select{|criteria| @available_criterias.has_key? criteria}
80 @criterias.uniq!
80 @criterias.uniq!
81 @criterias = @criterias[0,3]
81 @criterias = @criterias[0,3]
82
82
83 @columns = (params[:columns] && %w(year month week day).include?(params[:columns])) ? params[:columns] : 'month'
83 @columns = (params[:columns] && %w(year month week day).include?(params[:columns])) ? params[:columns] : 'month'
84
84
85 retrieve_date_range
85 retrieve_date_range
86
86
87 unless @criterias.empty?
87 unless @criterias.empty?
88 sql_select = @criterias.collect{|criteria| @available_criterias[criteria][:sql] + " AS " + criteria}.join(', ')
88 sql_select = @criterias.collect{|criteria| @available_criterias[criteria][:sql] + " AS " + criteria}.join(', ')
89 sql_group_by = @criterias.collect{|criteria| @available_criterias[criteria][:sql]}.join(', ')
89 sql_group_by = @criterias.collect{|criteria| @available_criterias[criteria][:sql]}.join(', ')
90 sql_condition = ''
90 sql_condition = ''
91
91
92 if @project.nil?
92 if @project.nil?
93 sql_condition = Project.allowed_to_condition(User.current, :view_time_entries)
93 sql_condition = Project.allowed_to_condition(User.current, :view_time_entries)
94 elsif @issue.nil?
94 elsif @issue.nil?
95 sql_condition = @project.project_condition(Setting.display_subprojects_issues?)
95 sql_condition = @project.project_condition(Setting.display_subprojects_issues?)
96 else
96 else
97 sql_condition = "#{TimeEntry.table_name}.issue_id = #{@issue.id}"
97 sql_condition = "#{TimeEntry.table_name}.issue_id = #{@issue.id}"
98 end
98 end
99
99
100 sql = "SELECT #{sql_select}, tyear, tmonth, tweek, spent_on, SUM(hours) AS hours"
100 sql = "SELECT #{sql_select}, tyear, tmonth, tweek, spent_on, SUM(hours) AS hours"
101 sql << " FROM #{TimeEntry.table_name}"
101 sql << " FROM #{TimeEntry.table_name}"
102 sql << " LEFT JOIN #{Issue.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id"
102 sql << " LEFT JOIN #{Issue.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id"
103 sql << " LEFT JOIN #{Project.table_name} ON #{TimeEntry.table_name}.project_id = #{Project.table_name}.id"
103 sql << " LEFT JOIN #{Project.table_name} ON #{TimeEntry.table_name}.project_id = #{Project.table_name}.id"
104 sql << " WHERE"
104 sql << " WHERE"
105 sql << " (%s) AND" % sql_condition
105 sql << " (%s) AND" % sql_condition
106 sql << " (spent_on BETWEEN '%s' AND '%s')" % [ActiveRecord::Base.connection.quoted_date(@from.to_time), ActiveRecord::Base.connection.quoted_date(@to.to_time)]
106 sql << " (spent_on BETWEEN '%s' AND '%s')" % [ActiveRecord::Base.connection.quoted_date(@from.to_time), ActiveRecord::Base.connection.quoted_date(@to.to_time)]
107 sql << " GROUP BY #{sql_group_by}, tyear, tmonth, tweek, spent_on"
107 sql << " GROUP BY #{sql_group_by}, tyear, tmonth, tweek, spent_on"
108
108
109 @hours = ActiveRecord::Base.connection.select_all(sql)
109 @hours = ActiveRecord::Base.connection.select_all(sql)
110
110
111 @hours.each do |row|
111 @hours.each do |row|
112 case @columns
112 case @columns
113 when 'year'
113 when 'year'
114 row['year'] = row['tyear']
114 row['year'] = row['tyear']
115 when 'month'
115 when 'month'
116 row['month'] = "#{row['tyear']}-#{row['tmonth']}"
116 row['month'] = "#{row['tyear']}-#{row['tmonth']}"
117 when 'week'
117 when 'week'
118 row['week'] = "#{row['tyear']}-#{row['tweek']}"
118 row['week'] = "#{row['tyear']}-#{row['tweek']}"
119 when 'day'
119 when 'day'
120 row['day'] = "#{row['spent_on']}"
120 row['day'] = "#{row['spent_on']}"
121 end
121 end
122 end
122 end
123
123
124 @total_hours = @hours.inject(0) {|s,k| s = s + k['hours'].to_f}
124 @total_hours = @hours.inject(0) {|s,k| s = s + k['hours'].to_f}
125
125
126 @periods = []
126 @periods = []
127 # Date#at_beginning_of_ not supported in Rails 1.2.x
127 # Date#at_beginning_of_ not supported in Rails 1.2.x
128 date_from = @from.to_time
128 date_from = @from.to_time
129 # 100 columns max
129 # 100 columns max
130 while date_from <= @to.to_time && @periods.length < 100
130 while date_from <= @to.to_time && @periods.length < 100
131 case @columns
131 case @columns
132 when 'year'
132 when 'year'
133 @periods << "#{date_from.year}"
133 @periods << "#{date_from.year}"
134 date_from = (date_from + 1.year).at_beginning_of_year
134 date_from = (date_from + 1.year).at_beginning_of_year
135 when 'month'
135 when 'month'
136 @periods << "#{date_from.year}-#{date_from.month}"
136 @periods << "#{date_from.year}-#{date_from.month}"
137 date_from = (date_from + 1.month).at_beginning_of_month
137 date_from = (date_from + 1.month).at_beginning_of_month
138 when 'week'
138 when 'week'
139 @periods << "#{date_from.year}-#{date_from.to_date.cweek}"
139 @periods << "#{date_from.year}-#{date_from.to_date.cweek}"
140 date_from = (date_from + 7.day).at_beginning_of_week
140 date_from = (date_from + 7.day).at_beginning_of_week
141 when 'day'
141 when 'day'
142 @periods << "#{date_from.to_date}"
142 @periods << "#{date_from.to_date}"
143 date_from = date_from + 1.day
143 date_from = date_from + 1.day
144 end
144 end
145 end
145 end
146 end
146 end
147
147
148 respond_to do |format|
148 respond_to do |format|
149 format.html { render :layout => !request.xhr? }
149 format.html { render :layout => !request.xhr? }
150 format.csv { send_data(report_to_csv(@criterias, @periods, @hours).read, :type => 'text/csv; header=present', :filename => 'timelog.csv') }
150 format.csv { send_data(report_to_csv(@criterias, @periods, @hours), :type => 'text/csv; header=present', :filename => 'timelog.csv') }
151 end
151 end
152 end
152 end
153
153
154 def details
154 def details
155 sort_init 'spent_on', 'desc'
155 sort_init 'spent_on', 'desc'
156 sort_update 'spent_on' => 'spent_on',
156 sort_update 'spent_on' => 'spent_on',
157 'user' => 'user_id',
157 'user' => 'user_id',
158 'activity' => 'activity_id',
158 'activity' => 'activity_id',
159 'project' => "#{Project.table_name}.name",
159 'project' => "#{Project.table_name}.name",
160 'issue' => 'issue_id',
160 'issue' => 'issue_id',
161 'hours' => 'hours'
161 'hours' => 'hours'
162
162
163 cond = ARCondition.new
163 cond = ARCondition.new
164 if @project.nil?
164 if @project.nil?
165 cond << Project.allowed_to_condition(User.current, :view_time_entries)
165 cond << Project.allowed_to_condition(User.current, :view_time_entries)
166 elsif @issue.nil?
166 elsif @issue.nil?
167 cond << @project.project_condition(Setting.display_subprojects_issues?)
167 cond << @project.project_condition(Setting.display_subprojects_issues?)
168 else
168 else
169 cond << ["#{TimeEntry.table_name}.issue_id = ?", @issue.id]
169 cond << ["#{TimeEntry.table_name}.issue_id = ?", @issue.id]
170 end
170 end
171
171
172 retrieve_date_range
172 retrieve_date_range
173 cond << ['spent_on BETWEEN ? AND ?', @from, @to]
173 cond << ['spent_on BETWEEN ? AND ?', @from, @to]
174
174
175 TimeEntry.visible_by(User.current) do
175 TimeEntry.visible_by(User.current) do
176 respond_to do |format|
176 respond_to do |format|
177 format.html {
177 format.html {
178 # Paginate results
178 # Paginate results
179 @entry_count = TimeEntry.count(:include => :project, :conditions => cond.conditions)
179 @entry_count = TimeEntry.count(:include => :project, :conditions => cond.conditions)
180 @entry_pages = Paginator.new self, @entry_count, per_page_option, params['page']
180 @entry_pages = Paginator.new self, @entry_count, per_page_option, params['page']
181 @entries = TimeEntry.find(:all,
181 @entries = TimeEntry.find(:all,
182 :include => [:project, :activity, :user, {:issue => :tracker}],
182 :include => [:project, :activity, :user, {:issue => :tracker}],
183 :conditions => cond.conditions,
183 :conditions => cond.conditions,
184 :order => sort_clause,
184 :order => sort_clause,
185 :limit => @entry_pages.items_per_page,
185 :limit => @entry_pages.items_per_page,
186 :offset => @entry_pages.current.offset)
186 :offset => @entry_pages.current.offset)
187 @total_hours = TimeEntry.sum(:hours, :include => :project, :conditions => cond.conditions).to_f
187 @total_hours = TimeEntry.sum(:hours, :include => :project, :conditions => cond.conditions).to_f
188
188
189 render :layout => !request.xhr?
189 render :layout => !request.xhr?
190 }
190 }
191 format.atom {
191 format.atom {
192 entries = TimeEntry.find(:all,
192 entries = TimeEntry.find(:all,
193 :include => [:project, :activity, :user, {:issue => :tracker}],
193 :include => [:project, :activity, :user, {:issue => :tracker}],
194 :conditions => cond.conditions,
194 :conditions => cond.conditions,
195 :order => "#{TimeEntry.table_name}.created_on DESC",
195 :order => "#{TimeEntry.table_name}.created_on DESC",
196 :limit => Setting.feeds_limit.to_i)
196 :limit => Setting.feeds_limit.to_i)
197 render_feed(entries, :title => l(:label_spent_time))
197 render_feed(entries, :title => l(:label_spent_time))
198 }
198 }
199 format.csv {
199 format.csv {
200 # Export all entries
200 # Export all entries
201 @entries = TimeEntry.find(:all,
201 @entries = TimeEntry.find(:all,
202 :include => [:project, :activity, :user, {:issue => [:tracker, :assigned_to, :priority]}],
202 :include => [:project, :activity, :user, {:issue => [:tracker, :assigned_to, :priority]}],
203 :conditions => cond.conditions,
203 :conditions => cond.conditions,
204 :order => sort_clause)
204 :order => sort_clause)
205 send_data(entries_to_csv(@entries).read, :type => 'text/csv; header=present', :filename => 'timelog.csv')
205 send_data(entries_to_csv(@entries), :type => 'text/csv; header=present', :filename => 'timelog.csv')
206 }
206 }
207 end
207 end
208 end
208 end
209 end
209 end
210
210
211 def edit
211 def edit
212 render_403 and return if @time_entry && !@time_entry.editable_by?(User.current)
212 render_403 and return if @time_entry && !@time_entry.editable_by?(User.current)
213 @time_entry ||= TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => Date.today)
213 @time_entry ||= TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => Date.today)
214 @time_entry.attributes = params[:time_entry]
214 @time_entry.attributes = params[:time_entry]
215
215
216 call_hook(:controller_timelog_edit_before_save, { :params => params, :time_entry => @time_entry })
216 call_hook(:controller_timelog_edit_before_save, { :params => params, :time_entry => @time_entry })
217
217
218 if request.post? and @time_entry.save
218 if request.post? and @time_entry.save
219 flash[:notice] = l(:notice_successful_update)
219 flash[:notice] = l(:notice_successful_update)
220 redirect_back_or_default :action => 'details', :project_id => @time_entry.project
220 redirect_back_or_default :action => 'details', :project_id => @time_entry.project
221 return
221 return
222 end
222 end
223 end
223 end
224
224
225 def destroy
225 def destroy
226 render_404 and return unless @time_entry
226 render_404 and return unless @time_entry
227 render_403 and return unless @time_entry.editable_by?(User.current)
227 render_403 and return unless @time_entry.editable_by?(User.current)
228 @time_entry.destroy
228 @time_entry.destroy
229 flash[:notice] = l(:notice_successful_delete)
229 flash[:notice] = l(:notice_successful_delete)
230 redirect_to :back
230 redirect_to :back
231 rescue ::ActionController::RedirectBackError
231 rescue ::ActionController::RedirectBackError
232 redirect_to :action => 'details', :project_id => @time_entry.project
232 redirect_to :action => 'details', :project_id => @time_entry.project
233 end
233 end
234
234
235 private
235 private
236 def find_project
236 def find_project
237 if params[:id]
237 if params[:id]
238 @time_entry = TimeEntry.find(params[:id])
238 @time_entry = TimeEntry.find(params[:id])
239 @project = @time_entry.project
239 @project = @time_entry.project
240 elsif params[:issue_id]
240 elsif params[:issue_id]
241 @issue = Issue.find(params[:issue_id])
241 @issue = Issue.find(params[:issue_id])
242 @project = @issue.project
242 @project = @issue.project
243 elsif params[:project_id]
243 elsif params[:project_id]
244 @project = Project.find(params[:project_id])
244 @project = Project.find(params[:project_id])
245 else
245 else
246 render_404
246 render_404
247 return false
247 return false
248 end
248 end
249 rescue ActiveRecord::RecordNotFound
249 rescue ActiveRecord::RecordNotFound
250 render_404
250 render_404
251 end
251 end
252
252
253 def find_optional_project
253 def find_optional_project
254 if !params[:issue_id].blank?
254 if !params[:issue_id].blank?
255 @issue = Issue.find(params[:issue_id])
255 @issue = Issue.find(params[:issue_id])
256 @project = @issue.project
256 @project = @issue.project
257 elsif !params[:project_id].blank?
257 elsif !params[:project_id].blank?
258 @project = Project.find(params[:project_id])
258 @project = Project.find(params[:project_id])
259 end
259 end
260 deny_access unless User.current.allowed_to?(:view_time_entries, @project, :global => true)
260 deny_access unless User.current.allowed_to?(:view_time_entries, @project, :global => true)
261 end
261 end
262
262
263 # Retrieves the date range based on predefined ranges or specific from/to param dates
263 # Retrieves the date range based on predefined ranges or specific from/to param dates
264 def retrieve_date_range
264 def retrieve_date_range
265 @free_period = false
265 @free_period = false
266 @from, @to = nil, nil
266 @from, @to = nil, nil
267
267
268 if params[:period_type] == '1' || (params[:period_type].nil? && !params[:period].nil?)
268 if params[:period_type] == '1' || (params[:period_type].nil? && !params[:period].nil?)
269 case params[:period].to_s
269 case params[:period].to_s
270 when 'today'
270 when 'today'
271 @from = @to = Date.today
271 @from = @to = Date.today
272 when 'yesterday'
272 when 'yesterday'
273 @from = @to = Date.today - 1
273 @from = @to = Date.today - 1
274 when 'current_week'
274 when 'current_week'
275 @from = Date.today - (Date.today.cwday - 1)%7
275 @from = Date.today - (Date.today.cwday - 1)%7
276 @to = @from + 6
276 @to = @from + 6
277 when 'last_week'
277 when 'last_week'
278 @from = Date.today - 7 - (Date.today.cwday - 1)%7
278 @from = Date.today - 7 - (Date.today.cwday - 1)%7
279 @to = @from + 6
279 @to = @from + 6
280 when '7_days'
280 when '7_days'
281 @from = Date.today - 7
281 @from = Date.today - 7
282 @to = Date.today
282 @to = Date.today
283 when 'current_month'
283 when 'current_month'
284 @from = Date.civil(Date.today.year, Date.today.month, 1)
284 @from = Date.civil(Date.today.year, Date.today.month, 1)
285 @to = (@from >> 1) - 1
285 @to = (@from >> 1) - 1
286 when 'last_month'
286 when 'last_month'
287 @from = Date.civil(Date.today.year, Date.today.month, 1) << 1
287 @from = Date.civil(Date.today.year, Date.today.month, 1) << 1
288 @to = (@from >> 1) - 1
288 @to = (@from >> 1) - 1
289 when '30_days'
289 when '30_days'
290 @from = Date.today - 30
290 @from = Date.today - 30
291 @to = Date.today
291 @to = Date.today
292 when 'current_year'
292 when 'current_year'
293 @from = Date.civil(Date.today.year, 1, 1)
293 @from = Date.civil(Date.today.year, 1, 1)
294 @to = Date.civil(Date.today.year, 12, 31)
294 @to = Date.civil(Date.today.year, 12, 31)
295 end
295 end
296 elsif params[:period_type] == '2' || (params[:period_type].nil? && (!params[:from].nil? || !params[:to].nil?))
296 elsif params[:period_type] == '2' || (params[:period_type].nil? && (!params[:from].nil? || !params[:to].nil?))
297 begin; @from = params[:from].to_s.to_date unless params[:from].blank?; rescue; end
297 begin; @from = params[:from].to_s.to_date unless params[:from].blank?; rescue; end
298 begin; @to = params[:to].to_s.to_date unless params[:to].blank?; rescue; end
298 begin; @to = params[:to].to_s.to_date unless params[:to].blank?; rescue; end
299 @free_period = true
299 @free_period = true
300 else
300 else
301 # default
301 # default
302 end
302 end
303
303
304 @from, @to = @to, @from if @from && @to && @from > @to
304 @from, @to = @to, @from if @from && @to && @from > @to
305 @from ||= (TimeEntry.minimum(:spent_on, :include => :project, :conditions => Project.allowed_to_condition(User.current, :view_time_entries)) || Date.today) - 1
305 @from ||= (TimeEntry.minimum(:spent_on, :include => :project, :conditions => Project.allowed_to_condition(User.current, :view_time_entries)) || Date.today) - 1
306 @to ||= (TimeEntry.maximum(:spent_on, :include => :project, :conditions => Project.allowed_to_condition(User.current, :view_time_entries)) || Date.today)
306 @to ||= (TimeEntry.maximum(:spent_on, :include => :project, :conditions => Project.allowed_to_condition(User.current, :view_time_entries)) || Date.today)
307 end
307 end
308 end
308 end
@@ -1,203 +1,199
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006 Jean-Philippe Lang
2 # Copyright (C) 2006 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require 'csv'
19
20 module IssuesHelper
18 module IssuesHelper
21 include ApplicationHelper
19 include ApplicationHelper
22
20
23 def render_issue_tooltip(issue)
21 def render_issue_tooltip(issue)
24 @cached_label_start_date ||= l(:field_start_date)
22 @cached_label_start_date ||= l(:field_start_date)
25 @cached_label_due_date ||= l(:field_due_date)
23 @cached_label_due_date ||= l(:field_due_date)
26 @cached_label_assigned_to ||= l(:field_assigned_to)
24 @cached_label_assigned_to ||= l(:field_assigned_to)
27 @cached_label_priority ||= l(:field_priority)
25 @cached_label_priority ||= l(:field_priority)
28
26
29 link_to_issue(issue) + ": #{h(issue.subject)}<br /><br />" +
27 link_to_issue(issue) + ": #{h(issue.subject)}<br /><br />" +
30 "<strong>#{@cached_label_start_date}</strong>: #{format_date(issue.start_date)}<br />" +
28 "<strong>#{@cached_label_start_date}</strong>: #{format_date(issue.start_date)}<br />" +
31 "<strong>#{@cached_label_due_date}</strong>: #{format_date(issue.due_date)}<br />" +
29 "<strong>#{@cached_label_due_date}</strong>: #{format_date(issue.due_date)}<br />" +
32 "<strong>#{@cached_label_assigned_to}</strong>: #{issue.assigned_to}<br />" +
30 "<strong>#{@cached_label_assigned_to}</strong>: #{issue.assigned_to}<br />" +
33 "<strong>#{@cached_label_priority}</strong>: #{issue.priority.name}"
31 "<strong>#{@cached_label_priority}</strong>: #{issue.priority.name}"
34 end
32 end
35
33
36 def render_custom_fields_rows(issue)
34 def render_custom_fields_rows(issue)
37 return if issue.custom_field_values.empty?
35 return if issue.custom_field_values.empty?
38 ordered_values = []
36 ordered_values = []
39 half = (issue.custom_field_values.size / 2.0).ceil
37 half = (issue.custom_field_values.size / 2.0).ceil
40 half.times do |i|
38 half.times do |i|
41 ordered_values << issue.custom_field_values[i]
39 ordered_values << issue.custom_field_values[i]
42 ordered_values << issue.custom_field_values[i + half]
40 ordered_values << issue.custom_field_values[i + half]
43 end
41 end
44 s = "<tr>\n"
42 s = "<tr>\n"
45 n = 0
43 n = 0
46 ordered_values.compact.each do |value|
44 ordered_values.compact.each do |value|
47 s << "</tr>\n<tr>\n" if n > 0 && (n % 2) == 0
45 s << "</tr>\n<tr>\n" if n > 0 && (n % 2) == 0
48 s << "\t<th>#{ h(value.custom_field.name) }:</th><td>#{ simple_format_without_paragraph(h(show_value(value))) }</td>\n"
46 s << "\t<th>#{ h(value.custom_field.name) }:</th><td>#{ simple_format_without_paragraph(h(show_value(value))) }</td>\n"
49 n += 1
47 n += 1
50 end
48 end
51 s << "</tr>\n"
49 s << "</tr>\n"
52 s
50 s
53 end
51 end
54
52
55 def sidebar_queries
53 def sidebar_queries
56 unless @sidebar_queries
54 unless @sidebar_queries
57 # User can see public queries and his own queries
55 # User can see public queries and his own queries
58 visible = ARCondition.new(["is_public = ? OR user_id = ?", true, (User.current.logged? ? User.current.id : 0)])
56 visible = ARCondition.new(["is_public = ? OR user_id = ?", true, (User.current.logged? ? User.current.id : 0)])
59 # Project specific queries and global queries
57 # Project specific queries and global queries
60 visible << (@project.nil? ? ["project_id IS NULL"] : ["project_id IS NULL OR project_id = ?", @project.id])
58 visible << (@project.nil? ? ["project_id IS NULL"] : ["project_id IS NULL OR project_id = ?", @project.id])
61 @sidebar_queries = Query.find(:all,
59 @sidebar_queries = Query.find(:all,
62 :select => 'id, name',
60 :select => 'id, name',
63 :order => "name ASC",
61 :order => "name ASC",
64 :conditions => visible.conditions)
62 :conditions => visible.conditions)
65 end
63 end
66 @sidebar_queries
64 @sidebar_queries
67 end
65 end
68
66
69 def show_detail(detail, no_html=false)
67 def show_detail(detail, no_html=false)
70 case detail.property
68 case detail.property
71 when 'attr'
69 when 'attr'
72 label = l(("field_" + detail.prop_key.to_s.gsub(/\_id$/, "")).to_sym)
70 label = l(("field_" + detail.prop_key.to_s.gsub(/\_id$/, "")).to_sym)
73 case detail.prop_key
71 case detail.prop_key
74 when 'due_date', 'start_date'
72 when 'due_date', 'start_date'
75 value = format_date(detail.value.to_date) if detail.value
73 value = format_date(detail.value.to_date) if detail.value
76 old_value = format_date(detail.old_value.to_date) if detail.old_value
74 old_value = format_date(detail.old_value.to_date) if detail.old_value
77 when 'project_id'
75 when 'project_id'
78 p = Project.find_by_id(detail.value) and value = p.name if detail.value
76 p = Project.find_by_id(detail.value) and value = p.name if detail.value
79 p = Project.find_by_id(detail.old_value) and old_value = p.name if detail.old_value
77 p = Project.find_by_id(detail.old_value) and old_value = p.name if detail.old_value
80 when 'status_id'
78 when 'status_id'
81 s = IssueStatus.find_by_id(detail.value) and value = s.name if detail.value
79 s = IssueStatus.find_by_id(detail.value) and value = s.name if detail.value
82 s = IssueStatus.find_by_id(detail.old_value) and old_value = s.name if detail.old_value
80 s = IssueStatus.find_by_id(detail.old_value) and old_value = s.name if detail.old_value
83 when 'tracker_id'
81 when 'tracker_id'
84 t = Tracker.find_by_id(detail.value) and value = t.name if detail.value
82 t = Tracker.find_by_id(detail.value) and value = t.name if detail.value
85 t = Tracker.find_by_id(detail.old_value) and old_value = t.name if detail.old_value
83 t = Tracker.find_by_id(detail.old_value) and old_value = t.name if detail.old_value
86 when 'assigned_to_id'
84 when 'assigned_to_id'
87 u = User.find_by_id(detail.value) and value = u.name if detail.value
85 u = User.find_by_id(detail.value) and value = u.name if detail.value
88 u = User.find_by_id(detail.old_value) and old_value = u.name if detail.old_value
86 u = User.find_by_id(detail.old_value) and old_value = u.name if detail.old_value
89 when 'priority_id'
87 when 'priority_id'
90 e = IssuePriority.find_by_id(detail.value) and value = e.name if detail.value
88 e = IssuePriority.find_by_id(detail.value) and value = e.name if detail.value
91 e = IssuePriority.find_by_id(detail.old_value) and old_value = e.name if detail.old_value
89 e = IssuePriority.find_by_id(detail.old_value) and old_value = e.name if detail.old_value
92 when 'category_id'
90 when 'category_id'
93 c = IssueCategory.find_by_id(detail.value) and value = c.name if detail.value
91 c = IssueCategory.find_by_id(detail.value) and value = c.name if detail.value
94 c = IssueCategory.find_by_id(detail.old_value) and old_value = c.name if detail.old_value
92 c = IssueCategory.find_by_id(detail.old_value) and old_value = c.name if detail.old_value
95 when 'fixed_version_id'
93 when 'fixed_version_id'
96 v = Version.find_by_id(detail.value) and value = v.name if detail.value
94 v = Version.find_by_id(detail.value) and value = v.name if detail.value
97 v = Version.find_by_id(detail.old_value) and old_value = v.name if detail.old_value
95 v = Version.find_by_id(detail.old_value) and old_value = v.name if detail.old_value
98 when 'estimated_hours'
96 when 'estimated_hours'
99 value = "%0.02f" % detail.value.to_f unless detail.value.blank?
97 value = "%0.02f" % detail.value.to_f unless detail.value.blank?
100 old_value = "%0.02f" % detail.old_value.to_f unless detail.old_value.blank?
98 old_value = "%0.02f" % detail.old_value.to_f unless detail.old_value.blank?
101 end
99 end
102 when 'cf'
100 when 'cf'
103 custom_field = CustomField.find_by_id(detail.prop_key)
101 custom_field = CustomField.find_by_id(detail.prop_key)
104 if custom_field
102 if custom_field
105 label = custom_field.name
103 label = custom_field.name
106 value = format_value(detail.value, custom_field.field_format) if detail.value
104 value = format_value(detail.value, custom_field.field_format) if detail.value
107 old_value = format_value(detail.old_value, custom_field.field_format) if detail.old_value
105 old_value = format_value(detail.old_value, custom_field.field_format) if detail.old_value
108 end
106 end
109 when 'attachment'
107 when 'attachment'
110 label = l(:label_attachment)
108 label = l(:label_attachment)
111 end
109 end
112 call_hook(:helper_issues_show_detail_after_setting, {:detail => detail, :label => label, :value => value, :old_value => old_value })
110 call_hook(:helper_issues_show_detail_after_setting, {:detail => detail, :label => label, :value => value, :old_value => old_value })
113
111
114 label ||= detail.prop_key
112 label ||= detail.prop_key
115 value ||= detail.value
113 value ||= detail.value
116 old_value ||= detail.old_value
114 old_value ||= detail.old_value
117
115
118 unless no_html
116 unless no_html
119 label = content_tag('strong', label)
117 label = content_tag('strong', label)
120 old_value = content_tag("i", h(old_value)) if detail.old_value
118 old_value = content_tag("i", h(old_value)) if detail.old_value
121 old_value = content_tag("strike", old_value) if detail.old_value and (!detail.value or detail.value.empty?)
119 old_value = content_tag("strike", old_value) if detail.old_value and (!detail.value or detail.value.empty?)
122 if detail.property == 'attachment' && !value.blank? && a = Attachment.find_by_id(detail.prop_key)
120 if detail.property == 'attachment' && !value.blank? && a = Attachment.find_by_id(detail.prop_key)
123 # Link to the attachment if it has not been removed
121 # Link to the attachment if it has not been removed
124 value = link_to_attachment(a)
122 value = link_to_attachment(a)
125 else
123 else
126 value = content_tag("i", h(value)) if value
124 value = content_tag("i", h(value)) if value
127 end
125 end
128 end
126 end
129
127
130 if !detail.value.blank?
128 if !detail.value.blank?
131 case detail.property
129 case detail.property
132 when 'attr', 'cf'
130 when 'attr', 'cf'
133 if !detail.old_value.blank?
131 if !detail.old_value.blank?
134 l(:text_journal_changed, :label => label, :old => old_value, :new => value)
132 l(:text_journal_changed, :label => label, :old => old_value, :new => value)
135 else
133 else
136 l(:text_journal_set_to, :label => label, :value => value)
134 l(:text_journal_set_to, :label => label, :value => value)
137 end
135 end
138 when 'attachment'
136 when 'attachment'
139 l(:text_journal_added, :label => label, :value => value)
137 l(:text_journal_added, :label => label, :value => value)
140 end
138 end
141 else
139 else
142 l(:text_journal_deleted, :label => label, :old => old_value)
140 l(:text_journal_deleted, :label => label, :old => old_value)
143 end
141 end
144 end
142 end
145
143
146 def issues_to_csv(issues, project = nil)
144 def issues_to_csv(issues, project = nil)
147 ic = Iconv.new(l(:general_csv_encoding), 'UTF-8')
145 ic = Iconv.new(l(:general_csv_encoding), 'UTF-8')
148 decimal_separator = l(:general_csv_decimal_separator)
146 decimal_separator = l(:general_csv_decimal_separator)
149 export = StringIO.new
147 export = FCSV.generate(:col_sep => l(:general_csv_separator)) do |csv|
150 CSV::Writer.generate(export, l(:general_csv_separator)) do |csv|
151 # csv header fields
148 # csv header fields
152 headers = [ "#",
149 headers = [ "#",
153 l(:field_status),
150 l(:field_status),
154 l(:field_project),
151 l(:field_project),
155 l(:field_tracker),
152 l(:field_tracker),
156 l(:field_priority),
153 l(:field_priority),
157 l(:field_subject),
154 l(:field_subject),
158 l(:field_assigned_to),
155 l(:field_assigned_to),
159 l(:field_category),
156 l(:field_category),
160 l(:field_fixed_version),
157 l(:field_fixed_version),
161 l(:field_author),
158 l(:field_author),
162 l(:field_start_date),
159 l(:field_start_date),
163 l(:field_due_date),
160 l(:field_due_date),
164 l(:field_done_ratio),
161 l(:field_done_ratio),
165 l(:field_estimated_hours),
162 l(:field_estimated_hours),
166 l(:field_created_on),
163 l(:field_created_on),
167 l(:field_updated_on)
164 l(:field_updated_on)
168 ]
165 ]
169 # Export project custom fields if project is given
166 # Export project custom fields if project is given
170 # otherwise export custom fields marked as "For all projects"
167 # otherwise export custom fields marked as "For all projects"
171 custom_fields = project.nil? ? IssueCustomField.for_all : project.all_issue_custom_fields
168 custom_fields = project.nil? ? IssueCustomField.for_all : project.all_issue_custom_fields
172 custom_fields.each {|f| headers << f.name}
169 custom_fields.each {|f| headers << f.name}
173 # Description in the last column
170 # Description in the last column
174 headers << l(:field_description)
171 headers << l(:field_description)
175 csv << headers.collect {|c| begin; ic.iconv(c.to_s); rescue; c.to_s; end }
172 csv << headers.collect {|c| begin; ic.iconv(c.to_s); rescue; c.to_s; end }
176 # csv lines
173 # csv lines
177 issues.each do |issue|
174 issues.each do |issue|
178 fields = [issue.id,
175 fields = [issue.id,
179 issue.status.name,
176 issue.status.name,
180 issue.project.name,
177 issue.project.name,
181 issue.tracker.name,
178 issue.tracker.name,
182 issue.priority.name,
179 issue.priority.name,
183 issue.subject,
180 issue.subject,
184 issue.assigned_to,
181 issue.assigned_to,
185 issue.category,
182 issue.category,
186 issue.fixed_version,
183 issue.fixed_version,
187 issue.author.name,
184 issue.author.name,
188 format_date(issue.start_date),
185 format_date(issue.start_date),
189 format_date(issue.due_date),
186 format_date(issue.due_date),
190 issue.done_ratio,
187 issue.done_ratio,
191 issue.estimated_hours.to_s.gsub('.', decimal_separator),
188 issue.estimated_hours.to_s.gsub('.', decimal_separator),
192 format_time(issue.created_on),
189 format_time(issue.created_on),
193 format_time(issue.updated_on)
190 format_time(issue.updated_on)
194 ]
191 ]
195 custom_fields.each {|f| fields << show_value(issue.custom_value_for(f)) }
192 custom_fields.each {|f| fields << show_value(issue.custom_value_for(f)) }
196 fields << issue.description
193 fields << issue.description
197 csv << fields.collect {|c| begin; ic.iconv(c.to_s); rescue; c.to_s; end }
194 csv << fields.collect {|c| begin; ic.iconv(c.to_s); rescue; c.to_s; end }
198 end
195 end
199 end
196 end
200 export.rewind
201 export
197 export
202 end
198 end
203 end
199 end
@@ -1,177 +1,173
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006 Jean-Philippe Lang
2 # Copyright (C) 2006 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 module TimelogHelper
18 module TimelogHelper
19 include ApplicationHelper
19 include ApplicationHelper
20
20
21 def render_timelog_breadcrumb
21 def render_timelog_breadcrumb
22 links = []
22 links = []
23 links << link_to(l(:label_project_all), {:project_id => nil, :issue_id => nil})
23 links << link_to(l(:label_project_all), {:project_id => nil, :issue_id => nil})
24 links << link_to(h(@project), {:project_id => @project, :issue_id => nil}) if @project
24 links << link_to(h(@project), {:project_id => @project, :issue_id => nil}) if @project
25 links << link_to_issue(@issue) if @issue
25 links << link_to_issue(@issue) if @issue
26 breadcrumb links
26 breadcrumb links
27 end
27 end
28
28
29 # Returns a collection of activities for a select field. time_entry
29 # Returns a collection of activities for a select field. time_entry
30 # is optional and will be used to check if the selected TimeEntryActivity
30 # is optional and will be used to check if the selected TimeEntryActivity
31 # is active.
31 # is active.
32 def activity_collection_for_select_options(time_entry=nil, project=nil)
32 def activity_collection_for_select_options(time_entry=nil, project=nil)
33 project ||= @project
33 project ||= @project
34 if project.nil?
34 if project.nil?
35 activities = TimeEntryActivity.active
35 activities = TimeEntryActivity.active
36 else
36 else
37 activities = project.activities
37 activities = project.activities
38 end
38 end
39
39
40 collection = []
40 collection = []
41 if time_entry && time_entry.activity && !time_entry.activity.active?
41 if time_entry && time_entry.activity && !time_entry.activity.active?
42 collection << [ "--- #{l(:actionview_instancetag_blank_option)} ---", '' ]
42 collection << [ "--- #{l(:actionview_instancetag_blank_option)} ---", '' ]
43 else
43 else
44 collection << [ "--- #{l(:actionview_instancetag_blank_option)} ---", '' ] unless activities.detect(&:is_default)
44 collection << [ "--- #{l(:actionview_instancetag_blank_option)} ---", '' ] unless activities.detect(&:is_default)
45 end
45 end
46 activities.each { |a| collection << [a.name, a.id] }
46 activities.each { |a| collection << [a.name, a.id] }
47 collection
47 collection
48 end
48 end
49
49
50 def select_hours(data, criteria, value)
50 def select_hours(data, criteria, value)
51 if value.to_s.empty?
51 if value.to_s.empty?
52 data.select {|row| row[criteria].blank? }
52 data.select {|row| row[criteria].blank? }
53 else
53 else
54 data.select {|row| row[criteria] == value}
54 data.select {|row| row[criteria] == value}
55 end
55 end
56 end
56 end
57
57
58 def sum_hours(data)
58 def sum_hours(data)
59 sum = 0
59 sum = 0
60 data.each do |row|
60 data.each do |row|
61 sum += row['hours'].to_f
61 sum += row['hours'].to_f
62 end
62 end
63 sum
63 sum
64 end
64 end
65
65
66 def options_for_period_select(value)
66 def options_for_period_select(value)
67 options_for_select([[l(:label_all_time), 'all'],
67 options_for_select([[l(:label_all_time), 'all'],
68 [l(:label_today), 'today'],
68 [l(:label_today), 'today'],
69 [l(:label_yesterday), 'yesterday'],
69 [l(:label_yesterday), 'yesterday'],
70 [l(:label_this_week), 'current_week'],
70 [l(:label_this_week), 'current_week'],
71 [l(:label_last_week), 'last_week'],
71 [l(:label_last_week), 'last_week'],
72 [l(:label_last_n_days, 7), '7_days'],
72 [l(:label_last_n_days, 7), '7_days'],
73 [l(:label_this_month), 'current_month'],
73 [l(:label_this_month), 'current_month'],
74 [l(:label_last_month), 'last_month'],
74 [l(:label_last_month), 'last_month'],
75 [l(:label_last_n_days, 30), '30_days'],
75 [l(:label_last_n_days, 30), '30_days'],
76 [l(:label_this_year), 'current_year']],
76 [l(:label_this_year), 'current_year']],
77 value)
77 value)
78 end
78 end
79
79
80 def entries_to_csv(entries)
80 def entries_to_csv(entries)
81 ic = Iconv.new(l(:general_csv_encoding), 'UTF-8')
81 ic = Iconv.new(l(:general_csv_encoding), 'UTF-8')
82 decimal_separator = l(:general_csv_decimal_separator)
82 decimal_separator = l(:general_csv_decimal_separator)
83 custom_fields = TimeEntryCustomField.find(:all)
83 custom_fields = TimeEntryCustomField.find(:all)
84 export = StringIO.new
84 export = FCSV.generate(:col_sep => l(:general_csv_separator)) do |csv|
85 CSV::Writer.generate(export, l(:general_csv_separator)) do |csv|
86 # csv header fields
85 # csv header fields
87 headers = [l(:field_spent_on),
86 headers = [l(:field_spent_on),
88 l(:field_user),
87 l(:field_user),
89 l(:field_activity),
88 l(:field_activity),
90 l(:field_project),
89 l(:field_project),
91 l(:field_issue),
90 l(:field_issue),
92 l(:field_tracker),
91 l(:field_tracker),
93 l(:field_subject),
92 l(:field_subject),
94 l(:field_hours),
93 l(:field_hours),
95 l(:field_comments)
94 l(:field_comments)
96 ]
95 ]
97 # Export custom fields
96 # Export custom fields
98 headers += custom_fields.collect(&:name)
97 headers += custom_fields.collect(&:name)
99
98
100 csv << headers.collect {|c| begin; ic.iconv(c.to_s); rescue; c.to_s; end }
99 csv << headers.collect {|c| begin; ic.iconv(c.to_s); rescue; c.to_s; end }
101 # csv lines
100 # csv lines
102 entries.each do |entry|
101 entries.each do |entry|
103 fields = [format_date(entry.spent_on),
102 fields = [format_date(entry.spent_on),
104 entry.user,
103 entry.user,
105 entry.activity,
104 entry.activity,
106 entry.project,
105 entry.project,
107 (entry.issue ? entry.issue.id : nil),
106 (entry.issue ? entry.issue.id : nil),
108 (entry.issue ? entry.issue.tracker : nil),
107 (entry.issue ? entry.issue.tracker : nil),
109 (entry.issue ? entry.issue.subject : nil),
108 (entry.issue ? entry.issue.subject : nil),
110 entry.hours.to_s.gsub('.', decimal_separator),
109 entry.hours.to_s.gsub('.', decimal_separator),
111 entry.comments
110 entry.comments
112 ]
111 ]
113 fields += custom_fields.collect {|f| show_value(entry.custom_value_for(f)) }
112 fields += custom_fields.collect {|f| show_value(entry.custom_value_for(f)) }
114
113
115 csv << fields.collect {|c| begin; ic.iconv(c.to_s); rescue; c.to_s; end }
114 csv << fields.collect {|c| begin; ic.iconv(c.to_s); rescue; c.to_s; end }
116 end
115 end
117 end
116 end
118 export.rewind
119 export
117 export
120 end
118 end
121
119
122 def format_criteria_value(criteria, value)
120 def format_criteria_value(criteria, value)
123 value.blank? ? l(:label_none) : ((k = @available_criterias[criteria][:klass]) ? k.find_by_id(value.to_i) : format_value(value, @available_criterias[criteria][:format]))
121 value.blank? ? l(:label_none) : ((k = @available_criterias[criteria][:klass]) ? k.find_by_id(value.to_i) : format_value(value, @available_criterias[criteria][:format]))
124 end
122 end
125
123
126 def report_to_csv(criterias, periods, hours)
124 def report_to_csv(criterias, periods, hours)
127 export = StringIO.new
125 export = FCSV.generate(:col_sep => l(:general_csv_separator)) do |csv|
128 CSV::Writer.generate(export, l(:general_csv_separator)) do |csv|
129 # Column headers
126 # Column headers
130 headers = criterias.collect {|criteria| l(@available_criterias[criteria][:label]) }
127 headers = criterias.collect {|criteria| l(@available_criterias[criteria][:label]) }
131 headers += periods
128 headers += periods
132 headers << l(:label_total)
129 headers << l(:label_total)
133 csv << headers.collect {|c| to_utf8(c) }
130 csv << headers.collect {|c| to_utf8(c) }
134 # Content
131 # Content
135 report_criteria_to_csv(csv, criterias, periods, hours)
132 report_criteria_to_csv(csv, criterias, periods, hours)
136 # Total row
133 # Total row
137 row = [ l(:label_total) ] + [''] * (criterias.size - 1)
134 row = [ l(:label_total) ] + [''] * (criterias.size - 1)
138 total = 0
135 total = 0
139 periods.each do |period|
136 periods.each do |period|
140 sum = sum_hours(select_hours(hours, @columns, period.to_s))
137 sum = sum_hours(select_hours(hours, @columns, period.to_s))
141 total += sum
138 total += sum
142 row << (sum > 0 ? "%.2f" % sum : '')
139 row << (sum > 0 ? "%.2f" % sum : '')
143 end
140 end
144 row << "%.2f" %total
141 row << "%.2f" %total
145 csv << row
142 csv << row
146 end
143 end
147 export.rewind
148 export
144 export
149 end
145 end
150
146
151 def report_criteria_to_csv(csv, criterias, periods, hours, level=0)
147 def report_criteria_to_csv(csv, criterias, periods, hours, level=0)
152 hours.collect {|h| h[criterias[level]].to_s}.uniq.each do |value|
148 hours.collect {|h| h[criterias[level]].to_s}.uniq.each do |value|
153 hours_for_value = select_hours(hours, criterias[level], value)
149 hours_for_value = select_hours(hours, criterias[level], value)
154 next if hours_for_value.empty?
150 next if hours_for_value.empty?
155 row = [''] * level
151 row = [''] * level
156 row << to_utf8(format_criteria_value(criterias[level], value))
152 row << to_utf8(format_criteria_value(criterias[level], value))
157 row += [''] * (criterias.length - level - 1)
153 row += [''] * (criterias.length - level - 1)
158 total = 0
154 total = 0
159 periods.each do |period|
155 periods.each do |period|
160 sum = sum_hours(select_hours(hours_for_value, @columns, period.to_s))
156 sum = sum_hours(select_hours(hours_for_value, @columns, period.to_s))
161 total += sum
157 total += sum
162 row << (sum > 0 ? "%.2f" % sum : '')
158 row << (sum > 0 ? "%.2f" % sum : '')
163 end
159 end
164 row << "%.2f" %total
160 row << "%.2f" %total
165 csv << row
161 csv << row
166
162
167 if criterias.length > level + 1
163 if criterias.length > level + 1
168 report_criteria_to_csv(csv, criterias, periods, hours_for_value, level + 1)
164 report_criteria_to_csv(csv, criterias, periods, hours_for_value, level + 1)
169 end
165 end
170 end
166 end
171 end
167 end
172
168
173 def to_utf8(s)
169 def to_utf8(s)
174 @ic ||= Iconv.new(l(:general_csv_encoding), 'UTF-8')
170 @ic ||= Iconv.new(l(:general_csv_encoding), 'UTF-8')
175 begin; @ic.iconv(s.to_s); rescue; s.to_s; end
171 begin; @ic.iconv(s.to_s); rescue; s.to_s; end
176 end
172 end
177 end
173 end
@@ -1,167 +1,174
1 require 'redmine/access_control'
1 require 'redmine/access_control'
2 require 'redmine/menu_manager'
2 require 'redmine/menu_manager'
3 require 'redmine/activity'
3 require 'redmine/activity'
4 require 'redmine/mime_type'
4 require 'redmine/mime_type'
5 require 'redmine/core_ext'
5 require 'redmine/core_ext'
6 require 'redmine/themes'
6 require 'redmine/themes'
7 require 'redmine/hook'
7 require 'redmine/hook'
8 require 'redmine/plugin'
8 require 'redmine/plugin'
9 require 'redmine/wiki_formatting'
9 require 'redmine/wiki_formatting'
10
10
11 begin
11 begin
12 require_library_or_gem 'RMagick' unless Object.const_defined?(:Magick)
12 require_library_or_gem 'RMagick' unless Object.const_defined?(:Magick)
13 rescue LoadError
13 rescue LoadError
14 # RMagick is not available
14 # RMagick is not available
15 end
15 end
16
16
17 if RUBY_VERSION < '1.9'
18 require 'faster_csv'
19 else
20 require 'csv'
21 FCSV = CSV
22 end
23
17 REDMINE_SUPPORTED_SCM = %w( Subversion Darcs Mercurial Cvs Bazaar Git Filesystem )
24 REDMINE_SUPPORTED_SCM = %w( Subversion Darcs Mercurial Cvs Bazaar Git Filesystem )
18
25
19 # Permissions
26 # Permissions
20 Redmine::AccessControl.map do |map|
27 Redmine::AccessControl.map do |map|
21 map.permission :view_project, {:projects => [:show, :activity]}, :public => true
28 map.permission :view_project, {:projects => [:show, :activity]}, :public => true
22 map.permission :search_project, {:search => :index}, :public => true
29 map.permission :search_project, {:search => :index}, :public => true
23 map.permission :add_project, {:projects => :add}, :require => :loggedin
30 map.permission :add_project, {:projects => :add}, :require => :loggedin
24 map.permission :edit_project, {:projects => [:settings, :edit]}, :require => :member
31 map.permission :edit_project, {:projects => [:settings, :edit]}, :require => :member
25 map.permission :select_project_modules, {:projects => :modules}, :require => :member
32 map.permission :select_project_modules, {:projects => :modules}, :require => :member
26 map.permission :manage_members, {:projects => :settings, :members => [:new, :edit, :destroy, :autocomplete_for_member]}, :require => :member
33 map.permission :manage_members, {:projects => :settings, :members => [:new, :edit, :destroy, :autocomplete_for_member]}, :require => :member
27 map.permission :manage_versions, {:projects => [:settings, :add_version], :versions => [:edit, :destroy]}, :require => :member
34 map.permission :manage_versions, {:projects => [:settings, :add_version], :versions => [:edit, :destroy]}, :require => :member
28
35
29 map.project_module :issue_tracking do |map|
36 map.project_module :issue_tracking do |map|
30 # Issue categories
37 # Issue categories
31 map.permission :manage_categories, {:projects => [:settings, :add_issue_category], :issue_categories => [:edit, :destroy]}, :require => :member
38 map.permission :manage_categories, {:projects => [:settings, :add_issue_category], :issue_categories => [:edit, :destroy]}, :require => :member
32 # Issues
39 # Issues
33 map.permission :view_issues, {:projects => [:changelog, :roadmap],
40 map.permission :view_issues, {:projects => [:changelog, :roadmap],
34 :issues => [:index, :changes, :show, :context_menu],
41 :issues => [:index, :changes, :show, :context_menu],
35 :versions => [:show, :status_by],
42 :versions => [:show, :status_by],
36 :queries => :index,
43 :queries => :index,
37 :reports => :issue_report}, :public => true
44 :reports => :issue_report}, :public => true
38 map.permission :add_issues, {:issues => :new}
45 map.permission :add_issues, {:issues => :new}
39 map.permission :edit_issues, {:issues => [:edit, :reply, :bulk_edit]}
46 map.permission :edit_issues, {:issues => [:edit, :reply, :bulk_edit]}
40 map.permission :manage_issue_relations, {:issue_relations => [:new, :destroy]}
47 map.permission :manage_issue_relations, {:issue_relations => [:new, :destroy]}
41 map.permission :add_issue_notes, {:issues => [:edit, :reply]}
48 map.permission :add_issue_notes, {:issues => [:edit, :reply]}
42 map.permission :edit_issue_notes, {:journals => :edit}, :require => :loggedin
49 map.permission :edit_issue_notes, {:journals => :edit}, :require => :loggedin
43 map.permission :edit_own_issue_notes, {:journals => :edit}, :require => :loggedin
50 map.permission :edit_own_issue_notes, {:journals => :edit}, :require => :loggedin
44 map.permission :move_issues, {:issues => :move}, :require => :loggedin
51 map.permission :move_issues, {:issues => :move}, :require => :loggedin
45 map.permission :delete_issues, {:issues => :destroy}, :require => :member
52 map.permission :delete_issues, {:issues => :destroy}, :require => :member
46 # Queries
53 # Queries
47 map.permission :manage_public_queries, {:queries => [:new, :edit, :destroy]}, :require => :member
54 map.permission :manage_public_queries, {:queries => [:new, :edit, :destroy]}, :require => :member
48 map.permission :save_queries, {:queries => [:new, :edit, :destroy]}, :require => :loggedin
55 map.permission :save_queries, {:queries => [:new, :edit, :destroy]}, :require => :loggedin
49 # Gantt & calendar
56 # Gantt & calendar
50 map.permission :view_gantt, :issues => :gantt
57 map.permission :view_gantt, :issues => :gantt
51 map.permission :view_calendar, :issues => :calendar
58 map.permission :view_calendar, :issues => :calendar
52 # Watchers
59 # Watchers
53 map.permission :view_issue_watchers, {}
60 map.permission :view_issue_watchers, {}
54 map.permission :add_issue_watchers, {:watchers => :new}
61 map.permission :add_issue_watchers, {:watchers => :new}
55 map.permission :delete_issue_watchers, {:watchers => :destroy}
62 map.permission :delete_issue_watchers, {:watchers => :destroy}
56 end
63 end
57
64
58 map.project_module :time_tracking do |map|
65 map.project_module :time_tracking do |map|
59 map.permission :log_time, {:timelog => :edit}, :require => :loggedin
66 map.permission :log_time, {:timelog => :edit}, :require => :loggedin
60 map.permission :view_time_entries, :timelog => [:details, :report]
67 map.permission :view_time_entries, :timelog => [:details, :report]
61 map.permission :edit_time_entries, {:timelog => [:edit, :destroy]}, :require => :member
68 map.permission :edit_time_entries, {:timelog => [:edit, :destroy]}, :require => :member
62 map.permission :edit_own_time_entries, {:timelog => [:edit, :destroy]}, :require => :loggedin
69 map.permission :edit_own_time_entries, {:timelog => [:edit, :destroy]}, :require => :loggedin
63 map.permission :manage_project_activities, {:projects => [:save_activities, :reset_activities]}, :require => :member
70 map.permission :manage_project_activities, {:projects => [:save_activities, :reset_activities]}, :require => :member
64 end
71 end
65
72
66 map.project_module :news do |map|
73 map.project_module :news do |map|
67 map.permission :manage_news, {:news => [:new, :edit, :destroy, :destroy_comment]}, :require => :member
74 map.permission :manage_news, {:news => [:new, :edit, :destroy, :destroy_comment]}, :require => :member
68 map.permission :view_news, {:news => [:index, :show]}, :public => true
75 map.permission :view_news, {:news => [:index, :show]}, :public => true
69 map.permission :comment_news, {:news => :add_comment}
76 map.permission :comment_news, {:news => :add_comment}
70 end
77 end
71
78
72 map.project_module :documents do |map|
79 map.project_module :documents do |map|
73 map.permission :manage_documents, {:documents => [:new, :edit, :destroy, :add_attachment]}, :require => :loggedin
80 map.permission :manage_documents, {:documents => [:new, :edit, :destroy, :add_attachment]}, :require => :loggedin
74 map.permission :view_documents, :documents => [:index, :show, :download]
81 map.permission :view_documents, :documents => [:index, :show, :download]
75 end
82 end
76
83
77 map.project_module :files do |map|
84 map.project_module :files do |map|
78 map.permission :manage_files, {:projects => :add_file}, :require => :loggedin
85 map.permission :manage_files, {:projects => :add_file}, :require => :loggedin
79 map.permission :view_files, :projects => :list_files, :versions => :download
86 map.permission :view_files, :projects => :list_files, :versions => :download
80 end
87 end
81
88
82 map.project_module :wiki do |map|
89 map.project_module :wiki do |map|
83 map.permission :manage_wiki, {:wikis => [:edit, :destroy]}, :require => :member
90 map.permission :manage_wiki, {:wikis => [:edit, :destroy]}, :require => :member
84 map.permission :rename_wiki_pages, {:wiki => :rename}, :require => :member
91 map.permission :rename_wiki_pages, {:wiki => :rename}, :require => :member
85 map.permission :delete_wiki_pages, {:wiki => :destroy}, :require => :member
92 map.permission :delete_wiki_pages, {:wiki => :destroy}, :require => :member
86 map.permission :view_wiki_pages, :wiki => [:index, :special]
93 map.permission :view_wiki_pages, :wiki => [:index, :special]
87 map.permission :view_wiki_edits, :wiki => [:history, :diff, :annotate]
94 map.permission :view_wiki_edits, :wiki => [:history, :diff, :annotate]
88 map.permission :edit_wiki_pages, :wiki => [:edit, :preview, :add_attachment]
95 map.permission :edit_wiki_pages, :wiki => [:edit, :preview, :add_attachment]
89 map.permission :delete_wiki_pages_attachments, {}
96 map.permission :delete_wiki_pages_attachments, {}
90 map.permission :protect_wiki_pages, {:wiki => :protect}, :require => :member
97 map.permission :protect_wiki_pages, {:wiki => :protect}, :require => :member
91 end
98 end
92
99
93 map.project_module :repository do |map|
100 map.project_module :repository do |map|
94 map.permission :manage_repository, {:repositories => [:edit, :committers, :destroy]}, :require => :member
101 map.permission :manage_repository, {:repositories => [:edit, :committers, :destroy]}, :require => :member
95 map.permission :browse_repository, :repositories => [:show, :browse, :entry, :annotate, :changes, :diff, :stats, :graph]
102 map.permission :browse_repository, :repositories => [:show, :browse, :entry, :annotate, :changes, :diff, :stats, :graph]
96 map.permission :view_changesets, :repositories => [:show, :revisions, :revision]
103 map.permission :view_changesets, :repositories => [:show, :revisions, :revision]
97 map.permission :commit_access, {}
104 map.permission :commit_access, {}
98 end
105 end
99
106
100 map.project_module :boards do |map|
107 map.project_module :boards do |map|
101 map.permission :manage_boards, {:boards => [:new, :edit, :destroy]}, :require => :member
108 map.permission :manage_boards, {:boards => [:new, :edit, :destroy]}, :require => :member
102 map.permission :view_messages, {:boards => [:index, :show], :messages => [:show]}, :public => true
109 map.permission :view_messages, {:boards => [:index, :show], :messages => [:show]}, :public => true
103 map.permission :add_messages, {:messages => [:new, :reply, :quote]}
110 map.permission :add_messages, {:messages => [:new, :reply, :quote]}
104 map.permission :edit_messages, {:messages => :edit}, :require => :member
111 map.permission :edit_messages, {:messages => :edit}, :require => :member
105 map.permission :edit_own_messages, {:messages => :edit}, :require => :loggedin
112 map.permission :edit_own_messages, {:messages => :edit}, :require => :loggedin
106 map.permission :delete_messages, {:messages => :destroy}, :require => :member
113 map.permission :delete_messages, {:messages => :destroy}, :require => :member
107 map.permission :delete_own_messages, {:messages => :destroy}, :require => :loggedin
114 map.permission :delete_own_messages, {:messages => :destroy}, :require => :loggedin
108 end
115 end
109 end
116 end
110
117
111 Redmine::MenuManager.map :top_menu do |menu|
118 Redmine::MenuManager.map :top_menu do |menu|
112 menu.push :home, :home_path
119 menu.push :home, :home_path
113 menu.push :my_page, { :controller => 'my', :action => 'page' }, :if => Proc.new { User.current.logged? }
120 menu.push :my_page, { :controller => 'my', :action => 'page' }, :if => Proc.new { User.current.logged? }
114 menu.push :projects, { :controller => 'projects', :action => 'index' }, :caption => :label_project_plural
121 menu.push :projects, { :controller => 'projects', :action => 'index' }, :caption => :label_project_plural
115 menu.push :administration, { :controller => 'admin', :action => 'index' }, :if => Proc.new { User.current.admin? }, :last => true
122 menu.push :administration, { :controller => 'admin', :action => 'index' }, :if => Proc.new { User.current.admin? }, :last => true
116 menu.push :help, Redmine::Info.help_url, :last => true
123 menu.push :help, Redmine::Info.help_url, :last => true
117 end
124 end
118
125
119 Redmine::MenuManager.map :account_menu do |menu|
126 Redmine::MenuManager.map :account_menu do |menu|
120 menu.push :login, :signin_path, :if => Proc.new { !User.current.logged? }
127 menu.push :login, :signin_path, :if => Proc.new { !User.current.logged? }
121 menu.push :register, { :controller => 'account', :action => 'register' }, :if => Proc.new { !User.current.logged? && Setting.self_registration? }
128 menu.push :register, { :controller => 'account', :action => 'register' }, :if => Proc.new { !User.current.logged? && Setting.self_registration? }
122 menu.push :my_account, { :controller => 'my', :action => 'account' }, :if => Proc.new { User.current.logged? }
129 menu.push :my_account, { :controller => 'my', :action => 'account' }, :if => Proc.new { User.current.logged? }
123 menu.push :logout, :signout_path, :if => Proc.new { User.current.logged? }
130 menu.push :logout, :signout_path, :if => Proc.new { User.current.logged? }
124 end
131 end
125
132
126 Redmine::MenuManager.map :application_menu do |menu|
133 Redmine::MenuManager.map :application_menu do |menu|
127 # Empty
134 # Empty
128 end
135 end
129
136
130 Redmine::MenuManager.map :admin_menu do |menu|
137 Redmine::MenuManager.map :admin_menu do |menu|
131 # Empty
138 # Empty
132 end
139 end
133
140
134 Redmine::MenuManager.map :project_menu do |menu|
141 Redmine::MenuManager.map :project_menu do |menu|
135 menu.push :overview, { :controller => 'projects', :action => 'show' }
142 menu.push :overview, { :controller => 'projects', :action => 'show' }
136 menu.push :activity, { :controller => 'projects', :action => 'activity' }
143 menu.push :activity, { :controller => 'projects', :action => 'activity' }
137 menu.push :roadmap, { :controller => 'projects', :action => 'roadmap' },
144 menu.push :roadmap, { :controller => 'projects', :action => 'roadmap' },
138 :if => Proc.new { |p| p.versions.any? }
145 :if => Proc.new { |p| p.versions.any? }
139 menu.push :issues, { :controller => 'issues', :action => 'index' }, :param => :project_id, :caption => :label_issue_plural
146 menu.push :issues, { :controller => 'issues', :action => 'index' }, :param => :project_id, :caption => :label_issue_plural
140 menu.push :new_issue, { :controller => 'issues', :action => 'new' }, :param => :project_id, :caption => :label_issue_new,
147 menu.push :new_issue, { :controller => 'issues', :action => 'new' }, :param => :project_id, :caption => :label_issue_new,
141 :html => { :accesskey => Redmine::AccessKeys.key_for(:new_issue) }
148 :html => { :accesskey => Redmine::AccessKeys.key_for(:new_issue) }
142 menu.push :news, { :controller => 'news', :action => 'index' }, :param => :project_id, :caption => :label_news_plural
149 menu.push :news, { :controller => 'news', :action => 'index' }, :param => :project_id, :caption => :label_news_plural
143 menu.push :documents, { :controller => 'documents', :action => 'index' }, :param => :project_id, :caption => :label_document_plural
150 menu.push :documents, { :controller => 'documents', :action => 'index' }, :param => :project_id, :caption => :label_document_plural
144 menu.push :wiki, { :controller => 'wiki', :action => 'index', :page => nil },
151 menu.push :wiki, { :controller => 'wiki', :action => 'index', :page => nil },
145 :if => Proc.new { |p| p.wiki && !p.wiki.new_record? }
152 :if => Proc.new { |p| p.wiki && !p.wiki.new_record? }
146 menu.push :boards, { :controller => 'boards', :action => 'index', :id => nil }, :param => :project_id,
153 menu.push :boards, { :controller => 'boards', :action => 'index', :id => nil }, :param => :project_id,
147 :if => Proc.new { |p| p.boards.any? }, :caption => :label_board_plural
154 :if => Proc.new { |p| p.boards.any? }, :caption => :label_board_plural
148 menu.push :files, { :controller => 'projects', :action => 'list_files' }, :caption => :label_attachment_plural
155 menu.push :files, { :controller => 'projects', :action => 'list_files' }, :caption => :label_attachment_plural
149 menu.push :repository, { :controller => 'repositories', :action => 'show' },
156 menu.push :repository, { :controller => 'repositories', :action => 'show' },
150 :if => Proc.new { |p| p.repository && !p.repository.new_record? }
157 :if => Proc.new { |p| p.repository && !p.repository.new_record? }
151 menu.push :settings, { :controller => 'projects', :action => 'settings' }, :last => true
158 menu.push :settings, { :controller => 'projects', :action => 'settings' }, :last => true
152 end
159 end
153
160
154 Redmine::Activity.map do |activity|
161 Redmine::Activity.map do |activity|
155 activity.register :issues, :class_name => %w(Issue Journal)
162 activity.register :issues, :class_name => %w(Issue Journal)
156 activity.register :changesets
163 activity.register :changesets
157 activity.register :news
164 activity.register :news
158 activity.register :documents, :class_name => %w(Document Attachment)
165 activity.register :documents, :class_name => %w(Document Attachment)
159 activity.register :files, :class_name => 'Attachment'
166 activity.register :files, :class_name => 'Attachment'
160 activity.register :wiki_edits, :class_name => 'WikiContent::Version', :default => false
167 activity.register :wiki_edits, :class_name => 'WikiContent::Version', :default => false
161 activity.register :messages, :default => false
168 activity.register :messages, :default => false
162 activity.register :time_entries, :default => false
169 activity.register :time_entries, :default => false
163 end
170 end
164
171
165 Redmine::WikiFormatting.map do |format|
172 Redmine::WikiFormatting.map do |format|
166 format.register :textile, Redmine::WikiFormatting::Textile::Formatter, Redmine::WikiFormatting::Textile::Helper
173 format.register :textile, Redmine::WikiFormatting::Textile::Formatter, Redmine::WikiFormatting::Textile::Helper
167 end
174 end
@@ -1,1103 +1,1106
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2008 Jean-Philippe Lang
2 # Copyright (C) 2006-2008 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require File.dirname(__FILE__) + '/../test_helper'
18 require File.dirname(__FILE__) + '/../test_helper'
19 require 'issues_controller'
19 require 'issues_controller'
20
20
21 # Re-raise errors caught by the controller.
21 # Re-raise errors caught by the controller.
22 class IssuesController; def rescue_action(e) raise e end; end
22 class IssuesController; def rescue_action(e) raise e end; end
23
23
24 class IssuesControllerTest < ActionController::TestCase
24 class IssuesControllerTest < ActionController::TestCase
25 fixtures :projects,
25 fixtures :projects,
26 :users,
26 :users,
27 :roles,
27 :roles,
28 :members,
28 :members,
29 :member_roles,
29 :member_roles,
30 :issues,
30 :issues,
31 :issue_statuses,
31 :issue_statuses,
32 :versions,
32 :versions,
33 :trackers,
33 :trackers,
34 :projects_trackers,
34 :projects_trackers,
35 :issue_categories,
35 :issue_categories,
36 :enabled_modules,
36 :enabled_modules,
37 :enumerations,
37 :enumerations,
38 :attachments,
38 :attachments,
39 :workflows,
39 :workflows,
40 :custom_fields,
40 :custom_fields,
41 :custom_values,
41 :custom_values,
42 :custom_fields_trackers,
42 :custom_fields_trackers,
43 :time_entries,
43 :time_entries,
44 :journals,
44 :journals,
45 :journal_details,
45 :journal_details,
46 :queries
46 :queries
47
47
48 def setup
48 def setup
49 @controller = IssuesController.new
49 @controller = IssuesController.new
50 @request = ActionController::TestRequest.new
50 @request = ActionController::TestRequest.new
51 @response = ActionController::TestResponse.new
51 @response = ActionController::TestResponse.new
52 User.current = nil
52 User.current = nil
53 end
53 end
54
54
55 def test_index_routing
55 def test_index_routing
56 assert_routing(
56 assert_routing(
57 {:method => :get, :path => '/issues'},
57 {:method => :get, :path => '/issues'},
58 :controller => 'issues', :action => 'index'
58 :controller => 'issues', :action => 'index'
59 )
59 )
60 end
60 end
61
61
62 def test_index
62 def test_index
63 Setting.default_language = 'en'
63 Setting.default_language = 'en'
64
64
65 get :index
65 get :index
66 assert_response :success
66 assert_response :success
67 assert_template 'index.rhtml'
67 assert_template 'index.rhtml'
68 assert_not_nil assigns(:issues)
68 assert_not_nil assigns(:issues)
69 assert_nil assigns(:project)
69 assert_nil assigns(:project)
70 assert_tag :tag => 'a', :content => /Can't print recipes/
70 assert_tag :tag => 'a', :content => /Can't print recipes/
71 assert_tag :tag => 'a', :content => /Subproject issue/
71 assert_tag :tag => 'a', :content => /Subproject issue/
72 # private projects hidden
72 # private projects hidden
73 assert_no_tag :tag => 'a', :content => /Issue of a private subproject/
73 assert_no_tag :tag => 'a', :content => /Issue of a private subproject/
74 assert_no_tag :tag => 'a', :content => /Issue on project 2/
74 assert_no_tag :tag => 'a', :content => /Issue on project 2/
75 # project column
75 # project column
76 assert_tag :tag => 'th', :content => /Project/
76 assert_tag :tag => 'th', :content => /Project/
77 end
77 end
78
78
79 def test_index_should_not_list_issues_when_module_disabled
79 def test_index_should_not_list_issues_when_module_disabled
80 EnabledModule.delete_all("name = 'issue_tracking' AND project_id = 1")
80 EnabledModule.delete_all("name = 'issue_tracking' AND project_id = 1")
81 get :index
81 get :index
82 assert_response :success
82 assert_response :success
83 assert_template 'index.rhtml'
83 assert_template 'index.rhtml'
84 assert_not_nil assigns(:issues)
84 assert_not_nil assigns(:issues)
85 assert_nil assigns(:project)
85 assert_nil assigns(:project)
86 assert_no_tag :tag => 'a', :content => /Can't print recipes/
86 assert_no_tag :tag => 'a', :content => /Can't print recipes/
87 assert_tag :tag => 'a', :content => /Subproject issue/
87 assert_tag :tag => 'a', :content => /Subproject issue/
88 end
88 end
89
89
90 def test_index_with_project_routing
90 def test_index_with_project_routing
91 assert_routing(
91 assert_routing(
92 {:method => :get, :path => '/projects/23/issues'},
92 {:method => :get, :path => '/projects/23/issues'},
93 :controller => 'issues', :action => 'index', :project_id => '23'
93 :controller => 'issues', :action => 'index', :project_id => '23'
94 )
94 )
95 end
95 end
96
96
97 def test_index_should_not_list_issues_when_module_disabled
97 def test_index_should_not_list_issues_when_module_disabled
98 EnabledModule.delete_all("name = 'issue_tracking' AND project_id = 1")
98 EnabledModule.delete_all("name = 'issue_tracking' AND project_id = 1")
99 get :index
99 get :index
100 assert_response :success
100 assert_response :success
101 assert_template 'index.rhtml'
101 assert_template 'index.rhtml'
102 assert_not_nil assigns(:issues)
102 assert_not_nil assigns(:issues)
103 assert_nil assigns(:project)
103 assert_nil assigns(:project)
104 assert_no_tag :tag => 'a', :content => /Can't print recipes/
104 assert_no_tag :tag => 'a', :content => /Can't print recipes/
105 assert_tag :tag => 'a', :content => /Subproject issue/
105 assert_tag :tag => 'a', :content => /Subproject issue/
106 end
106 end
107
107
108 def test_index_with_project_routing
108 def test_index_with_project_routing
109 assert_routing(
109 assert_routing(
110 {:method => :get, :path => 'projects/23/issues'},
110 {:method => :get, :path => 'projects/23/issues'},
111 :controller => 'issues', :action => 'index', :project_id => '23'
111 :controller => 'issues', :action => 'index', :project_id => '23'
112 )
112 )
113 end
113 end
114
114
115 def test_index_with_project
115 def test_index_with_project
116 Setting.display_subprojects_issues = 0
116 Setting.display_subprojects_issues = 0
117 get :index, :project_id => 1
117 get :index, :project_id => 1
118 assert_response :success
118 assert_response :success
119 assert_template 'index.rhtml'
119 assert_template 'index.rhtml'
120 assert_not_nil assigns(:issues)
120 assert_not_nil assigns(:issues)
121 assert_tag :tag => 'a', :content => /Can't print recipes/
121 assert_tag :tag => 'a', :content => /Can't print recipes/
122 assert_no_tag :tag => 'a', :content => /Subproject issue/
122 assert_no_tag :tag => 'a', :content => /Subproject issue/
123 end
123 end
124
124
125 def test_index_with_project_and_subprojects
125 def test_index_with_project_and_subprojects
126 Setting.display_subprojects_issues = 1
126 Setting.display_subprojects_issues = 1
127 get :index, :project_id => 1
127 get :index, :project_id => 1
128 assert_response :success
128 assert_response :success
129 assert_template 'index.rhtml'
129 assert_template 'index.rhtml'
130 assert_not_nil assigns(:issues)
130 assert_not_nil assigns(:issues)
131 assert_tag :tag => 'a', :content => /Can't print recipes/
131 assert_tag :tag => 'a', :content => /Can't print recipes/
132 assert_tag :tag => 'a', :content => /Subproject issue/
132 assert_tag :tag => 'a', :content => /Subproject issue/
133 assert_no_tag :tag => 'a', :content => /Issue of a private subproject/
133 assert_no_tag :tag => 'a', :content => /Issue of a private subproject/
134 end
134 end
135
135
136 def test_index_with_project_and_subprojects_should_show_private_subprojects
136 def test_index_with_project_and_subprojects_should_show_private_subprojects
137 @request.session[:user_id] = 2
137 @request.session[:user_id] = 2
138 Setting.display_subprojects_issues = 1
138 Setting.display_subprojects_issues = 1
139 get :index, :project_id => 1
139 get :index, :project_id => 1
140 assert_response :success
140 assert_response :success
141 assert_template 'index.rhtml'
141 assert_template 'index.rhtml'
142 assert_not_nil assigns(:issues)
142 assert_not_nil assigns(:issues)
143 assert_tag :tag => 'a', :content => /Can't print recipes/
143 assert_tag :tag => 'a', :content => /Can't print recipes/
144 assert_tag :tag => 'a', :content => /Subproject issue/
144 assert_tag :tag => 'a', :content => /Subproject issue/
145 assert_tag :tag => 'a', :content => /Issue of a private subproject/
145 assert_tag :tag => 'a', :content => /Issue of a private subproject/
146 end
146 end
147
147
148 def test_index_with_project_routing_formatted
148 def test_index_with_project_routing_formatted
149 assert_routing(
149 assert_routing(
150 {:method => :get, :path => 'projects/23/issues.pdf'},
150 {:method => :get, :path => 'projects/23/issues.pdf'},
151 :controller => 'issues', :action => 'index', :project_id => '23', :format => 'pdf'
151 :controller => 'issues', :action => 'index', :project_id => '23', :format => 'pdf'
152 )
152 )
153 assert_routing(
153 assert_routing(
154 {:method => :get, :path => 'projects/23/issues.atom'},
154 {:method => :get, :path => 'projects/23/issues.atom'},
155 :controller => 'issues', :action => 'index', :project_id => '23', :format => 'atom'
155 :controller => 'issues', :action => 'index', :project_id => '23', :format => 'atom'
156 )
156 )
157 end
157 end
158
158
159 def test_index_with_project_and_filter
159 def test_index_with_project_and_filter
160 get :index, :project_id => 1, :set_filter => 1
160 get :index, :project_id => 1, :set_filter => 1
161 assert_response :success
161 assert_response :success
162 assert_template 'index.rhtml'
162 assert_template 'index.rhtml'
163 assert_not_nil assigns(:issues)
163 assert_not_nil assigns(:issues)
164 end
164 end
165
165
166 def test_index_with_query
166 def test_index_with_query
167 get :index, :project_id => 1, :query_id => 5
167 get :index, :project_id => 1, :query_id => 5
168 assert_response :success
168 assert_response :success
169 assert_template 'index.rhtml'
169 assert_template 'index.rhtml'
170 assert_not_nil assigns(:issues)
170 assert_not_nil assigns(:issues)
171 assert_nil assigns(:issue_count_by_group)
171 assert_nil assigns(:issue_count_by_group)
172 end
172 end
173
173
174 def test_index_with_grouped_query
174 def test_index_with_grouped_query
175 get :index, :project_id => 1, :query_id => 6
175 get :index, :project_id => 1, :query_id => 6
176 assert_response :success
176 assert_response :success
177 assert_template 'index.rhtml'
177 assert_template 'index.rhtml'
178 assert_not_nil assigns(:issues)
178 assert_not_nil assigns(:issues)
179 assert_not_nil assigns(:issue_count_by_group)
179 assert_not_nil assigns(:issue_count_by_group)
180 end
180 end
181
181
182 def test_index_csv_with_project
182 def test_index_csv_with_project
183 Setting.default_language = 'en'
184
183 get :index, :format => 'csv'
185 get :index, :format => 'csv'
184 assert_response :success
186 assert_response :success
185 assert_not_nil assigns(:issues)
187 assert_not_nil assigns(:issues)
186 assert_equal 'text/csv', @response.content_type
188 assert_equal 'text/csv', @response.content_type
189 assert @response.body.starts_with?("#,")
187
190
188 get :index, :project_id => 1, :format => 'csv'
191 get :index, :project_id => 1, :format => 'csv'
189 assert_response :success
192 assert_response :success
190 assert_not_nil assigns(:issues)
193 assert_not_nil assigns(:issues)
191 assert_equal 'text/csv', @response.content_type
194 assert_equal 'text/csv', @response.content_type
192 end
195 end
193
196
194 def test_index_formatted
197 def test_index_formatted
195 assert_routing(
198 assert_routing(
196 {:method => :get, :path => 'issues.pdf'},
199 {:method => :get, :path => 'issues.pdf'},
197 :controller => 'issues', :action => 'index', :format => 'pdf'
200 :controller => 'issues', :action => 'index', :format => 'pdf'
198 )
201 )
199 assert_routing(
202 assert_routing(
200 {:method => :get, :path => 'issues.atom'},
203 {:method => :get, :path => 'issues.atom'},
201 :controller => 'issues', :action => 'index', :format => 'atom'
204 :controller => 'issues', :action => 'index', :format => 'atom'
202 )
205 )
203 end
206 end
204
207
205 def test_index_pdf
208 def test_index_pdf
206 get :index, :format => 'pdf'
209 get :index, :format => 'pdf'
207 assert_response :success
210 assert_response :success
208 assert_not_nil assigns(:issues)
211 assert_not_nil assigns(:issues)
209 assert_equal 'application/pdf', @response.content_type
212 assert_equal 'application/pdf', @response.content_type
210
213
211 get :index, :project_id => 1, :format => 'pdf'
214 get :index, :project_id => 1, :format => 'pdf'
212 assert_response :success
215 assert_response :success
213 assert_not_nil assigns(:issues)
216 assert_not_nil assigns(:issues)
214 assert_equal 'application/pdf', @response.content_type
217 assert_equal 'application/pdf', @response.content_type
215
218
216 get :index, :project_id => 1, :query_id => 6, :format => 'pdf'
219 get :index, :project_id => 1, :query_id => 6, :format => 'pdf'
217 assert_response :success
220 assert_response :success
218 assert_not_nil assigns(:issues)
221 assert_not_nil assigns(:issues)
219 assert_equal 'application/pdf', @response.content_type
222 assert_equal 'application/pdf', @response.content_type
220 end
223 end
221
224
222 def test_index_sort
225 def test_index_sort
223 get :index, :sort => 'tracker,id:desc'
226 get :index, :sort => 'tracker,id:desc'
224 assert_response :success
227 assert_response :success
225
228
226 sort_params = @request.session['issues_index_sort']
229 sort_params = @request.session['issues_index_sort']
227 assert sort_params.is_a?(String)
230 assert sort_params.is_a?(String)
228 assert_equal 'tracker,id:desc', sort_params
231 assert_equal 'tracker,id:desc', sort_params
229
232
230 issues = assigns(:issues)
233 issues = assigns(:issues)
231 assert_not_nil issues
234 assert_not_nil issues
232 assert !issues.empty?
235 assert !issues.empty?
233 assert_equal issues.sort {|a,b| a.tracker == b.tracker ? b.id <=> a.id : a.tracker <=> b.tracker }.collect(&:id), issues.collect(&:id)
236 assert_equal issues.sort {|a,b| a.tracker == b.tracker ? b.id <=> a.id : a.tracker <=> b.tracker }.collect(&:id), issues.collect(&:id)
234 end
237 end
235
238
236 def test_gantt
239 def test_gantt
237 get :gantt, :project_id => 1
240 get :gantt, :project_id => 1
238 assert_response :success
241 assert_response :success
239 assert_template 'gantt.rhtml'
242 assert_template 'gantt.rhtml'
240 assert_not_nil assigns(:gantt)
243 assert_not_nil assigns(:gantt)
241 events = assigns(:gantt).events
244 events = assigns(:gantt).events
242 assert_not_nil events
245 assert_not_nil events
243 # Issue with start and due dates
246 # Issue with start and due dates
244 i = Issue.find(1)
247 i = Issue.find(1)
245 assert_not_nil i.due_date
248 assert_not_nil i.due_date
246 assert events.include?(Issue.find(1))
249 assert events.include?(Issue.find(1))
247 # Issue with without due date but targeted to a version with date
250 # Issue with without due date but targeted to a version with date
248 i = Issue.find(2)
251 i = Issue.find(2)
249 assert_nil i.due_date
252 assert_nil i.due_date
250 assert events.include?(i)
253 assert events.include?(i)
251 end
254 end
252
255
253 def test_cross_project_gantt
256 def test_cross_project_gantt
254 get :gantt
257 get :gantt
255 assert_response :success
258 assert_response :success
256 assert_template 'gantt.rhtml'
259 assert_template 'gantt.rhtml'
257 assert_not_nil assigns(:gantt)
260 assert_not_nil assigns(:gantt)
258 events = assigns(:gantt).events
261 events = assigns(:gantt).events
259 assert_not_nil events
262 assert_not_nil events
260 end
263 end
261
264
262 def test_gantt_export_to_pdf
265 def test_gantt_export_to_pdf
263 get :gantt, :project_id => 1, :format => 'pdf'
266 get :gantt, :project_id => 1, :format => 'pdf'
264 assert_response :success
267 assert_response :success
265 assert_equal 'application/pdf', @response.content_type
268 assert_equal 'application/pdf', @response.content_type
266 assert @response.body.starts_with?('%PDF')
269 assert @response.body.starts_with?('%PDF')
267 assert_not_nil assigns(:gantt)
270 assert_not_nil assigns(:gantt)
268 end
271 end
269
272
270 def test_cross_project_gantt_export_to_pdf
273 def test_cross_project_gantt_export_to_pdf
271 get :gantt, :format => 'pdf'
274 get :gantt, :format => 'pdf'
272 assert_response :success
275 assert_response :success
273 assert_equal 'application/pdf', @response.content_type
276 assert_equal 'application/pdf', @response.content_type
274 assert @response.body.starts_with?('%PDF')
277 assert @response.body.starts_with?('%PDF')
275 assert_not_nil assigns(:gantt)
278 assert_not_nil assigns(:gantt)
276 end
279 end
277
280
278 if Object.const_defined?(:Magick)
281 if Object.const_defined?(:Magick)
279 def test_gantt_image
282 def test_gantt_image
280 get :gantt, :project_id => 1, :format => 'png'
283 get :gantt, :project_id => 1, :format => 'png'
281 assert_response :success
284 assert_response :success
282 assert_equal 'image/png', @response.content_type
285 assert_equal 'image/png', @response.content_type
283 end
286 end
284 else
287 else
285 puts "RMagick not installed. Skipping tests !!!"
288 puts "RMagick not installed. Skipping tests !!!"
286 end
289 end
287
290
288 def test_calendar
291 def test_calendar
289 get :calendar, :project_id => 1
292 get :calendar, :project_id => 1
290 assert_response :success
293 assert_response :success
291 assert_template 'calendar'
294 assert_template 'calendar'
292 assert_not_nil assigns(:calendar)
295 assert_not_nil assigns(:calendar)
293 end
296 end
294
297
295 def test_cross_project_calendar
298 def test_cross_project_calendar
296 get :calendar
299 get :calendar
297 assert_response :success
300 assert_response :success
298 assert_template 'calendar'
301 assert_template 'calendar'
299 assert_not_nil assigns(:calendar)
302 assert_not_nil assigns(:calendar)
300 end
303 end
301
304
302 def test_changes
305 def test_changes
303 get :changes, :project_id => 1
306 get :changes, :project_id => 1
304 assert_response :success
307 assert_response :success
305 assert_not_nil assigns(:journals)
308 assert_not_nil assigns(:journals)
306 assert_equal 'application/atom+xml', @response.content_type
309 assert_equal 'application/atom+xml', @response.content_type
307 end
310 end
308
311
309 def test_show_routing
312 def test_show_routing
310 assert_routing(
313 assert_routing(
311 {:method => :get, :path => '/issues/64'},
314 {:method => :get, :path => '/issues/64'},
312 :controller => 'issues', :action => 'show', :id => '64'
315 :controller => 'issues', :action => 'show', :id => '64'
313 )
316 )
314 end
317 end
315
318
316 def test_show_routing_formatted
319 def test_show_routing_formatted
317 assert_routing(
320 assert_routing(
318 {:method => :get, :path => '/issues/2332.pdf'},
321 {:method => :get, :path => '/issues/2332.pdf'},
319 :controller => 'issues', :action => 'show', :id => '2332', :format => 'pdf'
322 :controller => 'issues', :action => 'show', :id => '2332', :format => 'pdf'
320 )
323 )
321 assert_routing(
324 assert_routing(
322 {:method => :get, :path => '/issues/23123.atom'},
325 {:method => :get, :path => '/issues/23123.atom'},
323 :controller => 'issues', :action => 'show', :id => '23123', :format => 'atom'
326 :controller => 'issues', :action => 'show', :id => '23123', :format => 'atom'
324 )
327 )
325 end
328 end
326
329
327 def test_show_by_anonymous
330 def test_show_by_anonymous
328 get :show, :id => 1
331 get :show, :id => 1
329 assert_response :success
332 assert_response :success
330 assert_template 'show.rhtml'
333 assert_template 'show.rhtml'
331 assert_not_nil assigns(:issue)
334 assert_not_nil assigns(:issue)
332 assert_equal Issue.find(1), assigns(:issue)
335 assert_equal Issue.find(1), assigns(:issue)
333
336
334 # anonymous role is allowed to add a note
337 # anonymous role is allowed to add a note
335 assert_tag :tag => 'form',
338 assert_tag :tag => 'form',
336 :descendant => { :tag => 'fieldset',
339 :descendant => { :tag => 'fieldset',
337 :child => { :tag => 'legend',
340 :child => { :tag => 'legend',
338 :content => /Notes/ } }
341 :content => /Notes/ } }
339 end
342 end
340
343
341 def test_show_by_manager
344 def test_show_by_manager
342 @request.session[:user_id] = 2
345 @request.session[:user_id] = 2
343 get :show, :id => 1
346 get :show, :id => 1
344 assert_response :success
347 assert_response :success
345
348
346 assert_tag :tag => 'form',
349 assert_tag :tag => 'form',
347 :descendant => { :tag => 'fieldset',
350 :descendant => { :tag => 'fieldset',
348 :child => { :tag => 'legend',
351 :child => { :tag => 'legend',
349 :content => /Change properties/ } },
352 :content => /Change properties/ } },
350 :descendant => { :tag => 'fieldset',
353 :descendant => { :tag => 'fieldset',
351 :child => { :tag => 'legend',
354 :child => { :tag => 'legend',
352 :content => /Log time/ } },
355 :content => /Log time/ } },
353 :descendant => { :tag => 'fieldset',
356 :descendant => { :tag => 'fieldset',
354 :child => { :tag => 'legend',
357 :child => { :tag => 'legend',
355 :content => /Notes/ } }
358 :content => /Notes/ } }
356 end
359 end
357
360
358 def test_show_should_not_disclose_relations_to_invisible_issues
361 def test_show_should_not_disclose_relations_to_invisible_issues
359 Setting.cross_project_issue_relations = '1'
362 Setting.cross_project_issue_relations = '1'
360 IssueRelation.create!(:issue_from => Issue.find(1), :issue_to => Issue.find(2), :relation_type => 'relates')
363 IssueRelation.create!(:issue_from => Issue.find(1), :issue_to => Issue.find(2), :relation_type => 'relates')
361 # Relation to a private project issue
364 # Relation to a private project issue
362 IssueRelation.create!(:issue_from => Issue.find(1), :issue_to => Issue.find(4), :relation_type => 'relates')
365 IssueRelation.create!(:issue_from => Issue.find(1), :issue_to => Issue.find(4), :relation_type => 'relates')
363
366
364 get :show, :id => 1
367 get :show, :id => 1
365 assert_response :success
368 assert_response :success
366
369
367 assert_tag :div, :attributes => { :id => 'relations' },
370 assert_tag :div, :attributes => { :id => 'relations' },
368 :descendant => { :tag => 'a', :content => /#2$/ }
371 :descendant => { :tag => 'a', :content => /#2$/ }
369 assert_no_tag :div, :attributes => { :id => 'relations' },
372 assert_no_tag :div, :attributes => { :id => 'relations' },
370 :descendant => { :tag => 'a', :content => /#4$/ }
373 :descendant => { :tag => 'a', :content => /#4$/ }
371 end
374 end
372
375
373 def test_show_atom
376 def test_show_atom
374 get :show, :id => 2, :format => 'atom'
377 get :show, :id => 2, :format => 'atom'
375 assert_response :success
378 assert_response :success
376 assert_template 'changes.rxml'
379 assert_template 'changes.rxml'
377 # Inline image
380 # Inline image
378 assert @response.body.include?("&lt;img src=\"http://test.host/attachments/download/10\" alt=\"\" /&gt;")
381 assert @response.body.include?("&lt;img src=\"http://test.host/attachments/download/10\" alt=\"\" /&gt;")
379 end
382 end
380
383
381 def test_new_routing
384 def test_new_routing
382 assert_routing(
385 assert_routing(
383 {:method => :get, :path => '/projects/1/issues/new'},
386 {:method => :get, :path => '/projects/1/issues/new'},
384 :controller => 'issues', :action => 'new', :project_id => '1'
387 :controller => 'issues', :action => 'new', :project_id => '1'
385 )
388 )
386 assert_recognizes(
389 assert_recognizes(
387 {:controller => 'issues', :action => 'new', :project_id => '1'},
390 {:controller => 'issues', :action => 'new', :project_id => '1'},
388 {:method => :post, :path => '/projects/1/issues'}
391 {:method => :post, :path => '/projects/1/issues'}
389 )
392 )
390 end
393 end
391
394
392 def test_show_export_to_pdf
395 def test_show_export_to_pdf
393 get :show, :id => 3, :format => 'pdf'
396 get :show, :id => 3, :format => 'pdf'
394 assert_response :success
397 assert_response :success
395 assert_equal 'application/pdf', @response.content_type
398 assert_equal 'application/pdf', @response.content_type
396 assert @response.body.starts_with?('%PDF')
399 assert @response.body.starts_with?('%PDF')
397 assert_not_nil assigns(:issue)
400 assert_not_nil assigns(:issue)
398 end
401 end
399
402
400 def test_get_new
403 def test_get_new
401 @request.session[:user_id] = 2
404 @request.session[:user_id] = 2
402 get :new, :project_id => 1, :tracker_id => 1
405 get :new, :project_id => 1, :tracker_id => 1
403 assert_response :success
406 assert_response :success
404 assert_template 'new'
407 assert_template 'new'
405
408
406 assert_tag :tag => 'input', :attributes => { :name => 'issue[custom_field_values][2]',
409 assert_tag :tag => 'input', :attributes => { :name => 'issue[custom_field_values][2]',
407 :value => 'Default string' }
410 :value => 'Default string' }
408 end
411 end
409
412
410 def test_get_new_without_tracker_id
413 def test_get_new_without_tracker_id
411 @request.session[:user_id] = 2
414 @request.session[:user_id] = 2
412 get :new, :project_id => 1
415 get :new, :project_id => 1
413 assert_response :success
416 assert_response :success
414 assert_template 'new'
417 assert_template 'new'
415
418
416 issue = assigns(:issue)
419 issue = assigns(:issue)
417 assert_not_nil issue
420 assert_not_nil issue
418 assert_equal Project.find(1).trackers.first, issue.tracker
421 assert_equal Project.find(1).trackers.first, issue.tracker
419 end
422 end
420
423
421 def test_get_new_with_no_default_status_should_display_an_error
424 def test_get_new_with_no_default_status_should_display_an_error
422 @request.session[:user_id] = 2
425 @request.session[:user_id] = 2
423 IssueStatus.delete_all
426 IssueStatus.delete_all
424
427
425 get :new, :project_id => 1
428 get :new, :project_id => 1
426 assert_response 500
429 assert_response 500
427 assert_not_nil flash[:error]
430 assert_not_nil flash[:error]
428 assert_tag :tag => 'div', :attributes => { :class => /error/ },
431 assert_tag :tag => 'div', :attributes => { :class => /error/ },
429 :content => /No default issue/
432 :content => /No default issue/
430 end
433 end
431
434
432 def test_get_new_with_no_tracker_should_display_an_error
435 def test_get_new_with_no_tracker_should_display_an_error
433 @request.session[:user_id] = 2
436 @request.session[:user_id] = 2
434 Tracker.delete_all
437 Tracker.delete_all
435
438
436 get :new, :project_id => 1
439 get :new, :project_id => 1
437 assert_response 500
440 assert_response 500
438 assert_not_nil flash[:error]
441 assert_not_nil flash[:error]
439 assert_tag :tag => 'div', :attributes => { :class => /error/ },
442 assert_tag :tag => 'div', :attributes => { :class => /error/ },
440 :content => /No tracker/
443 :content => /No tracker/
441 end
444 end
442
445
443 def test_update_new_form
446 def test_update_new_form
444 @request.session[:user_id] = 2
447 @request.session[:user_id] = 2
445 xhr :post, :new, :project_id => 1,
448 xhr :post, :new, :project_id => 1,
446 :issue => {:tracker_id => 2,
449 :issue => {:tracker_id => 2,
447 :subject => 'This is the test_new issue',
450 :subject => 'This is the test_new issue',
448 :description => 'This is the description',
451 :description => 'This is the description',
449 :priority_id => 5}
452 :priority_id => 5}
450 assert_response :success
453 assert_response :success
451 assert_template 'new'
454 assert_template 'new'
452 end
455 end
453
456
454 def test_post_new
457 def test_post_new
455 @request.session[:user_id] = 2
458 @request.session[:user_id] = 2
456 assert_difference 'Issue.count' do
459 assert_difference 'Issue.count' do
457 post :new, :project_id => 1,
460 post :new, :project_id => 1,
458 :issue => {:tracker_id => 3,
461 :issue => {:tracker_id => 3,
459 :subject => 'This is the test_new issue',
462 :subject => 'This is the test_new issue',
460 :description => 'This is the description',
463 :description => 'This is the description',
461 :priority_id => 5,
464 :priority_id => 5,
462 :estimated_hours => '',
465 :estimated_hours => '',
463 :custom_field_values => {'2' => 'Value for field 2'}}
466 :custom_field_values => {'2' => 'Value for field 2'}}
464 end
467 end
465 assert_redirected_to :controller => 'issues', :action => 'show', :id => Issue.last.id
468 assert_redirected_to :controller => 'issues', :action => 'show', :id => Issue.last.id
466
469
467 issue = Issue.find_by_subject('This is the test_new issue')
470 issue = Issue.find_by_subject('This is the test_new issue')
468 assert_not_nil issue
471 assert_not_nil issue
469 assert_equal 2, issue.author_id
472 assert_equal 2, issue.author_id
470 assert_equal 3, issue.tracker_id
473 assert_equal 3, issue.tracker_id
471 assert_nil issue.estimated_hours
474 assert_nil issue.estimated_hours
472 v = issue.custom_values.find(:first, :conditions => {:custom_field_id => 2})
475 v = issue.custom_values.find(:first, :conditions => {:custom_field_id => 2})
473 assert_not_nil v
476 assert_not_nil v
474 assert_equal 'Value for field 2', v.value
477 assert_equal 'Value for field 2', v.value
475 end
478 end
476
479
477 def test_post_new_and_continue
480 def test_post_new_and_continue
478 @request.session[:user_id] = 2
481 @request.session[:user_id] = 2
479 post :new, :project_id => 1,
482 post :new, :project_id => 1,
480 :issue => {:tracker_id => 3,
483 :issue => {:tracker_id => 3,
481 :subject => 'This is first issue',
484 :subject => 'This is first issue',
482 :priority_id => 5},
485 :priority_id => 5},
483 :continue => ''
486 :continue => ''
484 assert_redirected_to :controller => 'issues', :action => 'new', :tracker_id => 3
487 assert_redirected_to :controller => 'issues', :action => 'new', :tracker_id => 3
485 end
488 end
486
489
487 def test_post_new_without_custom_fields_param
490 def test_post_new_without_custom_fields_param
488 @request.session[:user_id] = 2
491 @request.session[:user_id] = 2
489 assert_difference 'Issue.count' do
492 assert_difference 'Issue.count' do
490 post :new, :project_id => 1,
493 post :new, :project_id => 1,
491 :issue => {:tracker_id => 1,
494 :issue => {:tracker_id => 1,
492 :subject => 'This is the test_new issue',
495 :subject => 'This is the test_new issue',
493 :description => 'This is the description',
496 :description => 'This is the description',
494 :priority_id => 5}
497 :priority_id => 5}
495 end
498 end
496 assert_redirected_to :controller => 'issues', :action => 'show', :id => Issue.last.id
499 assert_redirected_to :controller => 'issues', :action => 'show', :id => Issue.last.id
497 end
500 end
498
501
499 def test_post_new_with_required_custom_field_and_without_custom_fields_param
502 def test_post_new_with_required_custom_field_and_without_custom_fields_param
500 field = IssueCustomField.find_by_name('Database')
503 field = IssueCustomField.find_by_name('Database')
501 field.update_attribute(:is_required, true)
504 field.update_attribute(:is_required, true)
502
505
503 @request.session[:user_id] = 2
506 @request.session[:user_id] = 2
504 post :new, :project_id => 1,
507 post :new, :project_id => 1,
505 :issue => {:tracker_id => 1,
508 :issue => {:tracker_id => 1,
506 :subject => 'This is the test_new issue',
509 :subject => 'This is the test_new issue',
507 :description => 'This is the description',
510 :description => 'This is the description',
508 :priority_id => 5}
511 :priority_id => 5}
509 assert_response :success
512 assert_response :success
510 assert_template 'new'
513 assert_template 'new'
511 issue = assigns(:issue)
514 issue = assigns(:issue)
512 assert_not_nil issue
515 assert_not_nil issue
513 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
516 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
514 end
517 end
515
518
516 def test_post_new_with_watchers
519 def test_post_new_with_watchers
517 @request.session[:user_id] = 2
520 @request.session[:user_id] = 2
518 ActionMailer::Base.deliveries.clear
521 ActionMailer::Base.deliveries.clear
519
522
520 assert_difference 'Watcher.count', 2 do
523 assert_difference 'Watcher.count', 2 do
521 post :new, :project_id => 1,
524 post :new, :project_id => 1,
522 :issue => {:tracker_id => 1,
525 :issue => {:tracker_id => 1,
523 :subject => 'This is a new issue with watchers',
526 :subject => 'This is a new issue with watchers',
524 :description => 'This is the description',
527 :description => 'This is the description',
525 :priority_id => 5,
528 :priority_id => 5,
526 :watcher_user_ids => ['2', '3']}
529 :watcher_user_ids => ['2', '3']}
527 end
530 end
528 issue = Issue.find_by_subject('This is a new issue with watchers')
531 issue = Issue.find_by_subject('This is a new issue with watchers')
529 assert_not_nil issue
532 assert_not_nil issue
530 assert_redirected_to :controller => 'issues', :action => 'show', :id => issue
533 assert_redirected_to :controller => 'issues', :action => 'show', :id => issue
531
534
532 # Watchers added
535 # Watchers added
533 assert_equal [2, 3], issue.watcher_user_ids.sort
536 assert_equal [2, 3], issue.watcher_user_ids.sort
534 assert issue.watched_by?(User.find(3))
537 assert issue.watched_by?(User.find(3))
535 # Watchers notified
538 # Watchers notified
536 mail = ActionMailer::Base.deliveries.last
539 mail = ActionMailer::Base.deliveries.last
537 assert_kind_of TMail::Mail, mail
540 assert_kind_of TMail::Mail, mail
538 assert [mail.bcc, mail.cc].flatten.include?(User.find(3).mail)
541 assert [mail.bcc, mail.cc].flatten.include?(User.find(3).mail)
539 end
542 end
540
543
541 def test_post_new_should_send_a_notification
544 def test_post_new_should_send_a_notification
542 ActionMailer::Base.deliveries.clear
545 ActionMailer::Base.deliveries.clear
543 @request.session[:user_id] = 2
546 @request.session[:user_id] = 2
544 assert_difference 'Issue.count' do
547 assert_difference 'Issue.count' do
545 post :new, :project_id => 1,
548 post :new, :project_id => 1,
546 :issue => {:tracker_id => 3,
549 :issue => {:tracker_id => 3,
547 :subject => 'This is the test_new issue',
550 :subject => 'This is the test_new issue',
548 :description => 'This is the description',
551 :description => 'This is the description',
549 :priority_id => 5,
552 :priority_id => 5,
550 :estimated_hours => '',
553 :estimated_hours => '',
551 :custom_field_values => {'2' => 'Value for field 2'}}
554 :custom_field_values => {'2' => 'Value for field 2'}}
552 end
555 end
553 assert_redirected_to :controller => 'issues', :action => 'show', :id => Issue.last.id
556 assert_redirected_to :controller => 'issues', :action => 'show', :id => Issue.last.id
554
557
555 assert_equal 1, ActionMailer::Base.deliveries.size
558 assert_equal 1, ActionMailer::Base.deliveries.size
556 end
559 end
557
560
558 def test_post_should_preserve_fields_values_on_validation_failure
561 def test_post_should_preserve_fields_values_on_validation_failure
559 @request.session[:user_id] = 2
562 @request.session[:user_id] = 2
560 post :new, :project_id => 1,
563 post :new, :project_id => 1,
561 :issue => {:tracker_id => 1,
564 :issue => {:tracker_id => 1,
562 # empty subject
565 # empty subject
563 :subject => '',
566 :subject => '',
564 :description => 'This is a description',
567 :description => 'This is a description',
565 :priority_id => 6,
568 :priority_id => 6,
566 :custom_field_values => {'1' => 'Oracle', '2' => 'Value for field 2'}}
569 :custom_field_values => {'1' => 'Oracle', '2' => 'Value for field 2'}}
567 assert_response :success
570 assert_response :success
568 assert_template 'new'
571 assert_template 'new'
569
572
570 assert_tag :textarea, :attributes => { :name => 'issue[description]' },
573 assert_tag :textarea, :attributes => { :name => 'issue[description]' },
571 :content => 'This is a description'
574 :content => 'This is a description'
572 assert_tag :select, :attributes => { :name => 'issue[priority_id]' },
575 assert_tag :select, :attributes => { :name => 'issue[priority_id]' },
573 :child => { :tag => 'option', :attributes => { :selected => 'selected',
576 :child => { :tag => 'option', :attributes => { :selected => 'selected',
574 :value => '6' },
577 :value => '6' },
575 :content => 'High' }
578 :content => 'High' }
576 # Custom fields
579 # Custom fields
577 assert_tag :select, :attributes => { :name => 'issue[custom_field_values][1]' },
580 assert_tag :select, :attributes => { :name => 'issue[custom_field_values][1]' },
578 :child => { :tag => 'option', :attributes => { :selected => 'selected',
581 :child => { :tag => 'option', :attributes => { :selected => 'selected',
579 :value => 'Oracle' },
582 :value => 'Oracle' },
580 :content => 'Oracle' }
583 :content => 'Oracle' }
581 assert_tag :input, :attributes => { :name => 'issue[custom_field_values][2]',
584 assert_tag :input, :attributes => { :name => 'issue[custom_field_values][2]',
582 :value => 'Value for field 2'}
585 :value => 'Value for field 2'}
583 end
586 end
584
587
585 def test_copy_routing
588 def test_copy_routing
586 assert_routing(
589 assert_routing(
587 {:method => :get, :path => '/projects/world_domination/issues/567/copy'},
590 {:method => :get, :path => '/projects/world_domination/issues/567/copy'},
588 :controller => 'issues', :action => 'new', :project_id => 'world_domination', :copy_from => '567'
591 :controller => 'issues', :action => 'new', :project_id => 'world_domination', :copy_from => '567'
589 )
592 )
590 end
593 end
591
594
592 def test_copy_issue
595 def test_copy_issue
593 @request.session[:user_id] = 2
596 @request.session[:user_id] = 2
594 get :new, :project_id => 1, :copy_from => 1
597 get :new, :project_id => 1, :copy_from => 1
595 assert_template 'new'
598 assert_template 'new'
596 assert_not_nil assigns(:issue)
599 assert_not_nil assigns(:issue)
597 orig = Issue.find(1)
600 orig = Issue.find(1)
598 assert_equal orig.subject, assigns(:issue).subject
601 assert_equal orig.subject, assigns(:issue).subject
599 end
602 end
600
603
601 def test_edit_routing
604 def test_edit_routing
602 assert_routing(
605 assert_routing(
603 {:method => :get, :path => '/issues/1/edit'},
606 {:method => :get, :path => '/issues/1/edit'},
604 :controller => 'issues', :action => 'edit', :id => '1'
607 :controller => 'issues', :action => 'edit', :id => '1'
605 )
608 )
606 assert_recognizes( #TODO: use a PUT on the issue URI isntead, need to adjust form
609 assert_recognizes( #TODO: use a PUT on the issue URI isntead, need to adjust form
607 {:controller => 'issues', :action => 'edit', :id => '1'},
610 {:controller => 'issues', :action => 'edit', :id => '1'},
608 {:method => :post, :path => '/issues/1/edit'}
611 {:method => :post, :path => '/issues/1/edit'}
609 )
612 )
610 end
613 end
611
614
612 def test_get_edit
615 def test_get_edit
613 @request.session[:user_id] = 2
616 @request.session[:user_id] = 2
614 get :edit, :id => 1
617 get :edit, :id => 1
615 assert_response :success
618 assert_response :success
616 assert_template 'edit'
619 assert_template 'edit'
617 assert_not_nil assigns(:issue)
620 assert_not_nil assigns(:issue)
618 assert_equal Issue.find(1), assigns(:issue)
621 assert_equal Issue.find(1), assigns(:issue)
619 end
622 end
620
623
621 def test_get_edit_with_params
624 def test_get_edit_with_params
622 @request.session[:user_id] = 2
625 @request.session[:user_id] = 2
623 get :edit, :id => 1, :issue => { :status_id => 5, :priority_id => 7 }
626 get :edit, :id => 1, :issue => { :status_id => 5, :priority_id => 7 }
624 assert_response :success
627 assert_response :success
625 assert_template 'edit'
628 assert_template 'edit'
626
629
627 issue = assigns(:issue)
630 issue = assigns(:issue)
628 assert_not_nil issue
631 assert_not_nil issue
629
632
630 assert_equal 5, issue.status_id
633 assert_equal 5, issue.status_id
631 assert_tag :select, :attributes => { :name => 'issue[status_id]' },
634 assert_tag :select, :attributes => { :name => 'issue[status_id]' },
632 :child => { :tag => 'option',
635 :child => { :tag => 'option',
633 :content => 'Closed',
636 :content => 'Closed',
634 :attributes => { :selected => 'selected' } }
637 :attributes => { :selected => 'selected' } }
635
638
636 assert_equal 7, issue.priority_id
639 assert_equal 7, issue.priority_id
637 assert_tag :select, :attributes => { :name => 'issue[priority_id]' },
640 assert_tag :select, :attributes => { :name => 'issue[priority_id]' },
638 :child => { :tag => 'option',
641 :child => { :tag => 'option',
639 :content => 'Urgent',
642 :content => 'Urgent',
640 :attributes => { :selected => 'selected' } }
643 :attributes => { :selected => 'selected' } }
641 end
644 end
642
645
643 def test_reply_routing
646 def test_reply_routing
644 assert_routing(
647 assert_routing(
645 {:method => :post, :path => '/issues/1/quoted'},
648 {:method => :post, :path => '/issues/1/quoted'},
646 :controller => 'issues', :action => 'reply', :id => '1'
649 :controller => 'issues', :action => 'reply', :id => '1'
647 )
650 )
648 end
651 end
649
652
650 def test_reply_to_issue
653 def test_reply_to_issue
651 @request.session[:user_id] = 2
654 @request.session[:user_id] = 2
652 get :reply, :id => 1
655 get :reply, :id => 1
653 assert_response :success
656 assert_response :success
654 assert_select_rjs :show, "update"
657 assert_select_rjs :show, "update"
655 end
658 end
656
659
657 def test_reply_to_note
660 def test_reply_to_note
658 @request.session[:user_id] = 2
661 @request.session[:user_id] = 2
659 get :reply, :id => 1, :journal_id => 2
662 get :reply, :id => 1, :journal_id => 2
660 assert_response :success
663 assert_response :success
661 assert_select_rjs :show, "update"
664 assert_select_rjs :show, "update"
662 end
665 end
663
666
664 def test_post_edit_without_custom_fields_param
667 def test_post_edit_without_custom_fields_param
665 @request.session[:user_id] = 2
668 @request.session[:user_id] = 2
666 ActionMailer::Base.deliveries.clear
669 ActionMailer::Base.deliveries.clear
667
670
668 issue = Issue.find(1)
671 issue = Issue.find(1)
669 assert_equal '125', issue.custom_value_for(2).value
672 assert_equal '125', issue.custom_value_for(2).value
670 old_subject = issue.subject
673 old_subject = issue.subject
671 new_subject = 'Subject modified by IssuesControllerTest#test_post_edit'
674 new_subject = 'Subject modified by IssuesControllerTest#test_post_edit'
672
675
673 assert_difference('Journal.count') do
676 assert_difference('Journal.count') do
674 assert_difference('JournalDetail.count', 2) do
677 assert_difference('JournalDetail.count', 2) do
675 post :edit, :id => 1, :issue => {:subject => new_subject,
678 post :edit, :id => 1, :issue => {:subject => new_subject,
676 :priority_id => '6',
679 :priority_id => '6',
677 :category_id => '1' # no change
680 :category_id => '1' # no change
678 }
681 }
679 end
682 end
680 end
683 end
681 assert_redirected_to :action => 'show', :id => '1'
684 assert_redirected_to :action => 'show', :id => '1'
682 issue.reload
685 issue.reload
683 assert_equal new_subject, issue.subject
686 assert_equal new_subject, issue.subject
684 # Make sure custom fields were not cleared
687 # Make sure custom fields were not cleared
685 assert_equal '125', issue.custom_value_for(2).value
688 assert_equal '125', issue.custom_value_for(2).value
686
689
687 mail = ActionMailer::Base.deliveries.last
690 mail = ActionMailer::Base.deliveries.last
688 assert_kind_of TMail::Mail, mail
691 assert_kind_of TMail::Mail, mail
689 assert mail.subject.starts_with?("[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}]")
692 assert mail.subject.starts_with?("[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}]")
690 assert mail.body.include?("Subject changed from #{old_subject} to #{new_subject}")
693 assert mail.body.include?("Subject changed from #{old_subject} to #{new_subject}")
691 end
694 end
692
695
693 def test_post_edit_with_custom_field_change
696 def test_post_edit_with_custom_field_change
694 @request.session[:user_id] = 2
697 @request.session[:user_id] = 2
695 issue = Issue.find(1)
698 issue = Issue.find(1)
696 assert_equal '125', issue.custom_value_for(2).value
699 assert_equal '125', issue.custom_value_for(2).value
697
700
698 assert_difference('Journal.count') do
701 assert_difference('Journal.count') do
699 assert_difference('JournalDetail.count', 3) do
702 assert_difference('JournalDetail.count', 3) do
700 post :edit, :id => 1, :issue => {:subject => 'Custom field change',
703 post :edit, :id => 1, :issue => {:subject => 'Custom field change',
701 :priority_id => '6',
704 :priority_id => '6',
702 :category_id => '1', # no change
705 :category_id => '1', # no change
703 :custom_field_values => { '2' => 'New custom value' }
706 :custom_field_values => { '2' => 'New custom value' }
704 }
707 }
705 end
708 end
706 end
709 end
707 assert_redirected_to :action => 'show', :id => '1'
710 assert_redirected_to :action => 'show', :id => '1'
708 issue.reload
711 issue.reload
709 assert_equal 'New custom value', issue.custom_value_for(2).value
712 assert_equal 'New custom value', issue.custom_value_for(2).value
710
713
711 mail = ActionMailer::Base.deliveries.last
714 mail = ActionMailer::Base.deliveries.last
712 assert_kind_of TMail::Mail, mail
715 assert_kind_of TMail::Mail, mail
713 assert mail.body.include?("Searchable field changed from 125 to New custom value")
716 assert mail.body.include?("Searchable field changed from 125 to New custom value")
714 end
717 end
715
718
716 def test_post_edit_with_status_and_assignee_change
719 def test_post_edit_with_status_and_assignee_change
717 issue = Issue.find(1)
720 issue = Issue.find(1)
718 assert_equal 1, issue.status_id
721 assert_equal 1, issue.status_id
719 @request.session[:user_id] = 2
722 @request.session[:user_id] = 2
720 assert_difference('TimeEntry.count', 0) do
723 assert_difference('TimeEntry.count', 0) do
721 post :edit,
724 post :edit,
722 :id => 1,
725 :id => 1,
723 :issue => { :status_id => 2, :assigned_to_id => 3 },
726 :issue => { :status_id => 2, :assigned_to_id => 3 },
724 :notes => 'Assigned to dlopper',
727 :notes => 'Assigned to dlopper',
725 :time_entry => { :hours => '', :comments => '', :activity_id => TimeEntryActivity.first }
728 :time_entry => { :hours => '', :comments => '', :activity_id => TimeEntryActivity.first }
726 end
729 end
727 assert_redirected_to :action => 'show', :id => '1'
730 assert_redirected_to :action => 'show', :id => '1'
728 issue.reload
731 issue.reload
729 assert_equal 2, issue.status_id
732 assert_equal 2, issue.status_id
730 j = issue.journals.find(:first, :order => 'id DESC')
733 j = issue.journals.find(:first, :order => 'id DESC')
731 assert_equal 'Assigned to dlopper', j.notes
734 assert_equal 'Assigned to dlopper', j.notes
732 assert_equal 2, j.details.size
735 assert_equal 2, j.details.size
733
736
734 mail = ActionMailer::Base.deliveries.last
737 mail = ActionMailer::Base.deliveries.last
735 assert mail.body.include?("Status changed from New to Assigned")
738 assert mail.body.include?("Status changed from New to Assigned")
736 # subject should contain the new status
739 # subject should contain the new status
737 assert mail.subject.include?("(#{ IssueStatus.find(2).name })")
740 assert mail.subject.include?("(#{ IssueStatus.find(2).name })")
738 end
741 end
739
742
740 def test_post_edit_with_note_only
743 def test_post_edit_with_note_only
741 notes = 'Note added by IssuesControllerTest#test_update_with_note_only'
744 notes = 'Note added by IssuesControllerTest#test_update_with_note_only'
742 # anonymous user
745 # anonymous user
743 post :edit,
746 post :edit,
744 :id => 1,
747 :id => 1,
745 :notes => notes
748 :notes => notes
746 assert_redirected_to :action => 'show', :id => '1'
749 assert_redirected_to :action => 'show', :id => '1'
747 j = Issue.find(1).journals.find(:first, :order => 'id DESC')
750 j = Issue.find(1).journals.find(:first, :order => 'id DESC')
748 assert_equal notes, j.notes
751 assert_equal notes, j.notes
749 assert_equal 0, j.details.size
752 assert_equal 0, j.details.size
750 assert_equal User.anonymous, j.user
753 assert_equal User.anonymous, j.user
751
754
752 mail = ActionMailer::Base.deliveries.last
755 mail = ActionMailer::Base.deliveries.last
753 assert mail.body.include?(notes)
756 assert mail.body.include?(notes)
754 end
757 end
755
758
756 def test_post_edit_with_note_and_spent_time
759 def test_post_edit_with_note_and_spent_time
757 @request.session[:user_id] = 2
760 @request.session[:user_id] = 2
758 spent_hours_before = Issue.find(1).spent_hours
761 spent_hours_before = Issue.find(1).spent_hours
759 assert_difference('TimeEntry.count') do
762 assert_difference('TimeEntry.count') do
760 post :edit,
763 post :edit,
761 :id => 1,
764 :id => 1,
762 :notes => '2.5 hours added',
765 :notes => '2.5 hours added',
763 :time_entry => { :hours => '2.5', :comments => '', :activity_id => TimeEntryActivity.first }
766 :time_entry => { :hours => '2.5', :comments => '', :activity_id => TimeEntryActivity.first }
764 end
767 end
765 assert_redirected_to :action => 'show', :id => '1'
768 assert_redirected_to :action => 'show', :id => '1'
766
769
767 issue = Issue.find(1)
770 issue = Issue.find(1)
768
771
769 j = issue.journals.find(:first, :order => 'id DESC')
772 j = issue.journals.find(:first, :order => 'id DESC')
770 assert_equal '2.5 hours added', j.notes
773 assert_equal '2.5 hours added', j.notes
771 assert_equal 0, j.details.size
774 assert_equal 0, j.details.size
772
775
773 t = issue.time_entries.find(:first, :order => 'id DESC')
776 t = issue.time_entries.find(:first, :order => 'id DESC')
774 assert_not_nil t
777 assert_not_nil t
775 assert_equal 2.5, t.hours
778 assert_equal 2.5, t.hours
776 assert_equal spent_hours_before + 2.5, issue.spent_hours
779 assert_equal spent_hours_before + 2.5, issue.spent_hours
777 end
780 end
778
781
779 def test_post_edit_with_attachment_only
782 def test_post_edit_with_attachment_only
780 set_tmp_attachments_directory
783 set_tmp_attachments_directory
781
784
782 # Delete all fixtured journals, a race condition can occur causing the wrong
785 # Delete all fixtured journals, a race condition can occur causing the wrong
783 # journal to get fetched in the next find.
786 # journal to get fetched in the next find.
784 Journal.delete_all
787 Journal.delete_all
785
788
786 # anonymous user
789 # anonymous user
787 post :edit,
790 post :edit,
788 :id => 1,
791 :id => 1,
789 :notes => '',
792 :notes => '',
790 :attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain')}}
793 :attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain')}}
791 assert_redirected_to :action => 'show', :id => '1'
794 assert_redirected_to :action => 'show', :id => '1'
792 j = Issue.find(1).journals.find(:first, :order => 'id DESC')
795 j = Issue.find(1).journals.find(:first, :order => 'id DESC')
793 assert j.notes.blank?
796 assert j.notes.blank?
794 assert_equal 1, j.details.size
797 assert_equal 1, j.details.size
795 assert_equal 'testfile.txt', j.details.first.value
798 assert_equal 'testfile.txt', j.details.first.value
796 assert_equal User.anonymous, j.user
799 assert_equal User.anonymous, j.user
797
800
798 mail = ActionMailer::Base.deliveries.last
801 mail = ActionMailer::Base.deliveries.last
799 assert mail.body.include?('testfile.txt')
802 assert mail.body.include?('testfile.txt')
800 end
803 end
801
804
802 def test_post_edit_with_no_change
805 def test_post_edit_with_no_change
803 issue = Issue.find(1)
806 issue = Issue.find(1)
804 issue.journals.clear
807 issue.journals.clear
805 ActionMailer::Base.deliveries.clear
808 ActionMailer::Base.deliveries.clear
806
809
807 post :edit,
810 post :edit,
808 :id => 1,
811 :id => 1,
809 :notes => ''
812 :notes => ''
810 assert_redirected_to :action => 'show', :id => '1'
813 assert_redirected_to :action => 'show', :id => '1'
811
814
812 issue.reload
815 issue.reload
813 assert issue.journals.empty?
816 assert issue.journals.empty?
814 # No email should be sent
817 # No email should be sent
815 assert ActionMailer::Base.deliveries.empty?
818 assert ActionMailer::Base.deliveries.empty?
816 end
819 end
817
820
818 def test_post_edit_should_send_a_notification
821 def test_post_edit_should_send_a_notification
819 @request.session[:user_id] = 2
822 @request.session[:user_id] = 2
820 ActionMailer::Base.deliveries.clear
823 ActionMailer::Base.deliveries.clear
821 issue = Issue.find(1)
824 issue = Issue.find(1)
822 old_subject = issue.subject
825 old_subject = issue.subject
823 new_subject = 'Subject modified by IssuesControllerTest#test_post_edit'
826 new_subject = 'Subject modified by IssuesControllerTest#test_post_edit'
824
827
825 post :edit, :id => 1, :issue => {:subject => new_subject,
828 post :edit, :id => 1, :issue => {:subject => new_subject,
826 :priority_id => '6',
829 :priority_id => '6',
827 :category_id => '1' # no change
830 :category_id => '1' # no change
828 }
831 }
829 assert_equal 1, ActionMailer::Base.deliveries.size
832 assert_equal 1, ActionMailer::Base.deliveries.size
830 end
833 end
831
834
832 def test_post_edit_with_invalid_spent_time
835 def test_post_edit_with_invalid_spent_time
833 @request.session[:user_id] = 2
836 @request.session[:user_id] = 2
834 notes = 'Note added by IssuesControllerTest#test_post_edit_with_invalid_spent_time'
837 notes = 'Note added by IssuesControllerTest#test_post_edit_with_invalid_spent_time'
835
838
836 assert_no_difference('Journal.count') do
839 assert_no_difference('Journal.count') do
837 post :edit,
840 post :edit,
838 :id => 1,
841 :id => 1,
839 :notes => notes,
842 :notes => notes,
840 :time_entry => {"comments"=>"", "activity_id"=>"", "hours"=>"2z"}
843 :time_entry => {"comments"=>"", "activity_id"=>"", "hours"=>"2z"}
841 end
844 end
842 assert_response :success
845 assert_response :success
843 assert_template 'edit'
846 assert_template 'edit'
844
847
845 assert_tag :textarea, :attributes => { :name => 'notes' },
848 assert_tag :textarea, :attributes => { :name => 'notes' },
846 :content => notes
849 :content => notes
847 assert_tag :input, :attributes => { :name => 'time_entry[hours]', :value => "2z" }
850 assert_tag :input, :attributes => { :name => 'time_entry[hours]', :value => "2z" }
848 end
851 end
849
852
850 def test_get_bulk_edit
853 def test_get_bulk_edit
851 @request.session[:user_id] = 2
854 @request.session[:user_id] = 2
852 get :bulk_edit, :ids => [1, 2]
855 get :bulk_edit, :ids => [1, 2]
853 assert_response :success
856 assert_response :success
854 assert_template 'bulk_edit'
857 assert_template 'bulk_edit'
855 end
858 end
856
859
857 def test_bulk_edit
860 def test_bulk_edit
858 @request.session[:user_id] = 2
861 @request.session[:user_id] = 2
859 # update issues priority
862 # update issues priority
860 post :bulk_edit, :ids => [1, 2], :priority_id => 7,
863 post :bulk_edit, :ids => [1, 2], :priority_id => 7,
861 :assigned_to_id => '',
864 :assigned_to_id => '',
862 :custom_field_values => {'2' => ''},
865 :custom_field_values => {'2' => ''},
863 :notes => 'Bulk editing'
866 :notes => 'Bulk editing'
864 assert_response 302
867 assert_response 302
865 # check that the issues were updated
868 # check that the issues were updated
866 assert_equal [7, 7], Issue.find_all_by_id([1, 2]).collect {|i| i.priority.id}
869 assert_equal [7, 7], Issue.find_all_by_id([1, 2]).collect {|i| i.priority.id}
867
870
868 issue = Issue.find(1)
871 issue = Issue.find(1)
869 journal = issue.journals.find(:first, :order => 'created_on DESC')
872 journal = issue.journals.find(:first, :order => 'created_on DESC')
870 assert_equal '125', issue.custom_value_for(2).value
873 assert_equal '125', issue.custom_value_for(2).value
871 assert_equal 'Bulk editing', journal.notes
874 assert_equal 'Bulk editing', journal.notes
872 assert_equal 1, journal.details.size
875 assert_equal 1, journal.details.size
873 end
876 end
874
877
875 def test_bullk_edit_should_send_a_notification
878 def test_bullk_edit_should_send_a_notification
876 @request.session[:user_id] = 2
879 @request.session[:user_id] = 2
877 ActionMailer::Base.deliveries.clear
880 ActionMailer::Base.deliveries.clear
878 post(:bulk_edit,
881 post(:bulk_edit,
879 {
882 {
880 :ids => [1, 2],
883 :ids => [1, 2],
881 :priority_id => 7,
884 :priority_id => 7,
882 :assigned_to_id => '',
885 :assigned_to_id => '',
883 :custom_field_values => {'2' => ''},
886 :custom_field_values => {'2' => ''},
884 :notes => 'Bulk editing'
887 :notes => 'Bulk editing'
885 })
888 })
886
889
887 assert_response 302
890 assert_response 302
888 assert_equal 2, ActionMailer::Base.deliveries.size
891 assert_equal 2, ActionMailer::Base.deliveries.size
889 end
892 end
890
893
891 def test_bulk_edit_status
894 def test_bulk_edit_status
892 @request.session[:user_id] = 2
895 @request.session[:user_id] = 2
893 # update issues priority
896 # update issues priority
894 post :bulk_edit, :ids => [1, 2], :priority_id => '',
897 post :bulk_edit, :ids => [1, 2], :priority_id => '',
895 :assigned_to_id => '',
898 :assigned_to_id => '',
896 :status_id => '5',
899 :status_id => '5',
897 :notes => 'Bulk editing status'
900 :notes => 'Bulk editing status'
898 assert_response 302
901 assert_response 302
899 issue = Issue.find(1)
902 issue = Issue.find(1)
900 assert issue.closed?
903 assert issue.closed?
901 end
904 end
902
905
903 def test_bulk_edit_custom_field
906 def test_bulk_edit_custom_field
904 @request.session[:user_id] = 2
907 @request.session[:user_id] = 2
905 # update issues priority
908 # update issues priority
906 post :bulk_edit, :ids => [1, 2], :priority_id => '',
909 post :bulk_edit, :ids => [1, 2], :priority_id => '',
907 :assigned_to_id => '',
910 :assigned_to_id => '',
908 :custom_field_values => {'2' => '777'},
911 :custom_field_values => {'2' => '777'},
909 :notes => 'Bulk editing custom field'
912 :notes => 'Bulk editing custom field'
910 assert_response 302
913 assert_response 302
911
914
912 issue = Issue.find(1)
915 issue = Issue.find(1)
913 journal = issue.journals.find(:first, :order => 'created_on DESC')
916 journal = issue.journals.find(:first, :order => 'created_on DESC')
914 assert_equal '777', issue.custom_value_for(2).value
917 assert_equal '777', issue.custom_value_for(2).value
915 assert_equal 1, journal.details.size
918 assert_equal 1, journal.details.size
916 assert_equal '125', journal.details.first.old_value
919 assert_equal '125', journal.details.first.old_value
917 assert_equal '777', journal.details.first.value
920 assert_equal '777', journal.details.first.value
918 end
921 end
919
922
920 def test_bulk_unassign
923 def test_bulk_unassign
921 assert_not_nil Issue.find(2).assigned_to
924 assert_not_nil Issue.find(2).assigned_to
922 @request.session[:user_id] = 2
925 @request.session[:user_id] = 2
923 # unassign issues
926 # unassign issues
924 post :bulk_edit, :ids => [1, 2], :notes => 'Bulk unassigning', :assigned_to_id => 'none'
927 post :bulk_edit, :ids => [1, 2], :notes => 'Bulk unassigning', :assigned_to_id => 'none'
925 assert_response 302
928 assert_response 302
926 # check that the issues were updated
929 # check that the issues were updated
927 assert_nil Issue.find(2).assigned_to
930 assert_nil Issue.find(2).assigned_to
928 end
931 end
929
932
930 def test_move_routing
933 def test_move_routing
931 assert_routing(
934 assert_routing(
932 {:method => :get, :path => '/issues/1/move'},
935 {:method => :get, :path => '/issues/1/move'},
933 :controller => 'issues', :action => 'move', :id => '1'
936 :controller => 'issues', :action => 'move', :id => '1'
934 )
937 )
935 assert_recognizes(
938 assert_recognizes(
936 {:controller => 'issues', :action => 'move', :id => '1'},
939 {:controller => 'issues', :action => 'move', :id => '1'},
937 {:method => :post, :path => '/issues/1/move'}
940 {:method => :post, :path => '/issues/1/move'}
938 )
941 )
939 end
942 end
940
943
941 def test_move_one_issue_to_another_project
944 def test_move_one_issue_to_another_project
942 @request.session[:user_id] = 2
945 @request.session[:user_id] = 2
943 post :move, :id => 1, :new_project_id => 2
946 post :move, :id => 1, :new_project_id => 2
944 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
947 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
945 assert_equal 2, Issue.find(1).project_id
948 assert_equal 2, Issue.find(1).project_id
946 end
949 end
947
950
948 def test_bulk_move_to_another_project
951 def test_bulk_move_to_another_project
949 @request.session[:user_id] = 2
952 @request.session[:user_id] = 2
950 post :move, :ids => [1, 2], :new_project_id => 2
953 post :move, :ids => [1, 2], :new_project_id => 2
951 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
954 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
952 # Issues moved to project 2
955 # Issues moved to project 2
953 assert_equal 2, Issue.find(1).project_id
956 assert_equal 2, Issue.find(1).project_id
954 assert_equal 2, Issue.find(2).project_id
957 assert_equal 2, Issue.find(2).project_id
955 # No tracker change
958 # No tracker change
956 assert_equal 1, Issue.find(1).tracker_id
959 assert_equal 1, Issue.find(1).tracker_id
957 assert_equal 2, Issue.find(2).tracker_id
960 assert_equal 2, Issue.find(2).tracker_id
958 end
961 end
959
962
960 def test_bulk_move_to_another_tracker
963 def test_bulk_move_to_another_tracker
961 @request.session[:user_id] = 2
964 @request.session[:user_id] = 2
962 post :move, :ids => [1, 2], :new_tracker_id => 2
965 post :move, :ids => [1, 2], :new_tracker_id => 2
963 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
966 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
964 assert_equal 2, Issue.find(1).tracker_id
967 assert_equal 2, Issue.find(1).tracker_id
965 assert_equal 2, Issue.find(2).tracker_id
968 assert_equal 2, Issue.find(2).tracker_id
966 end
969 end
967
970
968 def test_bulk_copy_to_another_project
971 def test_bulk_copy_to_another_project
969 @request.session[:user_id] = 2
972 @request.session[:user_id] = 2
970 assert_difference 'Issue.count', 2 do
973 assert_difference 'Issue.count', 2 do
971 assert_no_difference 'Project.find(1).issues.count' do
974 assert_no_difference 'Project.find(1).issues.count' do
972 post :move, :ids => [1, 2], :new_project_id => 2, :copy_options => {:copy => '1'}
975 post :move, :ids => [1, 2], :new_project_id => 2, :copy_options => {:copy => '1'}
973 end
976 end
974 end
977 end
975 assert_redirected_to 'projects/ecookbook/issues'
978 assert_redirected_to 'projects/ecookbook/issues'
976 end
979 end
977
980
978 def test_context_menu_one_issue
981 def test_context_menu_one_issue
979 @request.session[:user_id] = 2
982 @request.session[:user_id] = 2
980 get :context_menu, :ids => [1]
983 get :context_menu, :ids => [1]
981 assert_response :success
984 assert_response :success
982 assert_template 'context_menu'
985 assert_template 'context_menu'
983 assert_tag :tag => 'a', :content => 'Edit',
986 assert_tag :tag => 'a', :content => 'Edit',
984 :attributes => { :href => '/issues/1/edit',
987 :attributes => { :href => '/issues/1/edit',
985 :class => 'icon-edit' }
988 :class => 'icon-edit' }
986 assert_tag :tag => 'a', :content => 'Closed',
989 assert_tag :tag => 'a', :content => 'Closed',
987 :attributes => { :href => '/issues/1/edit?issue%5Bstatus_id%5D=5',
990 :attributes => { :href => '/issues/1/edit?issue%5Bstatus_id%5D=5',
988 :class => '' }
991 :class => '' }
989 assert_tag :tag => 'a', :content => 'Immediate',
992 assert_tag :tag => 'a', :content => 'Immediate',
990 :attributes => { :href => '/issues/bulk_edit?ids%5B%5D=1&amp;priority_id=8',
993 :attributes => { :href => '/issues/bulk_edit?ids%5B%5D=1&amp;priority_id=8',
991 :class => '' }
994 :class => '' }
992 assert_tag :tag => 'a', :content => 'Dave Lopper',
995 assert_tag :tag => 'a', :content => 'Dave Lopper',
993 :attributes => { :href => '/issues/bulk_edit?assigned_to_id=3&amp;ids%5B%5D=1',
996 :attributes => { :href => '/issues/bulk_edit?assigned_to_id=3&amp;ids%5B%5D=1',
994 :class => '' }
997 :class => '' }
995 assert_tag :tag => 'a', :content => 'Copy',
998 assert_tag :tag => 'a', :content => 'Copy',
996 :attributes => { :href => '/projects/ecookbook/issues/1/copy',
999 :attributes => { :href => '/projects/ecookbook/issues/1/copy',
997 :class => 'icon-copy' }
1000 :class => 'icon-copy' }
998 assert_tag :tag => 'a', :content => 'Move',
1001 assert_tag :tag => 'a', :content => 'Move',
999 :attributes => { :href => '/issues/move?ids%5B%5D=1',
1002 :attributes => { :href => '/issues/move?ids%5B%5D=1',
1000 :class => 'icon-move' }
1003 :class => 'icon-move' }
1001 assert_tag :tag => 'a', :content => 'Delete',
1004 assert_tag :tag => 'a', :content => 'Delete',
1002 :attributes => { :href => '/issues/destroy?ids%5B%5D=1',
1005 :attributes => { :href => '/issues/destroy?ids%5B%5D=1',
1003 :class => 'icon-del' }
1006 :class => 'icon-del' }
1004 end
1007 end
1005
1008
1006 def test_context_menu_one_issue_by_anonymous
1009 def test_context_menu_one_issue_by_anonymous
1007 get :context_menu, :ids => [1]
1010 get :context_menu, :ids => [1]
1008 assert_response :success
1011 assert_response :success
1009 assert_template 'context_menu'
1012 assert_template 'context_menu'
1010 assert_tag :tag => 'a', :content => 'Delete',
1013 assert_tag :tag => 'a', :content => 'Delete',
1011 :attributes => { :href => '#',
1014 :attributes => { :href => '#',
1012 :class => 'icon-del disabled' }
1015 :class => 'icon-del disabled' }
1013 end
1016 end
1014
1017
1015 def test_context_menu_multiple_issues_of_same_project
1018 def test_context_menu_multiple_issues_of_same_project
1016 @request.session[:user_id] = 2
1019 @request.session[:user_id] = 2
1017 get :context_menu, :ids => [1, 2]
1020 get :context_menu, :ids => [1, 2]
1018 assert_response :success
1021 assert_response :success
1019 assert_template 'context_menu'
1022 assert_template 'context_menu'
1020 assert_tag :tag => 'a', :content => 'Edit',
1023 assert_tag :tag => 'a', :content => 'Edit',
1021 :attributes => { :href => '/issues/bulk_edit?ids%5B%5D=1&amp;ids%5B%5D=2',
1024 :attributes => { :href => '/issues/bulk_edit?ids%5B%5D=1&amp;ids%5B%5D=2',
1022 :class => 'icon-edit' }
1025 :class => 'icon-edit' }
1023 assert_tag :tag => 'a', :content => 'Immediate',
1026 assert_tag :tag => 'a', :content => 'Immediate',
1024 :attributes => { :href => '/issues/bulk_edit?ids%5B%5D=1&amp;ids%5B%5D=2&amp;priority_id=8',
1027 :attributes => { :href => '/issues/bulk_edit?ids%5B%5D=1&amp;ids%5B%5D=2&amp;priority_id=8',
1025 :class => '' }
1028 :class => '' }
1026 assert_tag :tag => 'a', :content => 'Dave Lopper',
1029 assert_tag :tag => 'a', :content => 'Dave Lopper',
1027 :attributes => { :href => '/issues/bulk_edit?assigned_to_id=3&amp;ids%5B%5D=1&amp;ids%5B%5D=2',
1030 :attributes => { :href => '/issues/bulk_edit?assigned_to_id=3&amp;ids%5B%5D=1&amp;ids%5B%5D=2',
1028 :class => '' }
1031 :class => '' }
1029 assert_tag :tag => 'a', :content => 'Move',
1032 assert_tag :tag => 'a', :content => 'Move',
1030 :attributes => { :href => '/issues/move?ids%5B%5D=1&amp;ids%5B%5D=2',
1033 :attributes => { :href => '/issues/move?ids%5B%5D=1&amp;ids%5B%5D=2',
1031 :class => 'icon-move' }
1034 :class => 'icon-move' }
1032 assert_tag :tag => 'a', :content => 'Delete',
1035 assert_tag :tag => 'a', :content => 'Delete',
1033 :attributes => { :href => '/issues/destroy?ids%5B%5D=1&amp;ids%5B%5D=2',
1036 :attributes => { :href => '/issues/destroy?ids%5B%5D=1&amp;ids%5B%5D=2',
1034 :class => 'icon-del' }
1037 :class => 'icon-del' }
1035 end
1038 end
1036
1039
1037 def test_context_menu_multiple_issues_of_different_project
1040 def test_context_menu_multiple_issues_of_different_project
1038 @request.session[:user_id] = 2
1041 @request.session[:user_id] = 2
1039 get :context_menu, :ids => [1, 2, 4]
1042 get :context_menu, :ids => [1, 2, 4]
1040 assert_response :success
1043 assert_response :success
1041 assert_template 'context_menu'
1044 assert_template 'context_menu'
1042 assert_tag :tag => 'a', :content => 'Delete',
1045 assert_tag :tag => 'a', :content => 'Delete',
1043 :attributes => { :href => '#',
1046 :attributes => { :href => '#',
1044 :class => 'icon-del disabled' }
1047 :class => 'icon-del disabled' }
1045 end
1048 end
1046
1049
1047 def test_destroy_routing
1050 def test_destroy_routing
1048 assert_recognizes( #TODO: use DELETE on issue URI (need to change forms)
1051 assert_recognizes( #TODO: use DELETE on issue URI (need to change forms)
1049 {:controller => 'issues', :action => 'destroy', :id => '1'},
1052 {:controller => 'issues', :action => 'destroy', :id => '1'},
1050 {:method => :post, :path => '/issues/1/destroy'}
1053 {:method => :post, :path => '/issues/1/destroy'}
1051 )
1054 )
1052 end
1055 end
1053
1056
1054 def test_destroy_issue_with_no_time_entries
1057 def test_destroy_issue_with_no_time_entries
1055 assert_nil TimeEntry.find_by_issue_id(2)
1058 assert_nil TimeEntry.find_by_issue_id(2)
1056 @request.session[:user_id] = 2
1059 @request.session[:user_id] = 2
1057 post :destroy, :id => 2
1060 post :destroy, :id => 2
1058 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
1061 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
1059 assert_nil Issue.find_by_id(2)
1062 assert_nil Issue.find_by_id(2)
1060 end
1063 end
1061
1064
1062 def test_destroy_issues_with_time_entries
1065 def test_destroy_issues_with_time_entries
1063 @request.session[:user_id] = 2
1066 @request.session[:user_id] = 2
1064 post :destroy, :ids => [1, 3]
1067 post :destroy, :ids => [1, 3]
1065 assert_response :success
1068 assert_response :success
1066 assert_template 'destroy'
1069 assert_template 'destroy'
1067 assert_not_nil assigns(:hours)
1070 assert_not_nil assigns(:hours)
1068 assert Issue.find_by_id(1) && Issue.find_by_id(3)
1071 assert Issue.find_by_id(1) && Issue.find_by_id(3)
1069 end
1072 end
1070
1073
1071 def test_destroy_issues_and_destroy_time_entries
1074 def test_destroy_issues_and_destroy_time_entries
1072 @request.session[:user_id] = 2
1075 @request.session[:user_id] = 2
1073 post :destroy, :ids => [1, 3], :todo => 'destroy'
1076 post :destroy, :ids => [1, 3], :todo => 'destroy'
1074 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
1077 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
1075 assert !(Issue.find_by_id(1) || Issue.find_by_id(3))
1078 assert !(Issue.find_by_id(1) || Issue.find_by_id(3))
1076 assert_nil TimeEntry.find_by_id([1, 2])
1079 assert_nil TimeEntry.find_by_id([1, 2])
1077 end
1080 end
1078
1081
1079 def test_destroy_issues_and_assign_time_entries_to_project
1082 def test_destroy_issues_and_assign_time_entries_to_project
1080 @request.session[:user_id] = 2
1083 @request.session[:user_id] = 2
1081 post :destroy, :ids => [1, 3], :todo => 'nullify'
1084 post :destroy, :ids => [1, 3], :todo => 'nullify'
1082 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
1085 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
1083 assert !(Issue.find_by_id(1) || Issue.find_by_id(3))
1086 assert !(Issue.find_by_id(1) || Issue.find_by_id(3))
1084 assert_nil TimeEntry.find(1).issue_id
1087 assert_nil TimeEntry.find(1).issue_id
1085 assert_nil TimeEntry.find(2).issue_id
1088 assert_nil TimeEntry.find(2).issue_id
1086 end
1089 end
1087
1090
1088 def test_destroy_issues_and_reassign_time_entries_to_another_issue
1091 def test_destroy_issues_and_reassign_time_entries_to_another_issue
1089 @request.session[:user_id] = 2
1092 @request.session[:user_id] = 2
1090 post :destroy, :ids => [1, 3], :todo => 'reassign', :reassign_to_id => 2
1093 post :destroy, :ids => [1, 3], :todo => 'reassign', :reassign_to_id => 2
1091 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
1094 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
1092 assert !(Issue.find_by_id(1) || Issue.find_by_id(3))
1095 assert !(Issue.find_by_id(1) || Issue.find_by_id(3))
1093 assert_equal 2, TimeEntry.find(1).issue_id
1096 assert_equal 2, TimeEntry.find(1).issue_id
1094 assert_equal 2, TimeEntry.find(2).issue_id
1097 assert_equal 2, TimeEntry.find(2).issue_id
1095 end
1098 end
1096
1099
1097 def test_default_search_scope
1100 def test_default_search_scope
1098 get :index
1101 get :index
1099 assert_tag :div, :attributes => {:id => 'quick-search'},
1102 assert_tag :div, :attributes => {:id => 'quick-search'},
1100 :child => {:tag => 'form',
1103 :child => {:tag => 'form',
1101 :child => {:tag => 'input', :attributes => {:name => 'issues', :type => 'hidden', :value => '1'}}}
1104 :child => {:tag => 'input', :attributes => {:name => 'issues', :type => 'hidden', :value => '1'}}}
1102 end
1105 end
1103 end
1106 end
General Comments 0
You need to be logged in to leave comments. Login now