Project

General

Profile

Patch #7456 » gantt_filters.patch

v2 - Etienne Massip, 2011-02-04 15:05

View differences:

app/models/issue.rb (working copy)
17 17

  
18 18
class Issue < ActiveRecord::Base
19 19
  include Redmine::SafeAttributes
20
  
20

  
21 21
  belongs_to :project
22 22
  belongs_to :tracker
23 23
  belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
......
68 68
  named_scope :with_limit, lambda { |limit| { :limit => limit} }
69 69
  named_scope :on_active_project, :include => [:status, :project, :tracker],
70 70
                                  :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
71
  named_scope :for_gantt, lambda {
72
    {
73
      :include => [:tracker, :status, :assigned_to, :priority, :project, :fixed_version]
74
    }
75
  }
76 71

  
77 72
  named_scope :without_version, lambda {
78 73
    {
......
80 75
    }
81 76
  }
82 77

  
83
  named_scope :with_query, lambda {|query|
84
    {
85
      :conditions => Query.merge_conditions(query.statement)
86
    }
87
  }
88 78

  
89 79
  before_create :default_assign
90 80
  before_save :close_duplicates, :update_done_ratio_from_issue_status
app/models/query.rb (working copy)
388 388
  def group_by_statement
389 389
    group_by_column.try(:groupable)
390 390
  end
391
  
392
  def project_statement
391

  
392
  def project_statement(db_table)
393 393
    project_clauses = []
394
    if project && !@project.descendants.active.empty?
395
      ids = [project.id]
396
      if has_filter?("subproject_id")
397
        case operator_for("subproject_id")
398
        when '='
399
          # include the selected subprojects
400
          ids += values_for("subproject_id").each(&:to_i)
401
        when '!*'
402
          # main project only
394

  
395
    if valid?
396
      if project
397

  
398
        if !project.descendants.active.empty?
399

  
400
          if has_filter?('subproject_id')
401
            case operator_for('subproject_id')
402
            when '='
403
              # include the selected subprojects
404
              ids = values_for('subproject_id').each(&:to_i)
405
            when '!*'
406
              # main project only
407
              ids = [project.id]
408
            else
409
              # all subprojects
410
              ids = project.descendants.collect(&:id)
411
            end
412
          else
413
            ids = [project.id]
414
            ids << project.descendants.collect(&:id) if Setting.display_subprojects_issues?
415
          end
416
          project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
403 417
        else
404
          # all subprojects
405
          ids += project.descendants.collect(&:id)
418
          project_clauses << "#{Project.table_name}.id = %d" % project.id
406 419
        end
407
      elsif Setting.display_subprojects_issues?
408
        ids += project.descendants.collect(&:id)
420

  
421
      elsif has_filter?('project_id')
422
        # project filter
423
        db_field = db_table == Project.table_name ? 'id' : 'project_id'
424
        project_clauses << '(' + sql_for_field('project_id', operator_for('project_id'), values_for('project_id').each(&:to_i), db_table, db_field) + ')'
409 425
      end
410
      project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
411
    elsif project
412
      project_clauses << "#{Project.table_name}.id = %d" % project.id
426

  
413 427
    end
428

  
414 429
    project_clauses <<  Project.allowed_to_condition(User.current, :view_issues)
415 430
    project_clauses.join(' AND ')
416 431
  end
......
419 434
    # filters clauses
420 435
    filters_clauses = []
421 436
    filters.each_key do |field|
422
      next if field == "subproject_id"
437
      next if ['subproject_id', 'project_id'].include?(field)
423 438
      v = values_for(field).clone
424 439
      next unless v and !v.empty?
425 440
      operator = operator_for(field)
......
428 443
      if %w(assigned_to_id author_id watcher_id).include?(field)
429 444
        v.push(User.current.logged? ? User.current.id.to_s : "0") if v.delete("me")
430 445
      end
431
      
446

  
432 447
      sql = ''
433 448
      if field =~ /^cf_(\d+)$/
434 449
        # custom field
......
481 496
          end
482 497
          user_ids.flatten.uniq.compact
483 498
        }.sort.collect(&:to_s)
484
        
499

  
485 500
        sql << '(' + sql_for_field("assigned_to_id", operator, members_of_roles, Issue.table_name, "assigned_to_id", false) + ')'
486 501
      else
487 502
        # regular field
......
493 508
      
494 509
    end if filters and valid?
495 510
    
496
    (filters_clauses << project_statement).join(' AND ')
511
    (filters_clauses << project_statement(Issue.table_name)).join(' AND ')
497 512
  end
498 513
  
499 514
  # Returns the issue count
......
522 537
  rescue ::ActiveRecord::StatementInvalid => e
523 538
    raise StatementInvalid.new(e.message)
524 539
  end
525
  
540

  
526 541
  # Returns the issues
527 542
  # Valid options are :order, :offset, :limit, :include, :conditions
528 543
  def issues(options={})
......
554 569
  # Valid options are :conditions
555 570
  def versions(options={})
556 571
    Version.find :all, :include => :project,
557
                       :conditions => Query.merge_conditions(project_statement, options[:conditions])
572
                       :conditions => Query.merge_conditions(project_statement(Version.table_name), options[:conditions])
558 573
  rescue ::ActiveRecord::StatementInvalid => e
559 574
    raise StatementInvalid.new(e.message)
560 575
  end
561
  
576

  
562 577
  private
563 578
  
564 579
  # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
lib/redmine/helpers/gantt.rb (working copy)
38 38
      attr_accessor :query
39 39
      attr_accessor :project
40 40
      attr_accessor :view
41
      
41

  
42 42
      def initialize(options={})
43 43
        options = options.dup
44
        
44

  
45 45
        if options[:year] && options[:year].to_i >0
46 46
          @year_from = options[:year].to_i
47 47
          if options[:month] && options[:month].to_i >=1 && options[:month].to_i <= 12
......
53 53
          @month_from ||= Date.today.month
54 54
          @year_from ||= Date.today.year
55 55
        end
56
        
56

  
57 57
        zoom = (options[:zoom] || User.current.pref[:gantt_zoom]).to_i
58 58
        @zoom = (zoom > 0 && zoom < 5) ? zoom : 2    
59 59
        months = (options[:months] || User.current.pref[:gantt_months]).to_i
60 60
        @months = (months > 0 && months < 25) ? months : 6
61
        
61

  
62 62
        # Save gantt parameters as user preference (zoom and months count)
63 63
        if (User.current.logged? && (@zoom != User.current.pref[:gantt_zoom] || @months != User.current.pref[:gantt_months]))
64 64
          User.current.pref[:gantt_zoom], User.current.pref[:gantt_months] = @zoom, @months
65 65
          User.current.preference.save
66 66
        end
67
        
67

  
68 68
        @date_from = Date.civil(@year_from, @month_from, 1)
69 69
        @date_to = (@date_from >> @months) - 1
70 70
        
......
98 98
        common_params.merge({:year => (date_from >> months).year, :month => (date_from >> months).month, :zoom => zoom, :months => months })
99 99
      end
100 100

  
101
            ### Extracted from the HTML view/helpers
101
      ### Extracted from the HTML view/helpers
102 102
      # Returns the number of rows that will be rendered on the Gantt chart
103 103
      def number_of_rows
104
        return @number_of_rows if @number_of_rows
105
        
106
        rows = if @project
107
          number_of_rows_on_project(@project)
108
        else
109
          Project.roots.visible.has_module('issue_tracking').inject(0) do |total, project|
110
            total += number_of_rows_on_project(project)
111
          end
112
        end
113
        
114
        rows > @max_rows ? @max_rows : rows
104
        @number_of_rows
115 105
      end
116 106

  
117
      # Returns the number of rows that will be used to list a project on
118
      # the Gantt chart.  This will recurse for each subproject.
119
      def number_of_rows_on_project(project)
120
        # Remove the project requirement for Versions because it will
121
        # restrict issues to only be on the current project.  This
122
        # ends up missing issues which are assigned to shared versions.
123
        @query.project = nil if @query.project
124

  
125
        # One Root project
126
        count = 1
127
        # Issues without a Version
128
        count += project.issues.for_gantt.without_version.with_query(@query).count
129

  
130
        # Versions
131
        count += project.versions.count
132

  
133
        # Issues on the Versions
134
        project.versions.each do |version|
135
          count += version.fixed_issues.for_gantt.with_query(@query).count
136
        end
137

  
138
        # Subprojects
139
        project.children.visible.has_module('issue_tracking').each do |subproject|
140
          count += number_of_rows_on_project(subproject)
141
        end
142

  
143
        count
144
      end
145

  
146 107
      # Renders the subjects of the Gantt chart, the left side.
147 108
      def subjects(options={})
148 109
        render(options.merge(:only => :subjects)) unless @subjects_rendered
......
154 115
        render(options.merge(:only => :lines)) unless @lines_rendered
155 116
        @lines
156 117
      end
157
      
118

  
158 119
      def render(options={})
159 120
        options = {:indent => 4, :render => :subject, :format => :html}.merge(options)
160
        
121

  
161 122
        @subjects = '' unless options[:only] == :lines
162 123
        @lines = '' unless options[:only] == :subjects
163 124
        @number_of_rows = 0
164
        
165
        if @project
166
          render_project(@project, options)
167
        else
168
          Project.roots.visible.has_module('issue_tracking').each do |project|
169
            render_project(project, options)
125

  
126
        options[:render_issues_without_version_first] = true unless options.key? :render_issues_without_version_first
127

  
128
        options[:top] = 0 unless options.key? :top
129
        options[:indent_increment] = 20 unless options.key? :indent_increment
130
        options[:top_increment] = 20 unless options.key? :top_increment
131

  
132
        options[:gantt_indent] = options[:indent]
133

  
134
        # We sort by project.lft so that projects are listed before their children
135
        top_projects = Project.find( :all,
136
          :conditions => @query.project_statement(Project.table_name),
137
          :order => ["#{Project.table_name}.lft ASC"])
138

  
139
        # Purge descendants to be sure to have only top projects left
140
        prev_top = nil
141
        top_projects.delete_if do |top|
142
          remove_descendant = prev_top && top.is_descendant_of?(prev_top)
143
          prev_top = top
144
          remove_descendant
145
        end
146

  
147
        # Get issues with theirs versions and subprojects as filtered by query criterias
148
        issues = Issue.find(
149
          :all,
150
          :include => [:tracker, :status, :assigned_to, :priority, :project, :fixed_version],
151
          :conditions => @query.statement,
152
          :joins => [
153
            "LEFT OUTER JOIN #{Project.table_name} AS top_project ON top_project.id IN (#{top_projects.collect(&:id).join(', ')}) AND top_project.lft <= #{Project.table_name}.lft AND top_project.rgt >= #{Project.table_name}.rgt " + # Top project
154
            "LEFT OUTER JOIN #{Project.table_name} AS version_project ON version_project.id = #{Version.table_name}.project_id " + # Project of the fixed version
155
            "LEFT OUTER JOIN #{Project.table_name} AS version_top_project ON " +
156
                    "(version_top_project.id = #{Project.table_name}.id AND #{Version.table_name}.sharing = 'none')" +
157
                " OR (version_top_project.id = top_project.id AND #{Version.table_name}.sharing IN ('hierarchy', 'system', 'tree'))" +
158
                " OR (version_top_project.id = CASE WHEN top_project.lft < version_project.lft THEN version_project.id ELSE top_project.id END AND #{Version.table_name}.sharing = 'descendants')"], 
159
          :order => [
160
            "top_project.lft ASC, top_project.name ASC, " + # Top project
161
            "COALESCE(version_top_project.lft, #{Project.table_name}.lft) ASC, " + # Version top project or project if none
162
            "CASE WHEN #{Issue.table_name}.fixed_version_id IS NULL THEN #{ options[:render_issues_without_version_first] ? '0 ELSE 1' : '1 ELSE 0' } END ASC, " + # Issues without version first (or last)
163
            "CASE WHEN #{Version.table_name}.effective_date IS NULL THEN 0 ELSE 1 END DESC, " + # Version with effective_date first
164
            "#{Version.table_name}.effective_date ASC, #{Version.table_name}.name ASC, " +
165
            "#{Project.table_name}.lft ASC"] )
166

  
167
        # TODO : at this point we could have been returned 3 extra Project objects useful for building project path : top_project, version_project and top_version_project
168
        # There is no way ActiveRecord can map sql query results to more than one model (hibernate does), so we have to compute them again in update_and_render_project_path(), adding a dummy time & cpu overhead to the whole rendering process
169
        # Idea : use an IssueForGantt class with association to issue, top_project, version_project and top_version_project ?
170

  
171
        prev_issue = nil
172
        path_issues = []
173

  
174
        # TODO : use a Hash rather than an Array when switching to Ruby 1.9, as Hash is ordered in 1.9
175
        issue_path = []
176

  
177
        # Render Gantt by looping on issues
178
        issues.each do |i|
179

  
180
          # Stack issues within the same project path to allow them to be sorted before rendering
181
          if prev_issue && (
182
                prev_issue.project != i.project ||
183
                prev_issue.fixed_version != i.fixed_version)
184

  
185
            render_path_issues(path_issues, options) unless path_issues.empty?
170 186
            break if abort?
187

  
188
            path_issues.clear
171 189
          end
190

  
191
          issue_path = update_and_render_project_path(top_projects, i, prev_issue, issue_path, options)
192
          break if abort?
193

  
194
          path_issues << i
195

  
196
          prev_issue = i
172 197
        end
173
        
198

  
199
        render_path_issues(path_issues, options) unless abort?
200

  
174 201
        @subjects_rendered = true unless options[:only] == :lines
175 202
        @lines_rendered = true unless options[:only] == :subjects
176
        
203

  
177 204
        render_end(options)
178 205
      end
179 206

  
180
      def render_project(project, options={})
181
        options[:top] = 0 unless options.key? :top
182
        options[:indent_increment] = 20 unless options.key? :indent_increment
183
        options[:top_increment] = 20 unless options.key? :top_increment
207
      # Renders issues in a version/project path
208
      def render_path_issues(issues, options)
184 209

  
185
        subject_for_project(project, options) unless options[:only] == :lines
186
        line_for_project(project, options) unless options[:only] == :subjects
187
        
188
        options[:top] += options[:top_increment]
189
        options[:indent] += options[:indent_increment]
190
        @number_of_rows += 1
191
        return if abort?
192
        
193
        # Second, Issues without a version
194
        issues = project.issues.for_gantt.without_version.with_query(@query).all(:limit => current_limit)
195
        sort_issues!(issues)
196
        if issues
197
          render_issues(issues, options)
198
          return if abort?
199
        end
210
        sort_issues! issues
200 211

  
201
        # Third, Versions
202
        project.versions.sort.each do |version|
203
          render_version(version, options)
204
          return if abort?
205
        end
212
        base_indent = options[:indent]
206 213

  
207
        # Fourth, subprojects
208
        project.children.visible.has_module('issue_tracking').each do |project|
209
          render_project(project, options)
210
          return if abort?
211
        end unless project.leaf?
214
        prev_issue = nil
215
        issue_hierarchy = []
212 216

  
213
        # Remove indent to hit the next sibling
214
        options[:indent] -= options[:indent_increment]
215
      end
217
        issues.each do |i|
216 218

  
217
      def render_issues(issues, options={})
218
        @issue_ancestors = []
219
        
220
        issues.each do |i|
219
          # Indent issue hierarchy (issue in the same hierarchy tree should follow thanks to the sorting order)
220
          ancestor_idx = -1
221
          (issue_hierarchy.length - 1).downto(0) do |h_i|
222
            if issue_hierarchy[h_i].is_ancestor_of?(i)
223
              ancestor_idx = h_i
224
              break
225
            end
226
          end
227
          issue_hierarchy.slice!((ancestor_idx + 1)..-1)
228

  
229
          issue_hierarchy << i
230

  
231
          options[:indent] = base_indent + (issue_hierarchy.length - 1) * options[:indent_increment]
232

  
233
          # Render issue node
221 234
          subject_for_issue(i, options) unless options[:only] == :lines
222 235
          line_for_issue(i, options) unless options[:only] == :subjects
223
          
224
          options[:top] += options[:top_increment]
225 236
          @number_of_rows += 1
226 237
          break if abort?
238
          options[:top] += options[:top_increment]
239

  
240
          prev_issue = i
227 241
        end
228
        
229
        options[:indent] -= (options[:indent_increment] * @issue_ancestors.size)
242

  
230 243
      end
231 244

  
245
      def update_and_render_project_path(top_projects, i, prev_issue, issue_path, options)
246

  
247
        prev_version = prev_issue.nil? ? nil : prev_issue.fixed_version
248
        project_change = prev_issue.nil? || i.project != prev_issue.project
249

  
250
        first_item_to_render_idx = nil
251

  
252
        # Render the project path to the project the issue belongs to
253
        if project_change
254

  
255
          top_project = issue_path.empty? ? nil : issue_path.first 
256

  
257
          # Remove invalid path queue
258
          if top_project.nil? || !i.project.is_or_is_descendant_of?(top_project)
259
            # New branch
260
            top_project = top_projects.detect { |p| p.is_or_is_ancestor_of?(i.project) }
261
            top_projects.delete(top_project) # Lighten the top_projects map as a top project should not be rendered twice 
262
            issue_path = [top_project]
263
            first_item_to_render_idx = 0
264
            prev_version = nil
265
          elsif !prev_issue.nil? && !i.project.is_descendant_of?(prev_issue.project)
266
            # Truncate actual path (in any way, we keep the top project node)
267
            # TODO : use index { } with Ruby 1.8.7+
268
            first_distinct_project_idx = nil
269
            1.upto(issue_path.length - 1) do |pi_idx|
270
              if issue_path[pi_idx].is_a?(Project) && !issue_path[pi_idx].is_ancestor_of?(i.project)
271
                first_distinct_project_idx = pi_idx
272
                break
273
              end
274
            end
275
            issue_path.slice!(first_distinct_project_idx..-1)
276
          end
277

  
278
          # TODO : use rindex { } with Ruby 1.9
279
          last_common_project_idx = nil
280
          (issue_path.length - 1).downto(0) do |pi_idx|
281
            if issue_path[pi_idx].is_a?(Project)
282
              last_common_project_idx = pi_idx
283
              break
284
            end
285
          end
286

  
287
          # Complete path
288
          rpath_queue = []
289
          subproject = i.project
290
          until subproject == issue_path.at(last_common_project_idx)
291
            rpath_queue << subproject
292
            subproject = subproject.parent
293
          end
294

  
295
          unless rpath_queue.empty?
296
            first_item_to_render_idx = issue_path.length if first_item_to_render_idx.nil?
297
            issue_path += rpath_queue.reverse 
298
          end
299
        end
300

  
301
        version_change = i.fixed_version != prev_version
302

  
303
        if version_change
304
          # Remove previous Version node from path
305
          unless prev_version.nil?
306
            prev_version_idx = issue_path.index(prev_version)
307
            unless prev_version_idx.nil? # May have been removed with the project path truncation
308
              issue_path.delete_at(prev_version_idx)
309
              first_item_to_render_idx = prev_version_idx if prev_version_idx < issue_path.length || (!first_item_to_render_idx.nil? && prev_version_idx < first_item_to_render_idx)
310
            end
311
          end
312

  
313
          # Place new Version node within the path
314
          unless i.fixed_version.nil?
315
            # TODO : use index { } with Ruby 1.8.7+
316
            new_version_idx = nil
317
            0.upto(issue_path.length - 1) do |pi_idx|
318
              # TODO : p.shared_versions() can slow down rendering, maybe add a has_shared_version?(v) in Project ?
319
              if issue_path[pi_idx].shared_versions.include?(i.fixed_version)
320
                new_version_idx = pi_idx + 1
321
                break
322
              end
323
            end
324
            issue_path.insert(new_version_idx, i.fixed_version)
325
            first_item_to_render_idx = new_version_idx if first_item_to_render_idx.nil? || new_version_idx < first_item_to_render_idx
326
          end
327
        end
328

  
329
        # Render new path part
330
        unless first_item_to_render_idx.nil?
331
          options[:indent] = options[:gantt_indent] + first_item_to_render_idx * options[:indent_increment]
332

  
333
          issue_path.slice(first_item_to_render_idx..-1).each do |pi|
334

  
335
            if pi.is_a?(Version)
336
              # Render version node
337
              render_version(pi, options)
338
            else
339
              # Render project node
340
              render_subproject(pi, options)
341
            end
342
            break if abort?
343
            options[:indent] += options[:indent_increment]
344
            options[:top] += options[:top_increment]
345
          end
346
        else
347
          options[:indent] = options[:gantt_indent] + issue_path.length * options[:indent_increment]
348
        end
349

  
350
        issue_path
351
      end
352

  
232 353
      def render_version(version, options={})
233 354
        # Version header
234 355
        subject_for_version(version, options) unless options[:only] == :lines
235 356
        line_for_version(version, options) unless options[:only] == :subjects
236
        
237
        options[:top] += options[:top_increment]
238 357
        @number_of_rows += 1
239
        return if abort?
240
        
241
        # Remove the project requirement for Versions because it will
242
        # restrict issues to only be on the current project.  This
243
        # ends up missing issues which are assigned to shared versions.
244
        @query.project = nil if @query.project
245
        
246
        issues = version.fixed_issues.for_gantt.with_query(@query).all(:limit => current_limit)
247
        if issues
248
          sort_issues!(issues)
249
          # Indent issues
250
          options[:indent] += options[:indent_increment]
251
          render_issues(issues, options)
252
          options[:indent] -= options[:indent_increment]
253
        end
254 358
      end
255
      
359

  
360
      def render_subproject(subproject, options={})
361
        # Subproject header
362
        subject_for_project(subproject, options) unless options[:only] == :lines
363
        line_for_project(subproject, options) unless options[:only] == :subjects
364
        @number_of_rows += 1
365
      end
366

  
256 367
      def render_end(options={})
257 368
        case options[:format]
258 369
        when :pdf        
......
318 429
        if version.is_a?(Version) && version.start_date && version.due_date
319 430
          options[:zoom] ||= 1
320 431
          options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom]
321
          
432

  
322 433
          coords = coordinates(version.start_date, version.due_date, version.completed_pourcent, options[:zoom])
323 434
          label = "#{h version } #{h version.completed_pourcent.to_i.to_s}%"
324 435
          label = h("#{version.project} -") + label unless @project && @project == version.project
......
338 449
      end
339 450

  
340 451
      def subject_for_issue(issue, options)
341
        while @issue_ancestors.any? && !issue.is_descendant_of?(@issue_ancestors.last)
342
          @issue_ancestors.pop
343
          options[:indent] -= options[:indent_increment]
344
        end
345
          
346 452
        output = case options[:format]
347 453
        when :html
348 454
          css_classes = ''
349 455
          css_classes << ' issue-overdue' if issue.overdue?
350 456
          css_classes << ' issue-behind-schedule' if issue.behind_schedule?
351 457
          css_classes << ' icon icon-issue' unless Setting.gravatar_enabled? && issue.assigned_to
352
          
458

  
353 459
          subject = "<span class='#{css_classes}'>"
354 460
          if issue.assigned_to.present?
355 461
            assigned_string = l(:field_assigned_to) + ": " + issue.assigned_to.name
......
365 471
          pdf_subject(options, issue.subject)
366 472
        end
367 473

  
368
        unless issue.leaf?
369
          @issue_ancestors << issue
370
          options[:indent] += options[:indent_increment]
371
        end
372
        
373 474
        output
374 475
      end
375 476

  
......
405 506
        # width of one day in pixels
406 507
        zoom = @zoom*2
407 508
        g_width = (@date_to - @date_from + 1)*zoom
408
        g_height = 20 * number_of_rows + 30
509
        g_height = 20 * 5 + 30
409 510
        headers_heigth = (show_weeks ? 2*header_heigth : header_heigth)
410 511
        height = g_height + headers_heigth
411
            
512
        width = subject_width+g_width+1
513

  
412 514
        imgl = Magick::ImageList.new
413
        imgl.new_image(subject_width+g_width+1, height)
515
        imgl.new_image(width, height)
414 516
        gc = Magick::Draw.new
415
        
517

  
416 518
        # Subjects
417 519
        gc.stroke('transparent')
418 520
        subjects(:image => gc, :top => (headers_heigth + 20), :indent => 4, :format => :image)
419
    
521
        g_height = 20 * number_of_rows + 30
522
        height = g_height + headers_heigth
523
        imgl.last.resize!(width, height)
524

  
420 525
        # Months headers
421 526
        month_f = @date_from
422 527
        left = subject_width
......
433 538
          left = left + width
434 539
          month_f = month_f >> 1
435 540
        end
436
        
541

  
437 542
        # Weeks headers
438 543
        if show_weeks
439 544
        	left = subject_width
......
465 570
        		week_f = week_f+7
466 571
        	end
467 572
        end
468
        
573

  
469 574
        # Days details (week-end in grey)
470 575
        if show_days
471 576
        	left = subject_width
......
482 587
              wday = 1 if wday > 7
483 588
        	end
484 589
        end
485
    
590

  
486 591
        # border
487 592
        gc.fill('transparent')
488 593
        gc.stroke('grey')
......
490 595
        gc.rectangle(0, 0, subject_width+g_width, headers_heigth)
491 596
        gc.stroke('black')
492 597
        gc.rectangle(0, 0, subject_width+g_width, g_height+ headers_heigth-1)
493
            
598

  
494 599
        # content
495 600
        top = headers_heigth + 20
496 601

  
......
502 607
          gc.stroke('red')
503 608
          x = (Date.today-@date_from+1)*zoom + subject_width
504 609
          gc.line(x, headers_heigth, x, headers_heigth + g_height-1)      
505
        end    
506
        
610
        end
611

  
507 612
        gc.draw(imgl)
508 613
        imgl.format = format
509 614
        imgl.to_blob
......
520 625
        pdf.Cell(PDF::LeftPaneWidth, 20, project.to_s)
521 626
        pdf.Ln
522 627
        pdf.SetFontStyle('B',9)
523
        
628

  
524 629
        subject_width = PDF::LeftPaneWidth
525 630
        header_heigth = 5
526
        
631

  
527 632
        headers_heigth = header_heigth
528 633
        show_weeks = false
529 634
        show_days = false
530
        
635

  
531 636
        if self.months < 7
532 637
          show_weeks = true
533 638
          headers_heigth = 2*header_heigth
......
536 641
            headers_heigth = 3*header_heigth
537 642
          end
538 643
        end
539
        
644

  
540 645
        g_width = PDF.right_pane_width
541 646
        zoom = (g_width) / (self.date_to - self.date_from + 1)
542 647
        g_height = 120
543 648
        t_height = g_height + headers_heigth
544
        
649

  
545 650
        y_start = pdf.GetY
546
        
651

  
547 652
        # Months headers
548 653
        month_f = self.date_from
549 654
        left = subject_width
......
556 661
          left = left + width
557 662
          month_f = month_f >> 1
558 663
        end  
559
        
664

  
560 665
        # Weeks headers
561 666
        if show_weeks
562 667
          left = subject_width
......
582 687
            week_f = week_f+7
583 688
          end
584 689
        end
585
        
690

  
586 691
        # Days headers
587 692
        if show_days
588 693
          left = subject_width
......
599 704
            wday = 1 if wday > 7
600 705
          end
601 706
        end
602
        
707

  
603 708
        pdf.SetY(y_start)
604 709
        pdf.SetX(15)
605 710
        pdf.Cell(subject_width+g_width-15, headers_heigth, "", 1)
606
        
711

  
607 712
        # Tasks
608 713
        top = headers_heigth + y_start
609 714
        options = {
......
620 725
        render(options)
621 726
        pdf.Output
622 727
      end
623
      
728

  
624 729
      private
625
      
730

  
626 731
      def coordinates(start_date, end_date, progress, zoom=nil)
627 732
        zoom ||= @zoom
628
        
733

  
629 734
        coords = {}
630 735
        if start_date && end_date && start_date < self.date_to && end_date > self.date_from
631 736
          if start_date > self.date_from
......
640 745
          else
641 746
            coords[:bar_end] = self.date_to - self.date_from + 1
642 747
          end
643
        
748

  
644 749
          if progress
645 750
            progress_date = start_date + (end_date - start_date) * (progress / 100.0)
646 751
            if progress_date > self.date_from && progress_date > start_date
......
650 755
                coords[:bar_progress_end] = self.date_to - self.date_from + 1
651 756
              end
652 757
            end
653
            
758

  
654 759
            if progress_date < Date.today
655 760
              late_date = [Date.today, end_date].min
656 761
              if late_date > self.date_from && late_date > start_date
......
663 768
            end
664 769
          end
665 770
        end
666
        
771

  
667 772
        # Transforms dates into pixels witdh
668 773
        coords.keys.each do |key|
669 774
          coords[key] = (coords[key] * zoom).floor
......
671 776
        coords
672 777
      end
673 778

  
674
      # Sorts a collection of issues by start_date, due_date, id for gantt rendering
779
      # Sorts a collection of issues for gantt rendering
675 780
      def sort_issues!(issues)
676
        issues.sort! { |a, b| gantt_issue_compare(a, b, issues) }
781
        issues.sort! { |a, b| gantt_issue_compare(a, b) }
677 782
      end
678
  
679
      # TODO: top level issues should be sorted by start date
680
      def gantt_issue_compare(x, y, issues)
681
        if x.root_id == y.root_id
783

  
784
      # Compare issues for rendering order 
785
      def gantt_issue_compare(x, y)
786

  
787
        if x.parent_id == y.parent_id
788
          # Same level in issue hierarchy
789
          basic_gantt_issue_compare(x, y)
790
        elsif x.root_id == y.root_id || x.root_id == y.id || y.root_id == x.id
791
          # Same issue hierarchy
682 792
          x.lft <=> y.lft
683 793
        else
794
          # Distinct hierarchies
684 795
          x.root_id <=> y.root_id
796
          # TODO : advanced sort between issues when ancestors have been filtered out by query
685 797
        end
686 798
      end
687
      
799

  
800
      def basic_gantt_issue_compare(x, y)
801
        if x.start_date == y.start_date
802
          x.id <=> y.id
803
        elsif x.start_date.nil?
804
          1 # null date appears at the end
805
        elsif y.start_date.nil?
806
          -1
807
        else
808
          x.start_date <=> y.start_date
809
        end
810
      end
811

  
688 812
      def current_limit
689 813
        if @max_rows
690 814
          @max_rows - @number_of_rows
......
692 816
          nil
693 817
        end
694 818
      end
695
      
819

  
696 820
      def abort?
697 821
        if @max_rows && @number_of_rows >= @max_rows
698 822
          @truncated = true
699 823
        end
700 824
      end
701
      
825

  
702 826
      def pdf_new_page?(options)
703 827
        if options[:top] > 180
704 828
          options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top])
......
707 831
          options[:pdf].Line(15, options[:top] - 0.1, PDF::TotalWidth, options[:top] - 0.1)
708 832
        end
709 833
      end
710
      
834

  
711 835
      def html_subject(params, subject, options={})
712 836
        output = "<div class=' #{options[:css] }' style='position: absolute;line-height:1.2em;height:16px;top:#{params[:top]}px;left:#{params[:indent]}px;overflow:hidden;'>"
713 837
        output << subject
......
715 839
        @subjects << output
716 840
        output
717 841
      end
718
      
842

  
719 843
      def pdf_subject(params, subject, options={})
720 844
        params[:pdf].SetY(params[:top])
721 845
        params[:pdf].SetX(15)
722
        
846

  
723 847
        char_limit = PDF::MaxCharactorsForSubject - params[:indent]
724 848
        params[:pdf].Cell(params[:subject_width]-15, 5, (" " * params[:indent]) +  subject.to_s.sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'), "LR")
725
      
849

  
726 850
        params[:pdf].SetY(params[:top])
727 851
        params[:pdf].SetX(params[:subject_width])
728 852
        params[:pdf].Cell(params[:g_width], 5, "", "LR")
729 853
      end
730
      
854

  
731 855
      def image_subject(params, subject, options={})
732 856
        params[:image].fill('black')
733 857
        params[:image].stroke('transparent')
734 858
        params[:image].stroke_width(1)
735 859
        params[:image].text(params[:indent], params[:top] + 2, subject)
736 860
      end
737
      
861

  
738 862
      def html_task(params, coords, options={})
739 863
        output = ''
740 864
        # Renders the task bar, with progress and late
......
773 897
        @lines << output
774 898
        output
775 899
      end
776
      
900

  
777 901
      def pdf_task(params, coords, options={})
778 902
        height = options[:height] || 2
779 903
        
test/unit/lib/redmine/helpers/gantt_test.rb (working copy)
91 91
    end
92 92
  end
93 93

  
94
  context "#number_of_rows_on_project" do
95
    setup do
96
      create_gantt
97
    end
98
    
99
    should "clear the @query.project so cross-project issues and versions can be counted" do
100
      assert @gantt.query.project
101
      @gantt.number_of_rows_on_project(@project)
102
      assert_nil @gantt.query.project
103
    end
104

  
105
    should "count 1 for the project itself" do
106
      assert_equal 1, @gantt.number_of_rows_on_project(@project)
107
    end
108

  
109
    should "count the number of issues without a version" do
110
      @project.issues << Issue.generate_for_project!(@project, :fixed_version => nil)
111
      assert_equal 2, @gantt.number_of_rows_on_project(@project)
112
    end
113

  
114
    should "count the number of versions" do
115
      @project.versions << Version.generate!
116
      @project.versions << Version.generate!
117
      assert_equal 3, @gantt.number_of_rows_on_project(@project)
118
    end
119

  
120
    should "count the number of issues on versions, including cross-project" do
121
      version = Version.generate!
122
      @project.versions << version
123
      @project.issues << Issue.generate_for_project!(@project, :fixed_version => version)
124
      
125
      assert_equal 3, @gantt.number_of_rows_on_project(@project)
126
    end
127
    
128
    should "recursive and count the number of rows on each subproject" do
129
      @project.versions << Version.generate! # +1
130

  
131
      @subproject = Project.generate!(:enabled_module_names => ['issue_tracking']) # +1
132
      @subproject.set_parent!(@project)
133
      @subproject.issues << Issue.generate_for_project!(@subproject) # +1
134
      @subproject.issues << Issue.generate_for_project!(@subproject) # +1
135

  
136
      @subsubproject = Project.generate!(:enabled_module_names => ['issue_tracking']) # +1
137
      @subsubproject.set_parent!(@subproject)
138
      @subsubproject.issues << Issue.generate_for_project!(@subsubproject) # +1
139

  
140
      assert_equal 7, @gantt.number_of_rows_on_project(@project) # +1 for self
141
    end
142
  end
143

  
144 94
  # TODO: more of an integration test
145 95
  context "#subjects" do
146 96
    setup do
(2-2/8)