Merge branch 'master' into feature_save_queries

Conflicts:
	app/controllers/cost_reports_controller.rb
	app/models/report.rb
	app/views/cost_reports/index.rhtml
pull/6827/head
Tim Felgentreff 14 years ago
commit 7a43f4b57b
  1. 0
      README
  2. 240
      app/controllers/cost_reports_controller.rb
  3. 226
      app/helpers/reporting_helper.rb
  4. 80
      app/models/cost_query/custom_field_mixin.rb
  5. 40
      app/models/cost_query/filter.rb
  6. 7
      app/models/cost_query/filter/activity_id.rb
  7. 10
      app/models/cost_query/filter/assigned_to_id.rb
  8. 9
      app/models/cost_query/filter/author_id.rb
  9. 11
      app/models/cost_query/filter/category_id.rb
  10. 9
      app/models/cost_query/filter/cost_object_id.rb
  11. 18
      app/models/cost_query/filter/cost_type_id.rb
  12. 5
      app/models/cost_query/filter/created_on.rb
  13. 25
      app/models/cost_query/filter/custom_field.rb
  14. 6
      app/models/cost_query/filter/due_date.rb
  15. 11
      app/models/cost_query/filter/fixed_version_id.rb
  16. 25
      app/models/cost_query/filter/issue_id.rb
  17. 8
      app/models/cost_query/filter/no_filter.rb
  18. 11
      app/models/cost_query/filter/overridden_costs.rb
  19. 35
      app/models/cost_query/filter/permission_filter.rb
  20. 9
      app/models/cost_query/filter/priority_id.rb
  21. 26
      app/models/cost_query/filter/project_id.rb
  22. 4
      app/models/cost_query/filter/spent_on.rb
  23. 6
      app/models/cost_query/filter/start_date.rb
  24. 10
      app/models/cost_query/filter/status_id.rb
  25. 6
      app/models/cost_query/filter/subject.rb
  26. 8
      app/models/cost_query/filter/tmonth.rb
  27. 9
      app/models/cost_query/filter/tracker_id.rb
  28. 8
      app/models/cost_query/filter/tweek.rb
  29. 8
      app/models/cost_query/filter/tyear.rb
  30. 5
      app/models/cost_query/filter/updated_on.rb
  31. 12
      app/models/cost_query/filter/user_id.rb
  32. 36
      app/models/cost_query/group_by.rb
  33. 5
      app/models/cost_query/group_by/activity_id.rb
  34. 7
      app/models/cost_query/group_by/assigned_to_id.rb
  35. 7
      app/models/cost_query/group_by/author_id.rb
  36. 7
      app/models/cost_query/group_by/category_id.rb
  37. 7
      app/models/cost_query/group_by/cost_object_id.rb
  38. 5
      app/models/cost_query/group_by/cost_type_id.rb
  39. 7
      app/models/cost_query/group_by/custom_field.rb
  40. 7
      app/models/cost_query/group_by/fixed_version_id.rb
  41. 5
      app/models/cost_query/group_by/issue_id.rb
  42. 7
      app/models/cost_query/group_by/priority_id.rb
  43. 5
      app/models/cost_query/group_by/project_id.rb
  44. 5
      app/models/cost_query/group_by/singleton_value.rb
  45. 5
      app/models/cost_query/group_by/spent_on.rb
  46. 7
      app/models/cost_query/group_by/status_id.rb
  47. 5
      app/models/cost_query/group_by/tmonth.rb
  48. 7
      app/models/cost_query/group_by/tracker_id.rb
  49. 5
      app/models/cost_query/group_by/tweek.rb
  50. 5
      app/models/cost_query/group_by/tyear.rb
  51. 5
      app/models/cost_query/group_by/user_id.rb
  52. 5
      app/models/cost_query/group_by/week.rb
  53. 80
      app/models/entry.rb
  54. 30
      app/models/report.rb
  55. 32
      app/models/report/chainable.rb
  56. 15
      app/models/report/filter.rb
  57. 25
      app/models/report/filter/base.rb
  58. 9
      app/models/report/filter/no_filter.rb
  59. 17
      app/models/report/group_by.rb
  60. 15
      app/models/report/group_by/base.rb
  61. 4
      app/models/report/group_by/ruby_aggregation.rb
  62. 10
      app/models/report/group_by/singleton_value.rb
  63. 3
      app/models/report/group_by/sql_aggregation.rb
  64. 2
      app/models/report/inherited_attribute.rb
  65. 63
      app/models/report/inherited_namespace.rb
  66. 87
      app/models/report/operator.rb
  67. 64
      app/models/report/query_utils.rb
  68. 29
      app/models/report/result.rb
  69. 103
      app/models/report/sql_statement.rb
  70. 4
      app/models/report/table.rb
  71. 10
      app/models/report/transformer.rb
  72. 6
      app/models/report/validation.rb
  73. 2
      app/models/report/validation/dates.rb
  74. 2
      app/models/report/validation/integers.rb
  75. 2
      app/models/report/validation/sql.rb
  76. 2
      app/models/report/walker.rb
  77. 60
      app/views/cost_reports/_cost_entry_table.rhtml
  78. 102
      app/views/cost_reports/_cost_report_table.rhtml
  79. 39
      app/views/cost_reports/_filters.rhtml
  80. 74
      app/views/cost_reports/_group_by.rhtml
  81. 34
      app/views/cost_reports/_restore_query.rhtml
  82. 46
      app/views/cost_reports/_simple_cost_report_table.rhtml
  83. 13
      app/views/cost_reports/_sortable_init.rhtml
  84. 5
      app/views/cost_reports/available_values.rhtml
  85. 11
      app/views/cost_reports/filters/_activate_filter.rhtml
  86. 11
      app/views/cost_reports/filters/_available_value.rhtml
  87. 22
      app/views/cost_reports/filters/_date.rhtml
  88. 21
      app/views/cost_reports/filters/_heavy_values.rhtml
  89. 21
      app/views/cost_reports/filters/_multi_values.rhtml
  90. 25
      app/views/cost_reports/filters/_operators.rhtml
  91. 10
      app/views/cost_reports/filters/_remove_filter.rhtml
  92. 12
      app/views/cost_reports/filters/_text.rhtml
  93. 17
      app/views/cost_reports/filters/_text_box.rhtml
  94. 76
      app/views/cost_reports/index.rhtml
  95. 22
      app/views/hooks/_view_projects_show_sidebar_bottom_hook.rhtml
  96. 10
      assets/stylesheets/reporting.css
  97. 15
      config/locales/de.yml
  98. 15
      config/locales/en.yml
  99. 4
      config/routes.rb
  100. 138
      doc/untitled.html
  101. Some files were not shown because too many files have changed in this diff Show More

@ -1,240 +0,0 @@
class CostReportsController < ApplicationController
before_filter :load_all
before_filter :find_optional_project, :only => [:index, :drill_down]
before_filter :generate_query, :only => [:index, :drill_down]
before_filter :set_cost_types, :only => [:index, :drill_down]
before_filter :save_query, :only => [:index, :drill_down]
# rescue_from Exception do |exception|
# session.delete(:cost_query)
# @custom_errors ||= []
# @custom_errors << l(:error_generic)
# render :layout => !request.xhr?
# end
helper :reporting
include ReportingHelper
def index
if @valid = valid_query?
if @query.group_bys.empty?
@table_partial = "cost_entry_table"
elsif @query.depth_of(:column) + @query.depth_of(:row) == 1
@table_partial = "simple_cost_report_table"
else
if @query.depth_of(:column) == 0 || @query.depth_of(:row) == 0
@query.depth_of(:column) == 0 ? @query.column(:singleton_value) : @query.row(:singleton_value)
end
@table_partial = "cost_report_table"
end
end
respond_to do |format|
format.html { render :layout => !request.xhr? }
end
end
def drill_down
redirect_to :action => :index
end
def available_values
filter = filter_class(params[:filter_name].to_s)
render_404 unless filter
can_answer = filter.respond_to? :available_values
@available_values = filter.available_values
respond_to do |format|
format.html { can_answer ? render(:layout => !request.xhr?) : "" }
end
end
##
# Determines if the request contains filters to set
def set_filter? #FIXME: rename to set_query?
params[:set_filter].to_i == 1
end
##
# Determines if the request sets a unit type
def set_unit?
params[:unit]
end
##
# Find a query to search on and put it in the session
def filter_params
filters = http_filter_parameters if set_filter?
filters ||= session[:cost_query].try(:[], :filters)
filters ||= default_filter_parameters
end
def group_params
groups = http_group_parameters if set_filter?
groups ||= session[:cost_query].try(:[], :groups)
groups ||= default_group_parameters
end
##
# Extract active filters from the http params
def http_filter_parameters
params[:fields] ||= []
(params[:fields].reject { |f| f.empty? } || []).inject({:operators => {}, :values => {}}) do |hash, field|
hash[:operators][field.to_sym] = params[:operators][field]
hash[:values][field.to_sym] = params[:values][field]
hash
end
end
def http_group_parameters
if params[:groups]
rows = params[:groups][:rows]
columns = params[:groups][:columns]
end
{:rows => (rows || []), :columns => (columns || [])}
end
##
# Set a default query to cut down initial load time
def default_filter_parameters
{:operators => {:user_id => "=", :spent_on => ">d"},
:values => {:user_id => [User.current.id], :spent_on => [30.days.ago.strftime('%Y-%m-%d')]}
}.tap do |hash|
if @project
hash[:operators].merge! :project_id => "="
hash[:values].merge! :project_id => [@project.id]
end
end
end
##
# Set a default query to cut down initial load time
def default_group_parameters
{:columns => [:week], :rows => []}.tap do |h|
if @project
h[:rows] << :issue_id
else
h[:rows] << :project_id
end
end
end
def force_default?
params[:default].to_i == 1
end
##
# We apply a project filter, except when we are just applying a brand new query
def ensure_project_scope!(filters)
return unless ensure_project_scope?
if @project
filters[:operators].merge! :project_id => "="
filters[:values].merge! :project_id => @project.id.to_s
else
filters[:operators].delete :project_id
filters[:values].delete :project_id
end
end
def ensure_project_scope?
!(set_filter? or set_unit?)
end
def save_query
return unless params[:save_query].to_i == 1 || !User.current.allowed_to?(:save_queries, @project, :global => true)
@query.name = params[:name].present? ? params[:name] : l(:label_default)
@query.project ||= params[:project] || @project
@query.is_public = if User.current.allowed_to?(:manage_public_queries, @project) || User.current.admin?
params[:public]
end || false
@query.user_id ||= User.current.id
@query.save!
end
##
# Build the query from the current request and save it to
# the session.
def generate_query
CostQuery::QueryUtils.cache.clear
if params[:query]
return @query = CostQuery.deserialize(CostQuery.find(params[:query]).serialized)
end
filters = force_default? ? default_filter_parameters : filter_params
groups = force_default? ? default_group_parameters : group_params
ensure_project_scope! filters
session[:cost_query] = {:filters => filters, :groups => groups}
@query = CostQuery.new
@query.tap do |q|
filters[:operators].each do |filter, operator|
q.filter(filter.to_sym,
:operator => operator,
:values => filters[:values][filter])
end
end
groups[:rows].reverse_each {|r| @query.row(r) }
groups[:columns].reverse_each {|c| @query.column(c) }
@query
end
def valid_query?
return true unless @query
errornous = @query.filters ? @query.filters.select { |f| !f.valid? } : []
@custom_errors = errornous.map do |err|
"Filter #{l(err.label)}: #{err.errors.join(", ")}"
end
errornous.empty?
end
##
# Determine active cost types, the currently selected unit and corresponding cost type
def set_cost_types
set_active_cost_types
set_unit
set_cost_type
end
# Determine the currently active unit from the parameters or session
# sets the @unit_id -> this is used in the index for determining the active unit tab
def set_unit
@unit_id = params[:unit].try(:to_i) || session[:unit_id].to_i
@unit_id = 0 unless @cost_types.include? @unit_id
session[:unit_id] = @unit_id
end
# Determine the active cost type, if it is not labor or money, and add a hidden filter to the query
# sets the @cost_type -> this is used to select the proper units for display
def set_cost_type
if @unit_id != 0
@query.filter :cost_type_id, :operator => '=', :value => @unit_id.to_s, :display => false
@cost_type = CostType.find(@unit_id) if @unit_id > 0
end
end
# set the @cost_types -> this is used to determine which tabs to display
def set_active_cost_types
unless @cost_types = session[:cost_query][:filters][:values][:cost_type_id].try(:collect, &:to_i)
relevant_cost_types = CostType.find(:all, :select => "id", :order => "id ASC").select do |t|
t.cost_entries.count > 0
end.collect(&:id)
@cost_types = [-1, 0, *relevant_cost_types]
end
end
def load_all
CostQuery::GroupBy.all
CostQuery::Filter.all
end
private
## FIXME: Remove this once we moved to Redmine 1.0
def find_optional_project
@project = Project.find(params[:project_id]) unless params[:project_id].blank?
allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
allowed ? true : deny_access
rescue ActiveRecord::RecordNotFound
render_404
end
end

@ -1,226 +0,0 @@
module ReportingHelper
include QueriesHelper
def l(*values)
return values.first if values.size == 1 and values.first.respond_to? :to_str
super
end
##
# For a given CostQuery::Filter filter, return an array of hashes, that contain
# the partials that should be rendered (:name) for that filter and necessary
# parameters.
# @param [CostQuery::Filter] the filter we want to render
def html_elements(filter)
return text_elements filter if CostQuery::Operator.string_operators.all? { |o| filter.available_operators.include? o }
return date_elements filter if CostQuery::Operator.time_operators.all? { |o| filter.available_operators.include? o }
return heavy_object_elements filter if filter.heavy?
object_elements filter
end
def with_project(project)
project = Project.find(project) unless project.is_a? Project
project_was, @project = @project, project
yield
@project = project_was
end
def object_elements(filter)
[
{:name => :activate_filter, :filter_name => filter.underscore_name, :label => l(filter.label)},
{:name => :operators, :filter_name => filter.underscore_name, :operators => filter.available_operators},
{:name => :multi_values, :filter_name => filter.underscore_name},
{:name => :remove_filter, :filter_name => filter.underscore_name}]
end
def heavy_object_elements(filter)
[
{:name => :activate_filter, :filter_name => filter.underscore_name, :label => l(filter.label)},
{:name => :text, :text => l(:label_equals)},
{:name => :heavy_values, :filter_name => filter.underscore_name, :disable_controls => true},
{:name => :remove_filter, :filter_name => filter.underscore_name}]
end
def date_elements(filter)
[
{:name => :activate_filter, :filter_name => filter.underscore_name, :label => l(filter.label)},
{:name => :operators, :filter_name => filter.underscore_name, :operators => filter.available_operators},
{:name => :date, :filter_name => filter.underscore_name},
{:name => :remove_filter, :filter_name => filter.underscore_name}]
end
def text_elements(filter)
[
{:name => :activate_filter, :filter_name => filter.underscore_name, :label => l(filter.label)},
{:name => :operators, :filter_name => filter.underscore_name, :operators => filter.available_operators},
{:name => :text_box, :filter_name => filter.underscore_name},
{:name => :remove_filter, :filter_name => filter.underscore_name}]
end
def link_to_project(project)
link_to project.name, :controller => 'projects', :action => 'show', :id => project
end
def mapped(value, klass, default)
id = value.to_i
return default if id < 0
klass.find(id).name
end
def label_for(field)
name = field.to_s.camelcase
return l(field) unless CostQuery::Filter.const_defined? name
l(CostQuery::Filter.const_get(name).label)
end
def debug_fields(result, prefix = ", ")
#prefix << result.fields.inspect << ", " << result.key.inspect if params[:debug]
end
def show_field(key, value)
@show_row ||= Hash.new { |h,k| h[k] = {}}
@show_row[key][value] ||= field_representation_map(key, value)
end
def raw_field(key, value)
@raw_row ||= Hash.new { |h,k| h[k] = {}}
@raw_row[key][value] ||= field_sort_map(key, value)
end
def cost_object_link(cost_object_id)
co = CostObject.find(cost_object_id)
if User.current.allowed_to_with_inheritance?(:view_cost_objects, co.project)
link_to_cost_object(co)
else
co.subject
end
end
def field_representation_map(key, value)
return l(:label_none) if value.blank?
case key.to_sym
when :activity_id then mapped value, Enumeration, "<i>#{l(:caption_material_costs)}</i>"
when :project_id then link_to_project Project.find(value.to_i)
when :user_id, :assigned_to_id, :author_id then link_to_user User.find(value.to_i)
when :tyear, :units then value
when :tweek then "#{l(:label_week)} ##{value}"
when :tmonth then month_name(value.to_i)
when :category_id then IssueCategory.find(value.to_i).name
when :cost_type_id then mapped value, CostType, l(:caption_labor)
when :cost_object_id then cost_object_link value
when :issue_id then link_to_issue Issue.find(value.to_i)
when :spent_on then format_date(value.to_date)
when :tracker_id then Tracker.find(value.to_i)
when :week then "#{l(:label_week)} #%s" % value.to_i.modulo(100)
when :priority_id then IssuePriority.find(value.to_i).name
when :fixed_version_id then Version.find(value.to_i).name
when :singleton_value then ""
when :status_id then IssueStatus.find(value.to_i).name
else value.to_s
end
end
def field_sort_map(key, value)
return "" if value.blank?
case key.to_sym
when :issue_id, :tweek, :tmonth, :week then value.to_i
when :spent_on then value.to_date.mjd
else h(field_representation_map(key, value).gsub(/<\/?[^>]*>/, ""))
end
end
def show_result(row, unit_id = @unit_id)
case unit_id
when -1 then l_hours(row.units)
when 0 then row.real_costs ? number_to_currency(row.real_costs) : '-'
else
current_cost_type = @cost_type || CostType.find(unit_id)
pluralize(row.units, current_cost_type.unit, current_cost_type.unit_plural)
end
end
def set_filter_options(struct, key, value)
struct[:operators][key] = "="
struct[:values][key] = value.to_s
end
def available_cost_type_tabs(cost_types)
tabs = cost_types.to_a
tabs.delete 0 # remove money from list
tabs.unshift 0 # add money as first tab
tabs.map {|cost_type_id| [cost_type_id, cost_type_label(cost_type_id)] }
end
def cost_type_label(cost_type_id, cost_type_inst = nil, plural = true)
case cost_type_id
when -1 then l(:caption_labor)
when 0 then l(:label_money)
else (cost_type_inst || CostType.find(cost_type_id)).name
end
end
def link_to_details(result)
return '' # unless result.respond_to? :fields # uncomment to display
session_filter = {:operators => session[:cost_query][:filters][:operators].dup, :values => session[:cost_query][:filters][:values].dup }
filters = result.fields.inject session_filter do |struct, (key, value)|
key = key.to_sym
case key
when :week
set_filter_options struct, :tweek, value.to_i.modulo(100)
set_filter_options struct, :tyear, value.to_i / 100
when :month, :year
set_filter_options struct, :"t#{key}", value
when :count, :units, :costs, :display_costs, :sum, :real_costs
else
set_filter_options struct, key, value
end
struct
end
options = { :fields => filters[:operators].keys, :set_filter => 1, :action => :drill_down }
link_to '[+]', filters.merge(options), :class => 'drill_down', :title => l(:description_drill_down)
end
##
# Create the appropriate action for an entry with the type of log to use
def action_for(result, options = {})
options.merge :controller => result.fields['type'] == 'TimeEntry' ? 'timelog' : 'costlog', :id => result.fields['id'].to_i
end
##
# Create the appropriate action for an entry with the type of log to use
def entry_for(result)
type = result.fields['type'] == 'TimeEntry' ? TimeEntry : CostEntry
type.find(result.fields['id'].to_i)
end
##
# For a given row, determine how to render it's contents according to usability and
# localization rules
def show_row(row)
link_to_details(row) << row.render { |k,v| show_field(k,v) }
end
def delimit(items, options = {})
options[:step] ||= 1
options[:delim] ||= '&bull;'
delimited = []
items.each_with_index do |item, ix|
if ix != 0 and ix % options[:step] == 0
delimited << "<b> #{options[:delim]} </b>" + item
else
delimited << item
end
end
delimited
end
##
# Finds the Filter-Class for as specific filter name while being careful with the filter_name parameter as it is user input.
def filter_class(filter_name)
klass = CostQuery::Filter.const_get(filter_name.to_s.camelize)
return klass if klass.is_a? Class
nil
rescue NameError
return nil
end
end

@ -1,80 +0,0 @@
module CostQuery::CustomFieldMixin
include CostQuery::QueryUtils
attr_reader :custom_field
SQL_TYPES = {
'string' => mysql? ? 'char' : 'varchar',
'list' => mysql? ? 'char' : 'varchar',
'text' => mysql? ? 'char' : 'text',
'bool' => mysql? ? 'unsigned' : 'boolean',
'date' => 'date',
'int' => 'decimal(60,3)', 'float' => 'decimal(60,3)' }
def self.extended(base)
base.inherited_attribute :factory
base.factory = base
super
end
def all
@all ||= generate_subclasses
end
def generate_subclasses
IssueCustomField.all.map do |field|
class_name = class_name_for field.name
parent.send(:remove_const, class_name) if parent.const_defined? class_name
parent.const_set class_name, Class.new(self).prepare(field, class_name)
end
end
def factory?
factory == self
end
def on_prepare(&block)
return factory.on_prepare unless factory?
@on_prepare = block if block
@on_prepare ||= proc { }
@on_prepare
end
def table_name
@class_name.demodulize.underscore.tableize.singularize
end
def prepare(field, class_name)
@custom_field = field
label field.name
@class_name = class_name
dont_inherit :group_fields
db_field table_name
join_table (<<-SQL % [CustomValue.table_name, table_name, field.id, field.name, SQL_TYPES[field.field_format]]).gsub(/^ /, "")
-- BEGIN Custom Field Join: "%4$s"
LEFT OUTER JOIN (
\tSELECT
\t\tCAST(value AS %5$s) AS %2$s,
\t\tcustomized_type,
\t\tcustom_field_id,
\t\tcustomized_id
\tFROM
\t\t%1$s)
AS %2$s
ON %2$s.customized_type = 'Issue'
AND %2$s.custom_field_id = %3$d
AND %2$s.customized_id = entries.issue_id
-- END Custom Field Join: "%4$s"
SQL
instance_eval(&on_prepare)
self
end
def new(*)
fail "Only subclasses of #{self} should be instanciated." if factory?
super
end
def class_name_for(field)
"CustomField" << field.split(/[ \-_]/).map { |part| part.gsub(/\W/, '').capitalize }.join
end
end

@ -1,40 +0,0 @@
require "set"
module CostQuery::Filter
def self.all
@all ||= Set[
CostQuery::Filter::ActivityId,
CostQuery::Filter::AssignedToId,
CostQuery::Filter::AuthorId,
CostQuery::Filter::CategoryId,
CostQuery::Filter::CostTypeId,
CostQuery::Filter::CreatedOn,
CostQuery::Filter::DueDate,
CostQuery::Filter::FixedVersionId,
CostQuery::Filter::IssueId,
CostQuery::Filter::OverriddenCosts,
CostQuery::Filter::PriorityId,
CostQuery::Filter::ProjectId,
CostQuery::Filter::SpentOn,
CostQuery::Filter::StartDate,
CostQuery::Filter::StatusId,
CostQuery::Filter::Subject,
CostQuery::Filter::TrackerId,
#CostQuery::Filter::Tweek,
#CostQuery::Filter::Tmonth,
#CostQuery::Filter::Tyear,
CostQuery::Filter::UpdatedOn,
CostQuery::Filter::UserId,
CostQuery::Filter::PermissionFilter,
*CostQuery::Filter::CustomField.all
]
end
def self.all_grouped
all.group_by { |f| f.applies_for }.to_a.sort { |a,b| a.first.to_s <=> b.first.to_s }
end
def self.from_hash
raise NotImplementedError
end
end

@ -1,7 +0,0 @@
class CostQuery::Filter::ActivityId < CostQuery::Filter::Base
label :field_activity
def self.available_values(*)
TimeEntryActivity.find(:all, :order => 'name').map { |a| [a.name, a.id] }
end
end

@ -1,10 +0,0 @@
class CostQuery::Filter::AssignedToId < CostQuery::Filter::Base
use :null_operators
join_table Issue
applies_for :label_issue_attributes
label :field_assigned_to
def self.available_values(*)
CostQuery::Filter::UserId.available_values
end
end

@ -1,9 +0,0 @@
class CostQuery::Filter::AuthorId < CostQuery::Filter::Base
join_table Issue
applies_for :label_issue_attributes
label :field_author
def self.available_values(*)
CostQuery::Filter::UserId.available_values
end
end

@ -1,11 +0,0 @@
class CostQuery::Filter::CategoryId < CostQuery::Filter::Base
use :null_operators
join_table Issue
applies_for :label_issue_attributes
label :field_category
def self.available_values(*)
categories = IssueCategory.find :all, :conditions => {:project_id => Project.visible.map{|p| p.id}}
categories.map { |c| ["#{c.project.name} - #{c.name} ", c.id] }.sort_by { |a| a.first.to_s + a.second.to_s }
end
end

@ -1,9 +0,0 @@
class CostQuery::Filter::CostObjectId < CostQuery::Filter::Base
join_table Project
label :field_cost_object
applies_for :label_issue_attributes
def self.available_values(*)
([[l(:caption_labor), -1]] + CostObject.find(:all, :order => 'name').map { |t| [t.name, t.id] })
end
end

@ -1,18 +0,0 @@
class CostQuery::Filter::CostTypeId < CostQuery::Filter::Base
label :field_cost_type
extra_options :display
def initialize(child = nil, options = {})
super
@display = options[:display]
end
def display?
return super if @display.nil?
@display
end
def self.available_values(*)
([[l(:caption_labor), -1]] + CostType.find(:all, :order => 'name').map { |t| [t.name, t.id] })
end
end

@ -1,5 +0,0 @@
class CostQuery::Filter::CreatedOn < CostQuery::Filter::Base
db_field "entries.created_on"
use :time_operators
label :field_created_on
end

@ -1,25 +0,0 @@
module CostQuery::Filter
class CustomField < Base
extend CostQuery::CustomFieldMixin
on_prepare do
applies_for :label_issue_attributes
# redmine internals just suck
case custom_field.field_format
when 'string', 'text' then use :string_operators
when 'list' then use :null_operators
when 'date' then use :time_operators
when 'int', 'float' then use :integer_operators
when 'bool'
@possible_values = [['true', 't'], ['false', 'f']]
use :null_operators
else
fail "cannot handle #{custom_field.field_format.inspect}"
end
end
def self.available_values(*)
@possible_values || custom_field.possible_values
end
end
end

@ -1,6 +0,0 @@
class CostQuery::Filter::DueDate < CostQuery::Filter::Base
use :time_operators
join_table Issue
applies_for :label_issue_attributes
label :field_due_date
end

@ -1,11 +0,0 @@
class CostQuery::Filter::FixedVersionId < CostQuery::Filter::Base
use :null_operators
join_table Issue
applies_for :label_issue_attributes
label :field_fixed_version
def self.available_values(*)
versions = Version.find :all, :conditions => {:project_id => Project.visible.map{|p| p.id}}
versions.map { |a| ["#{a.project.name} - #{a.name}", a.id] }.sort_by { |a| a.first.to_s + a.second.to_s }
end
end

@ -1,25 +0,0 @@
class CostQuery::Filter::IssueId < CostQuery::Filter::Base
label :field_issue
def self.available_values(*)
issues = Project.visible.collect { |p| p.issues }.flatten.uniq.sort_by { |i| i.id }
issues.map { |i| [text_for_issue i, i.id] }
end
def self.heavy?
true
end
not_selectable! if heavy?
def self.text_for_issue(i)
i = i.first if i.is_a? Array
str = "##{i.id} "
str << (i.subject.length > 30 ? i.subject.first(26)+'...': i.subject)
end
def self.text_for_id(i)
text_for_issue Issue.find(i)
rescue ActiveRecord::RecordNotFound
""
end
end

@ -1,8 +0,0 @@
class CostQuery::Filter::NoFilter < CostQuery::Filter::Base
dont_display!
singleton
def sql_statement
CostQuery::SqlStatement.for_entries
end
end

@ -1,11 +0,0 @@
class CostQuery::Filter::OverriddenCosts < CostQuery::Filter::Base
label :field_overridden_costs
def self.available_operators
['y', 'n'].map { |s| s.to_operator }
end
def self.available_values(*)
[]
end
end

@ -1,35 +0,0 @@
class CostQuery::Filter::PermissionFilter < CostQuery::Filter::Base
dont_display!
not_selectable!
db_field ""
singleton
initialize_query_with { |query| query.filter self.to_s.demodulize.to_sym }
def permission_statement(permission)
User.current.allowed_for(permission).gsub(/(user|project)s?\.id/, '\1_id')
end
def permission_for(type)
"(#{permission_statement :"view_own_#{type}_entries"} " \
"OR #{permission_statement :"view_#{type}_entries"})"
end
def display_costs
"(#{permission_statement :view_hourly_rates} " \
"AND #{permission_statement :view_cost_rates}) " \
"OR " \
"(#{permission_statement :view_own_hourly_rate} " \
"AND type = 'TimeEntry')"
end
def sql_statement
super.tap do |query|
query.from.each_subselect do |sub|
sub.where permission_for(sub == query.from.first ? 'time' : 'cost')
sub.select.delete_if { |f| f.end_with? "display_costs" }
sub.select :display_costs => switch(display_costs => '1', :else => 0)
end
end
end
end

@ -1,9 +0,0 @@
class CostQuery::Filter::PriorityId < CostQuery::Filter::Base
join_table Issue
applies_for :label_issue_attributes
label :field_priority
def self.available_values(*)
IssuePriority.find(:all, :order => 'position DESC').map { |i| [i.name, i.id] }
end
end

@ -1,26 +0,0 @@
class CostQuery::Filter::ProjectId < CostQuery::Filter::Base
db_field "entries.project_id"
label :field_project
def self.available_operators
["=", "!", "=_child_projects", "!_child_projects"].map { |s| s.to_operator }
end
##
# Calculates the available values for this filter.
# Gives a map of [project_name, project_id, nesting_level_of_project].
# The map is sorted such that projects appear in alphabetical order within a nesting level
# and so that descendant projects appear after their ancestors.
def self.available_values(*)
map = []
ancestors = []
Project.visible.sort_by(&:lft).each do |project|
while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
ancestors.pop
end
map << [project.name, project.id, ancestors.size]
ancestors << project
end
map
end
end

@ -1,4 +0,0 @@
class CostQuery::Filter::SpentOn < CostQuery::Filter::Base
use :time_operators
label :label_spent_on_reporting
end

@ -1,6 +0,0 @@
class CostQuery::Filter::StartDate < CostQuery::Filter::Base
use :time_operators
join_table Issue
applies_for :label_issue_attributes
label :field_start_date
end

@ -1,10 +0,0 @@
class CostQuery::Filter::StatusId < CostQuery::Filter::Base
available_operators 'c', 'o'
join_table Issue, IssueStatus => [Issue, :status]
applies_for :label_issue_attributes
label :field_status
def self.available_values(*)
IssueStatus.find(:all, :order => 'name').map { |i| [i.name, i.id] }
end
end

@ -1,6 +0,0 @@
class CostQuery::Filter::Subject < CostQuery::Filter::Base
use :string_operators
join_table Issue
applies_for :label_issue_attributes
label :field_subject
end

@ -1,8 +0,0 @@
class CostQuery::Filter::Tmonth < CostQuery::Filter::Base
use :integer_operators
label :label_month_reporting
def self.available_values(*)
1.upto(12).map {|i| [ ::I18n.t('date.month_names')[i], i ]}
end
end

@ -1,9 +0,0 @@
class CostQuery::Filter::TrackerId < CostQuery::Filter::Base
join_table Issue
applies_for :label_issue_attributes
label :field_tracker
def self.available_values(*)
Tracker.find(:all, :order => 'name').map { |i| [i.name, i.id] }
end
end

@ -1,8 +0,0 @@
class CostQuery::Filter::Tweek < CostQuery::Filter::Base
use :integer_operators
label :label_week_reporting
def self.available_values(*)
1.upto(53).map {|i| [ i.to_s, i ]}
end
end

@ -1,8 +0,0 @@
class CostQuery::Filter::Tyear < CostQuery::Filter::Base
use :integer_operators
label :label_year_reporting
def self.available_values(*)
1970.upto(Date.today.year).map {|i| [ i.to_s, i ]}.reverse
end
end

@ -1,5 +0,0 @@
class CostQuery::Filter::UpdatedOn < CostQuery::Filter::Base
db_field "entries.updated_on"
use :time_operators
label :field_updated_on
end

@ -1,12 +0,0 @@
class CostQuery::Filter::UserId < CostQuery::Filter::Base
label :field_user
def self.available_values(*)
users = Project.visible.collect {|p| p.users}.flatten.uniq.sort
values = users.map { |u| [u.name, u.id] }
values.delete_if { |u| (u.first.include? "Redmine Admin") || (u.first.include? "Anonymous")}
values.sort!
values.unshift ["<< #{l(:label_me)} >>", User.current.id.to_s] if User.current.logged?
values
end
end

@ -1,36 +0,0 @@
require "set"
module CostQuery::GroupBy
def self.all
@all ||= Set[
CostQuery::GroupBy::ActivityId,
CostQuery::GroupBy::CostObjectId,
CostQuery::GroupBy::CostTypeId,
CostQuery::GroupBy::FixedVersionId,
CostQuery::GroupBy::IssueId,
CostQuery::GroupBy::PriorityId,
CostQuery::GroupBy::ProjectId,
CostQuery::GroupBy::SpentOn,
CostQuery::GroupBy::SingletonValue,
CostQuery::GroupBy::Tmonth,
CostQuery::GroupBy::TrackerId,
#CostQuery::GroupBy::Tweek,
CostQuery::GroupBy::Tyear,
CostQuery::GroupBy::UserId,
CostQuery::GroupBy::Week,
CostQuery::GroupBy::AuthorId,
CostQuery::GroupBy::AssignedToId,
CostQuery::GroupBy::CategoryId,
CostQuery::GroupBy::StatusId,
*CostQuery::GroupBy::CustomField.all
]
end
def self.all_grouped
all.group_by { |f| f.applies_for }.to_a.sort { |a,b| a.first.to_s <=> b.first.to_s }
end
def self.from_hash
raise NotImplementedError
end
end

@ -1,5 +0,0 @@
module CostQuery::GroupBy
class ActivityId < Base
label :field_activity
end
end

@ -1,7 +0,0 @@
module CostQuery::GroupBy
class AssignedToId < Base
join_table Issue
applies_for :label_issue_attributes
label :field_assigned_to
end
end

@ -1,7 +0,0 @@
module CostQuery::GroupBy
class AuthorId < Base
join_table Issue
applies_for :label_issue_attributes
label :field_author
end
end

@ -1,7 +0,0 @@
module CostQuery::GroupBy
class CategoryId < Base
join_table Issue
applies_for :label_issue_attributes
label :field_category
end
end

@ -1,7 +0,0 @@
module CostQuery::GroupBy
class CostObjectId < Base
join_table Issue
applies_for :label_issue_attributes
label :field_cost_object
end
end

@ -1,5 +0,0 @@
module CostQuery::GroupBy
class CostTypeId < Base
label :field_cost_type
end
end

@ -1,7 +0,0 @@
module CostQuery::GroupBy
class CustomField < Base
applies_for :label_issue_attributes
extend CostQuery::CustomFieldMixin
on_prepare { group_fields table_name }
end
end

@ -1,7 +0,0 @@
module CostQuery::GroupBy
class FixedVersionId < Base
join_table Issue
applies_for :label_issue_attributes
label :field_fixed_version
end
end

@ -1,5 +0,0 @@
module CostQuery::GroupBy
class IssueId < Base
label :field_issue
end
end

@ -1,7 +0,0 @@
module CostQuery::GroupBy
class PriorityId < Base
join_table Issue
applies_for :label_issue_attributes
label :field_priority
end
end

@ -1,5 +0,0 @@
module CostQuery::GroupBy
class ProjectId < Base
label :field_project
end
end

@ -1,5 +0,0 @@
module CostQuery::GroupBy
class SingletonValue < Base
dont_display!
end
end

@ -1,5 +0,0 @@
module CostQuery::GroupBy
class SpentOn < Base
label :label_spent_on_reporting
end
end

@ -1,7 +0,0 @@
module CostQuery::GroupBy
class StatusId < Base
join_table Issue
applies_for :label_issue_attributes
label :field_status
end
end

@ -1,5 +0,0 @@
module CostQuery::GroupBy
class Tmonth < Base
label :label_month_reporting
end
end

@ -1,7 +0,0 @@
module CostQuery::GroupBy
class TrackerId < Base
join_table Issue
applies_for :label_issue_attributes
label :field_tracker
end
end

@ -1,5 +0,0 @@
module CostQuery::GroupBy
class Tweek < Base
label :label_week_reporting
end
end

@ -1,5 +0,0 @@
module CostQuery::GroupBy
class Tyear < Base
label :label_year_reporting
end
end

@ -1,5 +0,0 @@
module CostQuery::GroupBy
class UserId < Base
label :field_user
end
end

@ -1,5 +0,0 @@
module CostQuery::GroupBy
class Week < Base
label :label_week_reporting
end
end

@ -1,80 +0,0 @@
require_dependency "time_entry"
require_dependency "cost_entry"
module Entry
[TimeEntry, CostEntry].each { |e| e.send :include, self }
class Delegator < ActiveRecord::Base
self.abstract_class = true
class << self
def ===(obj)
TimeEntry === obj or CostEntry === obj
end
def calculate(type, *args)
a, b = TimeEntry.calculate(type, *args), CostEntry.calculate(type, *args)
case type
when :sum, :count then a + b
when :avg then (a + b) / 2
when :min then [a, b].min
when :max then [a, b].max
else raise NotImplementedError
end
end
%[find_by_sql count_by_sql count sum].each do |meth|
define_method(meth) { |*args| find_all(meth, *args) }
end
undef_method :create, :update, :delete, :destroy, :new, :update_counters,
:increment_counter, :decrement_counter
%w[update_all destroy_all delete_all].each do |meth|
define_method(meth) { |*args| send_all(meth, *args) }
end
private
def find_initial(options) find_one :find_initial, options end
def find_last(options) find_one :find_last, options end
def find_every(options) find_many :find_every, options end
def find_from_ids(args, options) find_many :find_from_ids, options end
def find_one(*args)
TimeEntry.send(*args) || CostEntry.send(*args)
end
def find_many(*args)
TimeEntry.send(*args) + CostEntry.send(*args)
end
def send_all(*args)
[TimeEntry.send(*args), CostEntry.send(*args)]
end
end
end
def units
super
rescue NoMethodError
hours
end
def cost_type
super
rescue NoMethodError
end
def activity
super
rescue NoMethodError
end
def activity_id
super
rescue NoMethodError
end
def self.method_missing(*a, &b)
Delegator.send(*a, &b)
end
end

@ -1,7 +1,7 @@
require_dependency "entry"
require 'forwardable'
class CostQuery < ActiveRecord::Base
class Report < ActiveRecord::Base
InheritedNamespace.activate
extend Forwardable
include Enumerable
belongs_to :user
@ -10,8 +10,10 @@ class CostQuery < ActiveRecord::Base
before_save :serialize
serialize :serialized, Hash
self.abstract_class = true #lets have subclasses have their own SQL tables
def self.accepted_properties
@accepted_properties ||= []
@@accepted_properties ||= []
end
def self.chain_initializer
@ -31,15 +33,15 @@ class CostQuery < ActiveRecord::Base
end
def available_filters
CostQuery::Filter.all
self.class::Filter.all
end
def transformer
@transformer ||= CostQuery::Transformer.new self
@transformer ||= self.class::Transformer.new self
end
def walker
@walker ||= CostQuery::Walker.new self
@walker ||= self.class::Walker.new self
end
def add_chain(type, name, options)
@ -57,18 +59,18 @@ class CostQuery < ActiveRecord::Base
def build_new_chain
#FIXME: is there a better way to load all filter and groups?
Filter.all && GroupBy.all
self.class::Filter.all && self.class::GroupBy.all
minimal_chain!
self.class.chain_initializer.each { |block| block.call self }
end
def filter(name, options = {})
add_chain Filter, name, options
add_chain self.class::Filter, name, options
end
def group_by(name, options = {})
add_chain GroupBy, name, options.reverse_merge(:type => :column)
add_chain self.class::GroupBy, name, options.reverse_merge(:type => :column)
end
def column(name, options = {})
@ -80,7 +82,7 @@ class CostQuery < ActiveRecord::Base
end
def table
@table = Table.new(self)
@table = self.class::Table.new(self)
end
def group_bys
@ -99,7 +101,7 @@ class CostQuery < ActiveRecord::Base
def_delegators :transformer, :column_first, :row_first
def_delegators :chain, :empty_chain, :top, :bottom, :chain_collect, :sql_statement, :all_group_fields, :child, :clear, :result
def_delegators :result, :each_direct_result, :recursive_each, :recursive_each_with_level, :each, :each_row, :count,
:units, :real_costs, :size, :final_number
:units, :size, :final_number
def_delegators :table, :row_index, :colum_index
def to_a
@ -120,10 +122,14 @@ class CostQuery < ActiveRecord::Base
Digest::MD5.hexdigest(filter_string)
end
def == another_report
hash == another_report.hash
end
private
def minimal_chain!
@chain = Filter::NoFilter.new
@chain = self.class::Filter::NoFilter.new
end
end

@ -1,16 +1,16 @@
# Proviedes convinience layer and logic shared between GroupBy::Base and Filter::Base.
# Implements a dubble linked list (FIXME: is that the correct term?).
class CostQuery < ActiveRecord::Base
# Provides convinience layer and logic shared between GroupBy::Base and Filter::Base.
# Implements a double linked list (FIXME: is that the correct term?).
class Report < ActiveRecord::Base
class Chainable
include Enumerable
include CostQuery::QueryUtils
extend CostQuery::InheritedAttribute
include Report::QueryUtils
extend Report::InheritedAttribute
# this attr. should point to a symbol useable for translations
inherited_attribute :applies_for, :default => :label_cost_entry_attributes
def self.accepts_property(*list)
CostQuery.accepted_properties.push(*list.map(&:to_s))
engine.accepted_properties.push(*list.map(&:to_s))
end
def self.chain_list(*list)
@ -21,7 +21,9 @@ class CostQuery < ActiveRecord::Base
end
def self.base?
superclass == Chainable or self == Chainable
superclass == engine::Chainable or self == engine::Chainable or
superclass == Chainable or self == Chainable or
self == engine::Filter::Base or self == engine::GroupBy::Base
end
def self.base
@ -62,15 +64,15 @@ class CostQuery < ActiveRecord::Base
end
##
# The given block is called when a new chain is created for a cost_query.
# The given block is called when a new chain is created for a report.
# The query will be given to the block as a parameter.
# Example:
# initialize_query_with { |query| query.filter CostQuery::Filter::City, :operators => '=', :values => 'Berlin, da great City' }
# initialize_query_with { |query| query.filter Report::Filter::City, :operators => '=', :values => 'Berlin, da great City' }
def self.initialize_query_with(&block)
CostQuery.chain_initializer.push block
engine.chain_initializer.push block
end
inherited_attribute :label
inherited_attribute :label, :default => :translation_needed
inherited_attribute :properties, :list => true
class << self
@ -124,7 +126,7 @@ class CostQuery < ActiveRecord::Base
@options = options
options.each do |key, value|
unless self.class.extra_options.include? key
raise ArgumentError, "may not set #{key}" unless CostQuery.accepted_properties.include? key.to_s
raise ArgumentError, "may not set #{key}" unless engine.accepted_properties.include? key.to_s
send "#{key}=", value if value
end
end
@ -198,7 +200,7 @@ class CostQuery < ActiveRecord::Base
end
def compute_result
Result.new ActiveRecord::Base.connection.select_all(sql_statement.to_s), {}, type
engine::Result.new ActiveRecord::Base.connection.select_all(sql_statement.to_s), {}, type
end
def table_joins
@ -266,7 +268,7 @@ class CostQuery < ActiveRecord::Base
end
def self.last_table
@last_table ||= 'entries'
@last_table ||= engine::Filter::NoFilter.table_name
end
def self.table_name(value = nil)
@ -291,4 +293,4 @@ class CostQuery < ActiveRecord::Base
end
end
end
end

@ -0,0 +1,15 @@
require "set"
class Report::Filter
def self.all
@all ||= Set[]
end
def self.all_grouped
all.group_by { |f| f.applies_for }.to_a.sort { |a,b| a.first.to_s <=> b.first.to_s }
end
def self.from_hash
raise NotImplementedError
end
end

@ -1,6 +1,8 @@
module CostQuery::Filter
class Base < CostQuery::Chainable
CostQuery::Operator.load
class Report::Filter
class Base < Report::Chainable
include Report::QueryUtils
engine::Operator.load
inherited_attribute :available_operators,
:list => true, :map => :to_operator,
@ -30,8 +32,8 @@ module CostQuery::Filter
names.each do |name|
dont_inherit :available_operators if skip_inherited_operators.include? name
case name
when String, CostQuery::Operator then operators << name.to_operator
when Symbol then operators.push(*CostQuery::Operator.send(name))
when String, engine::Operator then operators << name.to_operator
when Symbol then operators.push(*engine::Operator.send(name))
else fail "dunno what to do with #{name.inspect}"
end
end
@ -54,12 +56,12 @@ module CostQuery::Filter
super
end
def self.available_values(user)
def self.available_values(params = {})
raise NotImplementedError, "subclass responsibility"
end
def correct_position?
child.nil? or child.is_a? CostQuery::Filter::Base
child.nil? or child.filter?
end
def from_for(scope)
@ -82,8 +84,9 @@ module CostQuery::Filter
[]
end
def initialze(child = nil, options = {})
raise ArgumentError, "Child has to be a Filter." if child and not child.filter?
def initialize(child = nil, options = {})
# TODO: wtf?
#raise ArgumentError, "Child has to be a Filter." if child and not child.filter?
@values = []
super
end
@ -93,7 +96,7 @@ module CostQuery::Filter
end
def operator
(@operator || self.class.default_operator || CostQuery::Operator.default_operator).to_operator
(@operator || self.class.default_operator || engine::Operator.default_operator).to_operator
end
def operator=(value)
@ -119,4 +122,4 @@ module CostQuery::Filter
end
end
end
end
end

@ -0,0 +1,9 @@
class Report::Filter::NoFilter < Report::Filter::Base
table_name "entries"
dont_display!
singleton
def sql_statement
raise NotImplementedError, "My subclass should have overwritten 'sql_statement'"
end
end

@ -0,0 +1,17 @@
require "set"
class Report::GroupBy
include Report::QueryUtils
def self.all
Set[engine::GroupBy::SingletonValue]
end
def self.all_grouped
all.group_by { |f| f.applies_for }.to_a.sort { |a,b| a.first.to_s <=> b.first.to_s }
end
def self.from_hash
raise NotImplementedError
end
end

@ -1,5 +1,7 @@
module CostQuery::GroupBy
class Base < CostQuery::Chainable
class Report::GroupBy
class Base < Report::Chainable
include Report::QueryUtils
inherited_attributes :group_fields, :list => true, :merge => false
def self.inherited(klass)
@ -8,7 +10,7 @@ module CostQuery::GroupBy
end
def correct_position?
type == :row or !child.is_a?(CostQuery::GroupBy::Base) or child.type == :column
type == :row or !child.is_a?(engine::GroupBy::Base) or child.type == :column
end
def filter?
@ -25,7 +27,8 @@ module CostQuery::GroupBy
def all_group_fields(prefix = true)
@all_group_fields ||= []
@all_group_fields[prefix ? 0 : 1] ||= begin
(parent ? parent.all_group_fields(prefix) : []) + (prefix ? with_table(group_fields) : group_fields)
fields = group_fields.reject { |c| c.blank? or c == 'base' }
(parent ? parent.all_group_fields(prefix) : []) + (prefix ? with_table(fields) : fields)
end.uniq
end
@ -35,8 +38,8 @@ module CostQuery::GroupBy
end
def aggregation_mixin
sql_aggregation? ? SqlAggregation : RubyAggregation
end
sql_aggregation? ? engine::GroupBy::SqlAggregation : engine::GroupBy::RubyAggregation
end
def initialize(child = nil, optios = {})
super

@ -1,11 +1,11 @@
module CostQuery::GroupBy
class Report::GroupBy
module RubyAggregation
def responsible_for_sql?
false
end
##
# @return [CostQuery::Result] aggregation
# @return [Report::Result] aggregation
def compute_result
child.result.grouped_by(all_group_fields(false), type, group_fields)
end

@ -0,0 +1,10 @@
class Report::GroupBy
class SingletonValue < Base
dont_display!
def define_group(sql)
sql.select "1 as singleton_value"
sql.group_by "singleton_value"
end
end
end

@ -1,4 +1,4 @@
module CostQuery::GroupBy
class Report::GroupBy
module SqlAggregation
def responsible_for_sql?
true
@ -11,7 +11,6 @@ module CostQuery::GroupBy
def sql_statement
super.tap do |sql|
define_group sql
sql.sum :units => :units, :real_costs => :real_costs, :display_costs => :display_costs
sql.count
end
end

@ -1,6 +1,6 @@
require 'set'
module CostQuery::InheritedAttribute
module Report::InheritedAttribute
def inherited_attribute(*attributes)
options = attributes.extract_options!
list = options[:list]

@ -0,0 +1,63 @@
module Report::InheritedNamespace
NESTED_NAMESPACES = %w[Validation Filter GroupBy Result Operator QueryUtils]
module Hook
def const_missing(name, *)
super
rescue ArgumentError => error
# require 'ruby-debug'; debugger
rescue NameError => error
load_constant name, error
end
end
def self.activate
Report.extend self
NESTED_NAMESPACES.each { |n| n.extend self }
end
def inherited(klass)
super
propagate klass
end
def included(klass)
super
propagate klass
end
def propagate(klass)
klass.extend Report::InheritedNamespace
klass.extend Hook
return unless klass < Report
NESTED_NAMESPACES.each do |name|
if file = ActiveSupport::Dependencies.search_for_file("#{klass.name}::#{name}".underscore)
require_or_load file
propagate klass.const_get(name)
else
const_missing name
end
end
end
def load_constant(name, error = NameError)
zuper = (Class === self ? superclass : ancestors.second).const_get(name)
klass = case zuper
when Class then const_set name, Class.new(zuper)
when Module then const_set name, Module.new { include zuper }
else const_set name, zuper
end
propagate klass
klass
rescue NameError, ArgumentError => new_error
if file = ActiveSupport::Dependencies.search_for_file("#{self.name}::#{name}".underscore)
require_or_load file
const_get name
else
error.message << "\n\tWas #{new_error.class}: #{new_error.message}"
new_error.backtrace[0..9].each { |l| error.message << "\n\t\t#{l}" }
error.message << "\n\t\t..." if new_error.backtrace.size > 10
raise error
end
end
end

@ -1,6 +1,6 @@
class CostQuery::Operator
include CostQuery::QueryUtils
include CostQuery::Validation
class Report::Operator
include Report::QueryUtils
include Report::Validation
#############################################################################################
# Wrapped so we can place this at the top of the file.
@ -22,19 +22,19 @@ class CostQuery::Operator
end
def label
@label ||= Query.operators[name]
@label ||= self.class.name.to_sym
end
end
# Operators from Redmine
new ">t-" do
new ">t-", :label => :label_less_than_ago do
include DateRange
def modify(query, field, value)
super query, field, -value.to_i, 0
end
end
new "w", :arity => 0 do
new "w", :arity => 0, :label => :label_this_week do
def modify(query, field, offset = nil)
offset ||= 0
from = Time.now.at_beginning_of_week - ((l(:general_first_day_of_week).to_i % 7) + 1).days
@ -43,16 +43,16 @@ class CostQuery::Operator
end
end
new "t+" do
new "t+", :label => :label_in do
include DateRange
def modify(query, field, *values)
super query, field, values.first.to_i, values.first.to_i
end
end
new "<="
new "<=", :label => :label_less_or_equal
new "!" do
new "!", :label => :label_not_equals do
def modify(query, field, *values)
where_clause = "(#{field} IS NULL"
where_clause += " OR #{field} NOT IN #{collection(*values)}" unless values.compact.empty?
@ -62,30 +62,14 @@ class CostQuery::Operator
end
end
new "t-" do
new "t-", :label => :label_ago do
include DateRange
def modify(query, field, *values)
super query, field, -values.first.to_i, -values.first.to_i
end
end
new "c", :arity => 0 do
def modify(query, field, *values)
raise "wrong field" if field.to_s.split('.').last != "status_id"
query.where "(#{IssueStatus.table_name}.is_closed = #{quoted_true})"
query
end
end
new "o", :arity => 0 do
def modify(query, field, *values)
raise "wrong field" if field.to_s.split('.').last != "status_id"
query.where "(#{IssueStatus.table_name}.is_closed = #{quoted_false})"
query
end
end
new "!~", :arity => 1 do
new "!~", :arity => 1, :label => :label_not_contains do
def modify(query, field, *values)
value = values.first || ''
query.where "LOWER(#{field}) NOT LIKE '%#{quote_string(value.to_s.downcase)}%'"
@ -93,7 +77,7 @@ class CostQuery::Operator
end
end
new "=" do
new "=", :label => :label_equals do
def modify(query, field, *values)
if values.compact.empty?
query.where "1=0"
@ -104,7 +88,7 @@ class CostQuery::Operator
end
end
new "~", :arity => 1 do
new "~", :arity => 1, :label => :label_contains do
def modify(query, field, *values)
value = values.first || ''
query.where "LOWER(#{field}) LIKE '%#{quote_string(value.to_s.downcase)}%'"
@ -112,39 +96,39 @@ class CostQuery::Operator
end
end
new "<t+" do
new "<t+", :label => :label_in_less_than do
include DateRange
def modify(query, field, value)
super query, field, 0, value.to_i
end
end
new "t" do
new "t", :label => :label_today do
include DateRange
def modify(query, field)
super query, field, 0, 0
end
end
new ">="
new ">=", :label => :label_greater_or_equal
new "!*", :arity => 0, :where_clause => "%s IS NULL"
new "!*", :arity => 0, :where_clause => "%s IS NULL", :label => :label_none
new "<t-" do
new "<t-", :label => :label_more_than_ago do
include DateRange
def modify(query, field, value)
super query, field, nil, -value.to_i
end
end
new ">t+" do
new ">t+", :label => :label_in_more_than do
include DateRange
def modify(query, field, value)
super query, field, value.to_i, nil
end
end
new "*", :arity => 0, :where_clause => "%s IS NOT NULL"
new "*", :arity => 0, :where_clause => "%s IS NOT NULL", :label => :label_all
# Our own operators
new "<", :label => :label_less
@ -190,30 +174,6 @@ class CostQuery::Operator
end
end
new "=_child_projects", :validate => :integers, :label => :label_is_project_with_subprojects do
def modify(query, field, *values)
p_ids = []
values.each do |value|
p_ids += ([value] << Project.find(value).descendants.map{ |p| p.id })
end
"=".to_operator.modify query, field, p_ids
rescue ActiveRecord::RecordNotFound
query
end
end
new "!_child_projects", :validate => :integers, :label => :label_is_not_project_with_subprojects do
def modify(query, field, *values)
p_ids = []
values.each do |value|
p_ids += ([value] << Project.find(value).descendants.map{ |p| p.id })
end
"!".to_operator.modify query, field, p_ids
rescue ActiveRecord::RecordNotFound
query
end
end
end
#############################################################################################
@ -221,7 +181,7 @@ class CostQuery::Operator
::String.send :include, self
::Symbol.send :include, self
def to_operator
CostQuery::Operator.find self
Report::Operator.find self
end
end
@ -229,8 +189,9 @@ class CostQuery::Operator
all[name.to_s] ||= super
end
#TODO: this should be inheritable by subclasses
def self.all
@all ||= {}
@@all_operators ||= {}
end
def self.load
@ -240,7 +201,7 @@ class CostQuery::Operator
end
def self.find(name)
all[name.to_s] or raise ArgumentError, "Operator not defined"
all[name.to_s] or raise ArgumentError, "Operator #{name.inspect} not defined"
end
def self.defaults(&block)

@ -1,7 +1,16 @@
module CostQuery::QueryUtils
include Redmine::I18n
module Report::QueryUtils
delegate :quoted_false, :quoted_true, :to => "ActiveRecord::Base.connection"
##
# Subclass of Report to be used for constant lookup and such.
# It is considered public API to override this method i.e. in Tests.
#
# @return [Class] subclass
def engine
return self.class.engine unless is_a? Module
Object.const_get(name[/^[^:]+/] || :Report)
end
##
# Graceful string quoting.
#
@ -13,23 +22,27 @@ module CostQuery::QueryUtils
end
##
# Graceful, internationalized quoted string.
# Creates a SQL fragment representing a collection/array.
#
# @see quote_string
# @param [Object] str String to quote/translate
# @return [Object] Quoted, translated version
def quoted_label(ident)
"'#{quote_string l(ident)}'"
# @param [#flatten] *values Ruby collection
# @return [String] SQL collection
def collection(*values)
if values.empty?
""
else
"(#{values.flatten.map { |v| "'#{quote_string(v)}'" }.join ", "})"
end
end
##
# Creates a SQL fragment representing a collection/array.
# Graceful, internationalized quoted string.
#
# @see quote_string
# @param [#flatten] *values Ruby collection
# @return [String] SQL collection
def collection(*values)
"(#{values.flatten.map { |v| "'#{quote_string(v)}'" }.join ", "})"
# @param [Object] str String to quote/translate
# @return [Object] Quoted, translated version
def quoted_label(ident)
"'#{quote_string l(ident)}'"
end
def quoted_date(date)
@ -75,6 +88,7 @@ module CostQuery::QueryUtils
# @return [String] Field name.
def field_name_for(arg, default_table = nil)
return 'NULL' unless arg
return field_name_for(arg.keys.first, default_table) if arg.is_a? Hash
return arg if arg.is_a? String and arg =~ /\.| |\(.*\)/
return table_name_for(arg.first || default_table) + '.' << arg.last.to_s if arg.is_a? Array and arg.size == 2
return arg.to_s unless default_table
@ -88,10 +102,11 @@ module CostQuery::QueryUtils
# @param [Object] statement Not sanitized statement.
# @return [String] Sanitized statement.
def sanitize_sql_for_conditions(statement)
CostQuery.send :sanitize_sql_for_conditions, statement
engine.send :sanitize_sql_for_conditions, statement
end
##
# FIXME: This is redmine
# Generates string representation for a currency.
#
# @see CostRate.clean_currency
@ -170,29 +185,20 @@ module CostQuery::QueryUtils
end
end
def adapter_name
ActiveRecord::Base.connection.adapter_name.downcase.to_sym
end
def map_field(key, value)
case key.to_s
when "user_id" then value ? user_name(value.to_i) : ''
when "tweek", "tyear", "tmonth", /_id$/ then value.to_i
when "week" then value.to_i.divmod(100)
when /_(on|at)$/ then value ? Time.parse(value) : Time.at(0)
when /^custom_field/ then value.to_s
when "singleton_value" then value.to_i
else fail "add mapping for #{key}"
if key.to_s == "singleton_value"
value.to_i
else
value.to_s
end
end
def user_name(id)
# we have no identity map... :(
cache[:user_name][id] ||= User.find(id).name
def adapter_name
ActiveRecord::Base.connection.adapter_name.downcase.to_sym
end
def cache
CostQuery::QueryUtils.cache
Report::QueryUtils.cache
end
def mysql?

@ -1,11 +1,11 @@
module CostQuery::Result
class Report::Result
class Base
attr_accessor :parent, :type, :important_fields
attr_accessor :key
attr_reader :value
alias values value
include Enumerable
include CostQuery::QueryUtils
include Report::QueryUtils
def initialize(value)
@important_fields ||= []
@ -41,16 +41,16 @@ module CostQuery::Result
fields.inject({}) { |hash, key| hash.merge key => entry.fields[key] }
end
# map group back to array, all fields with same key get grouped into one list
data.keys.map { |f| CostQuery::Result.new data[f], f, type, important_fields }
data.keys.map { |f| engine::Result.new data[f], f, type, important_fields }
end
end
# create a single result from that list
CostQuery::Result.new list, {}, type, important_fields
engine::Result.new list, {}, type, important_fields
end
def inspect
"<##{self.class}: @fields=#{fields.inspect} @type=#{type.inspect} " \
"@size=#{size} @count=#{count} @units=#{units} @real_costs=#{real_costs}>"
"@size=#{size} @count=#{count} @units=#{units}>"
end
def row?
@ -105,9 +105,6 @@ module CostQuery::Result
self.key = index.map { |k| map_field(k, fields[k]) }
end
def display_costs?
display_costs > 0
end
end
class DirectResult < Base
@ -117,10 +114,6 @@ module CostQuery::Result
false
end
def display_costs
self["display_costs"].to_i
end
def count
self["count"].to_i
end
@ -129,10 +122,6 @@ module CostQuery::Result
self["units"].to_d
end
def real_costs
(self["real_costs"] || 0).to_d if display_costs? # FIXME: default value here?
end
##
# @return [Integer] Number of child results
def size
@ -181,18 +170,10 @@ module CostQuery::Result
sum_for :count
end
def display_costs
(sum_for :display_costs) >= 1 ? 1 : 0
end
def units
sum_for :units
end
def real_costs
sum_for :real_costs if display_costs?
end
def sum_for(field)
@sum_for ||= {}
@sum_for[field] ||= sum { |v| v.send(field) || 0 }

@ -1,4 +1,4 @@
class CostQuery::SqlStatement
class Report::SqlStatement
class Union
attr_accessor :first, :second, :as
def initialize(first, second, as = nil)
@ -20,14 +20,11 @@ class CostQuery::SqlStatement
end
end
include CostQuery::QueryUtils
attr_accessor :desc
include Report::QueryUtils
COMMON_FIELDS = %w[
user_id project_id issue_id rate_id
comments spent_on created_on updated_on tyear tmonth tweek
costs overridden_costs type
]
##
# Describes the query. This may be used in a sql-comment later.
attr_accessor :desc
##
# Generates new SqlStatement.
@ -37,87 +34,10 @@ class CostQuery::SqlStatement
from table
end
##
# Generates SqlStatement that maps time_entries and cost_entries to a common structure.
#
# Mapping for direct fields:
#
# Result | Time Entires | Cost entries
# --------------------------|--------------------------|--------------------------
# id | id | id
# user_id | user_id | user_id
# project_id | project_id | project_id
# issue_id | issue_id | issue_id
# rate_id | rate_id | rate_id
# comments | comments | comments
# spent_on | spent_on | spent_on
# created_on | created_on | created_on
# updated_on | updated_on | updated_on
# tyear | tyear | tyear
# tmonth | tmonth | tmonth
# tweek | tweek | tweek
# costs | costs | costs
# overridden_costs | overridden_costs | overridden_costs
# units | hours | units
# activity_id | activity_id | -1
# cost_type_id | -1 | cost_type_id
# type | "TimeEntry" | "CostEntry"
# count | 1 | 1
#
# Also: This _should_ handle joining activities and cost_types, as the logic differs for time_entries
# and cost_entries.
#
# @param [#table_name] model The model to map
# @return [CostQuery::SqlStatement] Generated statement
def self.unified_entry(model)
table = table_name_for model
new(table).tap do |query|
query.select COMMON_FIELDS
query.desc = "Subquery for #{table}"
query.select({
:count => 1, :id => [model, :id], :display_costs => 1,
:real_costs => switch("#{table}.overridden_costs IS NULL" => [model, :costs], :else => [model, :overridden_costs]),
:week => iso_year_week(:spent_on, model),
:singleton_value => 1
})
#FIXME: build this subquery from a sql_statement
query.from "(SELECT *, #{typed :text, model.model_name} AS type FROM #{table}) AS #{table}"
send("unify_#{table}", query)
end
end
##
# Applies logic for mapping time entries to general entries structure.
#
# @param [CostQuery::SqlStatement] query The statement to adjust
def self.unify_time_entries(query)
query.select :activity_id, :units => :hours, :cost_type_id => -1
query.select :cost_type => quoted_label(:caption_labor)
end
##
# Applies logic for mapping cost entries to general entries structure.
#
# @param [CostQuery::SqlStatement] query The statement to adjust
def self.unify_cost_entries(query)
query.select :units, :cost_type_id, :activity_id => -1
query.select :cost_type => "cost_types.name"
query.join CostType
end
##
# Generates a statement based on all entries (i.e. time entries and cost entries) mapped to the general entries structure,
# and therefore usable by filters and such.
#
# @return [CostQuery::SqlStatement] Generated statement
def self.for_entries
new unified_entry(TimeEntry).union(unified_entry(CostEntry), "entries")
end
##
# Creates a uninon of the caller and the callee.
#
# @param [CostQuery::SqlStatement] other Second part of the union
# @param [Report::SqlStatement] other Second part of the union
# @return [String] The sql query.
def union(other, as = nil)
Union.new(self, other, as)
@ -187,7 +107,7 @@ class CostQuery::SqlStatement
# @overload where(fields)
# Adds condition to where clause
# @param [Array, Hash, String] fields Parameters passed to sanitize_sql_for_conditions.
# @see CostQuery::QueryUtils#sanitize_sql_for_conditions
# @see Report::QueryUtils#sanitize_sql_for_conditions
def where(fields = nil)
@where ||= ["1=1"]
unless fields.nil?
@ -230,6 +150,11 @@ class CostQuery::SqlStatement
end
end
def default_select(value = nil)
@default_select = value if value
@default_select ||= ["*"]
end
##
# @overload select
# @return [Array<String>] All fields/statements for select part
@ -245,7 +170,7 @@ class CostQuery::SqlStatement
# @param [Array, Hash, String, Symbol, SqlStatement] fields Fields to add to select part
# @return [Array<String>] All fields/statements for select part
def select(*fields)
return(@select || ["*"]) if fields.empty?
return(@select || default_select) if fields.empty?
returning(@select ||= []) do
@sql = nil
fields.each do |f|
@ -256,7 +181,7 @@ class CostQuery::SqlStatement
end
when Hash then select f.map { |k,v| "#{field_name_for v} as #{field_name_for k}" }
when String, Symbol then @select << field_name_for(f)
when CostQuery::SqlStatement then @select << f.to_s
when engine::SqlStatement then @select << f.to_s
else raise ArgumentError, "cannot handle #{f.inspect}"
end
end

@ -1,9 +1,9 @@
# encoding: UTF-8
require 'enumerator'
class CostQuery::Table
class Report::Table
attr_accessor :query
include CostQuery::QueryUtils
include Report::QueryUtils
def initialize(query)
@query = query

@ -1,5 +1,5 @@
# encoding: UTF-8
class CostQuery::Transformer
class Report::Transformer
attr_reader :query
def initialize(query)
@ -7,15 +7,15 @@ class CostQuery::Transformer
end
##
# @return [CostQuery::Result::Base] Result tree with row group bys at the top
# @see CostQuery::Chainable#result
# @return [Report::Result::Base] Result tree with row group bys at the top
# @see Report::Chainable#result
def row_first
@row_first ||= query.result
end
##
# @return [CostQuery::Result::Base] Result tree with column group bys at the top
# @see CostQuery::Walker#row_first
# @return [Report::Result::Base] Result tree with column group bys at the top
# @see Report::Walker#row_first
def column_first
@column_first ||= begin
# reverse since we fake recursion ↓↓↓

@ -1,4 +1,4 @@
module CostQuery::Validation
module Report::Validation
def register_validations(*validation_methods)
validation_methods.flatten.each do |val_method|
register_validation(val_method)
@ -8,7 +8,7 @@ module CostQuery::Validation
def register_validation(val_method)
const_name = val_method.to_s.camelize
begin
val_module = CostQuery::Validation.const_get const_name
val_module = Report::Validation.const_get const_name
metaclass.send(:include, val_module)
val_method = "validate_" + val_method.to_s.pluralize
if method(val_method)
@ -17,7 +17,7 @@ module CostQuery::Validation
warn "#{val_module.name} does not define #{val_method}"
end
rescue NameError
warn "No Module CostQuery::Validation::#{const_name} found to validate #{val_method}"
warn "No Module Report::Validation::#{const_name} found to validate #{val_method}"
end
self
end

@ -1,4 +1,4 @@
module CostQuery::Validation
module Report::Validation
module Dates
def validate_dates(*values)
values = values.flatten

@ -1,4 +1,4 @@
module CostQuery::Validation
module Report::Validation
module Integers
def validate_integers(*values)
values = values.flatten

@ -1,4 +1,4 @@
module CostQuery::Validation
module Report::Validation
module Sql
def validate_sql(values = [])
raise NotImplementedError, "Haven't done SQL validation just yet!"

@ -1,4 +1,4 @@
class CostQuery::Walker
class Report::Walker
attr_accessor :query, :header_stack
def initialize(query)
@query = query

@ -1,60 +0,0 @@
<% list = [:spent_on, :user_id, :activity_id, :issue_id, :comments, :project_id] %>
<table class="report detail-report" id="sortable-table">
<thead>
<tr>
<% list.each do |field| %><th><%= label_for(field) %></th><% end %>
<th class='right'><%= @cost_type.try(:unit_plural) || l(:units) %></th>
<th class="right"><%= l(:field_costs) %></th>
<th></th>
</tr>
</thead>
<tfoot>
<tr>
<% if show_result(@query, 0) != show_result(@query) %>
<th class="inner right" colspan='<%= list.size + 1 %>'>
<%= show_result @query %>
</th>
<th class="result right"><%= show_result @query, 0 %></th>
<% else %>
<th class="result right" colspan='<%= list.size + 2 %>'><%= show_result @query %></th>
<% end %>
<th class="unsortable"></th>
</tr>
</tfoot>
<tbody>
<% @query.each_direct_result do |result| %>
<tr class='<%= cycle("odd", "even") %>'>
<% list.each do |field| %>
<td
raw-data="<%= raw_field(field, result.fields[field.to_s]) -%>"
class="left">
<%= show_field field, result.fields[field.to_s] %>
</td>
<% end %>
<td class="units right" raw-data="<%= result.units -%>"><%= show_result(result, result.fields['cost_type_id'].to_i) %></td>
<td class="currency right" raw-data="<%= result.real_costs -%>"><%= show_result(result, 0) %></td>
<td style="width: 40px">
<% with_project(result.fields['project_id']) do %>
<% if entry_for(result).editable_by? User.current %>
<%= link_to image_tag('edit.png'),
action_for(result, :action => 'edit'), :title => l(:button_edit) %>
<%= link_to image_tag('delete.png'), action_for(result, :action => 'destroy'),
:title => l(:button_edit), :confirm => l(:text_are_you_sure),
:method => :post, :title => l(:button_delete) %>
<% end %>
<% end %>
</td>
</tr>
<% if params[:debug] %>
<tr>
<td colspan='<%= list.size + 3 %>'>
<%= result.fields.reject {|k,v| list.include? k.to_sym }.inspect %>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
<%= render :partial => 'sortable_init', :locals => { :sort_first_row => true } %>

@ -1,102 +0,0 @@
<%
walker.for_final_row do |row, cells|
html = "<th class='normal inner left'>#{show_row row}#{debug_fields(row)}</th>"
html << cells.join
html << "<th class='normal inner right'>#{show_result(row)}#{debug_fields(row)}</th>"
end
walker.for_row do |row, subrows|
subrows.flatten!
unless row.fields.empty?
subrows[0] = <<-HTML
<th class='top left' rowspan='#{subrows.size}'>#{show_row row}#{debug_fields(row)}</th>
#{subrows[0].gsub("class='normal", "class='top")}
<th class='top right' rowspan='#{subrows.size}'>#{show_result(row)}#{debug_fields(row )}</th>
HTML
end
subrows.last.gsub!("class='normal", "class='bottom")
subrows.last.gsub!("class='top", "class='bottom top")
subrows
end
walker.for_empty_cell { "<td class='normal empty'>&nbsp;</td>" }
walker.for_cell do |result|
"<td class='normal'>#{link_to_details(result)}#{show_result result}#{debug_fields(result)}</td>"
end
%>
<table class='report'>
<thead>
<% walker.headers do |list, first, first_in_col, last_in_col| %>
<%= '<tr>' if first_in_col %>
<%= "<th rowspan='#{query.depth_of(:column)}' colspan='#{query.depth_of(:row)}'></th>" if first %>
<% list.each do |column| %>
<th colspan="<%= column.final_number(:column) %>" class="<%= "inner" if column.final? :column %>">
<%= show_row column %>
</th>
<% end %>
<%= "<th rowspan='#{@query.depth_of(:column)}' colspan='#{query.depth_of(:row)}'></th>" if first %>
<%= '</tr>' if last_in_col %>
<% end %>
</thead>
<tfoot>
<% walker.reverse_headers do |list, first, first_in_col, last_in_col| %>
<% if first_in_col %>
<tr>
<%= "<th rowspan='#{query.depth_of(:column)}' colspan='#{query.depth_of(:row)}' class='top'>&nbsp;</th>" if first %>
<% end %>
<% list.each do |column| %>
<th colspan="<%= column.final_number(:column) %>" class="<%= "inner" if first %>">
<%= show_result column %><%= debug_fields(column) %>
</th>
<% end %>
<% if last_in_col %>
<% if first %>
<th rowspan='<%= @query.depth_of(:column) %>' colspan='<%= query.depth_of(:row) %>' class='top result'>
<%= show_result query %>
</th>
<% end %>
</tr>
<% end %>
<% end %>
</tfoot>
<tbody>
<% first = true %>
<% walker.body do |line| %>
<%
if first
line.gsub!("class='normal", "class='top")
first = false
end
%>
<tr class='<%= cycle("odd", "even") %>'><%= line %></tr>
<% end %>
</tbody>
</table>
<% if false or params[:debug] %>
<pre>
[ Query ]
<% query.chain.each do |child| %>
- <%= h child.class.inspect %>, <%= h child.type %>
<% end %>
[ RESULT ]
<% query.result.recursive_each_with_level do |level, result| %>
<%= ">>> " * (level+1) %><%= h result.inspect %>,
<%= " " * (level+1) %><%= h result.type.inspect %>,
<%= " " * (level+1) %><%= h result.fields.inspect %>
<% end %>
[ HEADER STACK ]
<% walker.header_stack.each do |l| %>
<%= ">>> #{l.inspect}" %>
<% end %>
</pre>
<% end %>

@ -1,39 +0,0 @@
<%#
This partial requires the following locals:
f An ActionView::Helpers::FormBuilder
query A CostQuery object
%>
<%= javascript_include_tag "reporting", :plugin => "redmine_reporting" %>
<% grouped_filters = CostQuery::Filter.all_grouped.sort_by { |label, group_by_ary| l(label) } %>
<% partial_prefix = File.join(File.basename(File.dirname(__FILE__)), 'filters') %>
<table id="filter_table">
<% grouped_filters.each do |label, filter_ary| %>
<tr id="tr_<%= label.to_s %>" style="display:none"><td><h3><%= l(label) %></h3></td></tr>
<% filter_ary.sort_by { |f| l(f.label)}.each do |filter| %>
<% next unless filter.display? %>
<tr id="tr_<%= filter.underscore_name %>" class="filter" style="display:none" data-label="tr_<%= label.to_s %>">
<% html_elements(filter).each do |element| %>
<%= render :partial => File.join(partial_prefix, element[:name].to_s),
:locals => {:element => element, :f => f, :filter => filter, :query => query} %>
<% end %>
</tr>
<% end %>
<% end %>
</table>
<div id="add_filter_block">
<select onchange="add_filter(this);" id="add_filter_select" class="select-small">
<option value="">-- <%= l(:label_filter_add) %> --</option>
<% grouped_filters.each do |label, filter_ary| %>
<optgroup label="<%= l(label) %>">
<% filter_ary.sort_by { |f| l(f.label)}.each do |filter| %>
<% next unless filter.selectable? %>
<option value="<%= filter.underscore_name %>"><%= l(filter.label) %></option>
<% end %>
</optgroup>
<% end %>
</select>
</div>

@ -1,74 +0,0 @@
<%#
This partial requires the following locals:
f An ActionView::Helpers::FormBuilder
query A CostQuery object
%>
<%#TODO: replace me with a drag&drop group_by selector %>
<table style="border-collapse: collapse; border: 0pt none;" id="group_by_table">
<tbody>
<tr>
<td colspan="2" rowspan="2">&nbsp;</td>
<td>&nbsp;</td>
<td><h3>Columns</h3><td>
</tr>
<tr>
<td align="center" valign="top">
<input type="button" class="buttons group_by sort sortUp" onclick="moveOptionUp(this.form.group_by_columns);"/><br />
<input type="button" class="buttons group_by sort sortDown" onclick="moveOptionDown(this.form.group_by_columns);"/>
</td>
<td>
<select style="width: 180px;" size="4" name="groups[columns][]" multiple="multiple" id="group_by_columns">
</select>
</td>
</tr>
<tr>
<td>
&nbsp;
</td>
<td valign="bottom" style="padding-bottom: 0;">
<h3>Rows</h3>
</td>
<td>
&nbsp;
</td>
<td align="left" valign="top">
<input type="button" class="buttons group_by move moveUp" onclick="moveOptions(this.form.group_by_container, this.form.group_by_columns);"/>
<input type="button" class="buttons group_by move moveDown" onclick="moveOptions(this.form.group_by_columns, this.form.group_by_container);"/>
</td>
</tr>
<tr>
<td align="center" valign="top">
<input type="button" class="buttons group_by sort sortUp" onclick="moveOptionUp(this.form.group_by_rows);" /><br />
<input type="button" class="buttons group_by sort sortDown" onclick="moveOptionDown(this.form.group_by_rows);"/>
</td>
<td style="padding-left: 0pt;" valign="top">
<select style="width: 180px;" size="4" name="groups[rows][]" multiple="multiple" id="group_by_rows">
</select>
</td>
<td align="center" valign="top">
<input type="button" class="buttons group_by move moveLeft" onclick="moveOptions(this.form.group_by_container, this.form.group_by_rows);"/><br />
<input type="button" class="buttons group_by move moveRight" onclick="moveOptions(this.form.group_by_rows, this.form.group_by_container);"/>
</td>
<td>
<select style="width: 180px;" size="9" multiple="multiple" id="group_by_container">
<% CostQuery::GroupBy.all_grouped.sort_by { |label, group_by_ary| l(label) }.each do |label, group_by_ary| %>
<optgroup label="<%= l(label) %>" data-category="<%= label.to_s %>">
<% group_by_ary.sort_by { |g| l(g.label)}.each do |group_by| %>
<% next unless group_by.selectable? %>
<option value="<%= group_by.underscore_name %>" data-category="<%= label.to_s %>"><%= l(group_by.label) %></option>
<% end %>
</optgroup>
<% end %>
</select>
</td>
</tr></tbody>
</table>
<%#
up &#8593;
down &#8595;
left &#8592;
right &#8594;
%>

@ -1,34 +0,0 @@
<script type="text/javascript">
//<![CDATA[
var set_filters, set_group_bys, restore_query_inputs;
window.global_prefix = '<%= ActionController::Base.relative_url_root %>';
set_filters = function () {
// Activate recent filters on loading
<% query.filters.select {|f| f.display? }.each do |f| %>
restore_filter("<%= f.class.underscore_name %>",
"<%= f.operator.to_s %>"<%= "," if f.values %>
<%= f.values.to_json if f.values %>);
<% end %>
};
set_group_bys = function () {
// Activate recent group_bys on loading
<% query.group_bys.each do |group_by| %>
<%= "show_group_by_column('#{group_by.class.underscore_name}');" if group_by.column? %>
<%= "show_group_by_row('#{group_by.class.underscore_name}');" if group_by.row? %>
<% end %>
};
restore_query_inputs = function () {
// init_group_bys();
disable_all_filters();
disable_all_group_bys();
set_filters();
set_group_bys();
};
restore_query_inputs();
//]]>
</script>

@ -1,46 +0,0 @@
<%
list = @query.collect {|r| r.important_fields }.flatten.uniq
show_units = list.include? "cost_type_id"
%>
<table class="report" id="sortable-table">
<thead>
<tr>
<% list.each do |field| %><th class="right"><%= label_for(field) %></th><% end %>
<% if show_units %>
<th class="right"><%= label_for(:field_units) %></th>
<% end %>
<th class="right"><%= label_for(:label_sum) %></th>
</tr>
</thead>
<tfoot>
<tr>
<th class="result inner" colspan='<%= list.size %>'></th>
<th class="result right" <%= "colspan='2'" if show_units %>>
<%= show_result @query %>
</th>
</tr>
</tfoot>
<tbody>
<% @query.each do |result| %>
<tr class='<%= cycle("odd", "even") %>'>
<td raw-data="<%= raw_field(*result.fields.first) -%>">
<%= show_row result %>
</td>
<% if show_units %>
<td raw-data="<%= result.units -%>"><%= show_result result, result.fields[:cost_type_id].to_i %></td>
<% end %>
<td raw-data="<%= result.real_costs -%>"><%= show_result result %></td>
</tr>
<% if params[:debug] %>
<tr>
<td colspan='<%= list.size + 3 %>'>
<%= result.fields.reject {|k,v| list.include? k.to_sym }.inspect %>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
<%= render :partial => 'sortable_init' %>

@ -1,13 +0,0 @@
<% sort_first_row = sort_first_row || false %>
<script type="text/javascript">
//<![CDATA[
var table_date_header = $$('#sortable-table th').first();
sortables_init();
<% if sort_first_row %>
if (table_date_header.childElements().size() > 0) {
ts_resortTable(table_date_header.childElements().first(), table_date_header.cellIndex);
}
<% end %>
//]]>
</script>

@ -1,5 +0,0 @@
<% @available_values.each do |name, id, *args| %>
<%= render :partial => 'cost_reports/filters/available_value',
:locals => { :name => name, :id => id, :level => args.first },
:layout => !request.xhr? %>
<% end %>

@ -1,11 +0,0 @@
<%#
This partial requires the following locals:
element a Hash containing the following keys:
- :name => :activate_filter
- :label => String: A text which is shown to the user as a label for this filter
- :width => Integer (optional): The width this partial may consume. If not given, a standard width will be applied
%>
<td width="<%= element[:width] || 150 %>">
<label id="label_<%= element[:filter_name]%>"><%= element[:label] %></label>
</td>

@ -1,11 +0,0 @@
<%#
This partial requires the following locals:
name String: The displayed name of the option
id Integer: The id the option refers to
level Integer: The indendation level of this option
%>
<% name_prefix = ((level && level > 0) ? ('&nbsp;' * 2 * level + '&#187; ') : '') %>
<option value="<%= id %>">
<%= name_prefix + h(name) %>
</option>

@ -1,22 +0,0 @@
<%#
This partial requires the following locals:
element a Hash containing the following keys:
- :name => :date
- :filter_name => String: The name of a filter (e.g. activity_id)
- :hide => Boolean (optional, default = true): whether the content of this partial is initially hidden or not
%>
<% name = "values[#{element[:filter_name]}][]"
id_prefix = "#{element[:filter_name]}_" %>
<td <%= style="display:none" if element[:hide] %>>
<div style="" id="<%= id_prefix %>arg_1" class="filter_values">
<%= text_field_tag "#{name}", "", :size => 10, :class => "select-small", :id => "#{id_prefix}arg_1_val"%>
<%= calendar_for("#{id_prefix}arg_1_val") %>
<span id="<%= id_prefix %>arg_2" class="between_tags">
<%=
text_field_tag("#{name}", "", :size => 10, :class => "select-small", :id => "#{id_prefix}arg_2_val") + " " +
calendar_for("#{id_prefix}arg_2_val")
%>
</span>
</div>
</td>

@ -1,21 +0,0 @@
<%#
This partial requires the following locals:
element a Hash containing the following keys:
- :name => :heavy_values
- :filter_name => String: The name of a filter (e.g. activity_id)
- :hide => Boolean: If you want this to be hidden
query => the current CostQuery
%>
<% filter = query.filters.detect {|f| f.class.underscore_name == element[:filter_name] }
values_available = !!filter && !!filter.values %>
<td <%= 'style="display:none"' if element[:hide] %> >
<div style="" id="<%= element[:filter_name] %>_arg_1" class="filter_values">
<%= filter.values.map{ |v| filter.class.text_for_id v }.join '<br />' if values_available %>
<input type="hidden"
name="values[<%= element[:filter_name] %>][]"
id="<%= element[:filter_name] %>_arg_1_val"
<%= 'disabled="disabled"' unless values_available %>
value="<%= filter.values.to_json if values_available %>"/>
</div>
</td>

@ -1,21 +0,0 @@
<%#
This partial requires the following locals:
element a Hash containing the following keys:
- :name => :multi_values
- :filter_name => String: The name of a filter (e.g. activity_id)
- :hide => Boolean (optional, default = true): whether the content of this partial is initially hidden or not
%>
<td <%= style="display:none" if element[:hide] %>>
<div style="" id="<%= element[:filter_name] %>_arg_1" class="filter_values">
<select style="vertical-align: top;"
name="values[<%= element[:filter_name] %>][]"
id="<%= element[:filter_name] %>_arg_1_val"
class="select-small"
data-loading="ajax"
multiple="multiple"> <%# multiple will be disabled/enabled later by JavaScript anyhow. We need to specify multiple here because of a IE6-bug. %>
<%# content will be inserted on filter activation %>
</select>
<%= link_to_function image_tag('bullet_toggle_plus.png'), "toggle_multi_select($('#{element[:filter_name]}_arg_1_val'));", :style => "vertical-align: bottom;" %>
</div>
</td>

@ -1,25 +0,0 @@
<%#
This partial requires the following locals:
element a Hash containing the following keys:
- :name => :operators
- :filter_name => String: The name of a filter (e.g. activity_id)
- :operators => Array<CostQuery::Operator>, operators available for the user
- :width => Integer (optional): The width this partial may consume. If not given, a standard width will be applied
- :hide => Boolean (optional, default = true): whether the content of this partial is initially hidden or not
#TODO: javascrpt to update elements depending on which operator is selected
%>
<td width="<%= element[:width] || 100 %>" <%= style="display:none" if element[:hide] %>>
<select style="vertical-align: top;"
onchange="operator_changed('<%= element[:filter_name] %>', this);"
name="operators[<%= element[:filter_name] %>]"
id="operators_<%= element[:filter_name] %>"
class="select-small">
<% element[:operators].each do |operator| %>
<option value="<%= h(operator.to_s) %>" data-arity="<%= operator.arity %>">
<%= h(l(operator.label)) %>
</option>
<% end %>
</select>
</td>

@ -1,10 +0,0 @@
<%#
This partial requires the following locals:
element a Hash containing the following keys:
- :filter_name => String: The name of a filter (e.g. activity_id)
- :hide => Boolean (optional, default = true): whether the content of this partial is initially hidden or not
%>
<td width="25px">
<input id= "rm_<%= element[:filter_name] %>" name="fields[]" onclick="remove_filter('<%= element[:filter_name] %>');"
type="button" value="" class="icon filter_rem icon-filter-rem"/>
</td>

@ -1,12 +0,0 @@
<%#
This partial requires the following locals:
element a Hash containing the following keys:
- :name => 'text'
- :text => String: The text that should be displayed
- :width => Integer (optional): The width this partial may consume. If not given, a standard width will be applied
- :hide => Boolean (optional, default = true): whether the content of this partial is hidden or not
%>
<td width="<%= element[:width] || 100 %>" <%= style="display:none" if element[:hide] %>>
<%= element[:text] || '' %>
</td>

@ -1,17 +0,0 @@
<%#
This partial requires the following locals:
element a Hash containing the following keys:
- :name => :text_box
- :filter_name => String: The name of a filter (e.g. activity_id)
- :size => Integer, the size of the textboxt
- :hide => Boolean (optional, default = true): whether the content of this partial is initially hidden or not
%>
<td <%= style="display:none" if element[:hide] %>>
<div style="" id="<%= element[:filter_name] %>_arg_1" class="filter_values">
<%= text_field_tag("values[#{element[:filter_name]}]", "",
:size => element[:size],
:class => "select-small",
:id => "#{element[:filter_name]}_arg_1_val") %>
</div>
</td>

@ -1,76 +0,0 @@
<% content_for :header_tags do %>
<%= javascript_include_tag "select_list_move_optgroup", :plugin => "redmine_reporting" %>
<%= javascript_include_tag "reporting", :plugin => "redmine_reporting" %>
<%= javascript_include_tag "sortable", :plugin => "redmine_reporting" %>
<%= stylesheet_link_tag 'reporting', :plugin => 'redmine_reporting' %>
<% end %>
<% if @custom_errors.present? %>
<% @custom_errors.each do |err| %>
<div class="flash error"><%= err %></div>
<% end %>
<% end %>
<h2><%= l(:label_cost_report) %></h2>
<% html_title( l(:label_cost_report) ) %>
<%= render :partial => 'saved_queries', :locals => { :queries => CostQuery.all } %>
<% form_for @query, :url => {:controller => 'cost_report', :action => 'new' }, :html => {:id => 'query_form', :method => :post} do |query_form| %>
<div id="query_form_content">
<fieldset id="filters" class="collapsible">
<legend onclick="toggleFieldset(this);"><%= l(:label_filter_plural) %></legend>
<div><%= render :partial => 'filters', :locals => {:f => query_form, :query => @query} %></div>
</fieldset>
<fieldset id="group-by" class="collapsible">
<legend onclick="toggleFieldset(this);"><%= l(:label_group_by) %></legend>
<div><%= render :partial => 'group_by', :locals => {:f => query_form, :query => @query} %></div>
</fieldset>
<%= render :partial => 'restore_query', :locals => {:f => query_form, :query => @query} %>
<p class="buttons">
<%= link_to_remote "<span><em>#{l(:button_apply)}</em></span>",
{ :url => { :set_filter => 1 },
:before => 'selectAllOptions("group_by_rows");selectAllOptions("group_by_columns");',
:condition => 'Ajax.activeRequestCount === 0',
:update => "content",
:with => "Form.serialize('query_form')",
:eval_scripts => true
}, :class => 'reporting_button apply' %>
<%= link_to_function l(:button_clear), "disable_all_filters(); disable_all_group_bys();", :class => 'icon icon-reload' %>
<% if User.current.allowed_to?(:save_queries, @project, :global => true) && @valid %>
<%= render :partial => 'save_query' %>
<% end %>
</p>
</div>
<div class='cost_types'>
<b><%= l(:label_report) %>:</b>
<%= delimit(
available_cost_type_tabs(@cost_types).map do |id, label|
if id != @unit_id
link_to_remote label, {
:url => { :set_filter => 1, :unit => id },
:condition => 'Ajax.activeRequestCount === 0',
:update => "content",
:before => 'selectAllOptions("group_by_rows");selectAllOptions("group_by_columns");',
:with => "Form.serialize('query_form')",
:eval_scripts => true }
else
"<b>#{label}</b>"
end
end)
%>
</div>
<% end %>
<% if @valid and @query.result.count > 0 %>
<%= render :partial => @table_partial, :locals => {:query => @query, :walker => @query.walker} %>
<p class="footnote">
<%= l(:text_costs_are_rounded_note) %>
<%= "<br />#{l(:information_restricted_depending_on_permission)}" if !User.current.admin?%>
</p>
<%= call_hook(:view_cost_report_table_bottom) %>
<% else %>
<p class="nodata"><%= l(:label_no_data) %></p>
<% end %>

@ -1,22 +0,0 @@
<script type="text/javascript">
//<![CDATA[
remove_old_sidebar = function() {
if ($($("sidebar").down()).innerHTML === "<%= l(:label_spent_time) %>") {
var reporting_link, old_html;
// The sidebar is showing spent time, remove the links, which are in the second para
$("sidebar").down().siblings()[1].remove();
// Make the hours a link, which are in the first para
reporting_link = "<%= link_to('PLACEHOLDER', {
:controller => 'cost_reports', :project_id => project,
:unit => -1, :set_filter => 1,
:values => {:project_id => [project.id]},
:operators => {:project_id => '='},
:fields => [:project_id] }).gsub('"', "'") %>";
old_html = $("sidebar").down().siblings().first().innerHTML;
$("sidebar").down().siblings().first().innerHTML = reporting_link.replace("PLACEHOLDER", old_html)
}
}
remove_old_sidebar();
//]]>
</script>

@ -98,7 +98,7 @@
}
/* Aligning filter elements at the top. */
.new_cost_query fieldset#filters table td {
.new_report fieldset#filters table td {
vertical-align: top;
border-spacing: 5px 5px;
border-color: white;
@ -106,7 +106,7 @@
border-width: 2px 0px 0px;
}
.new_cost_query fieldset#filters table td > label {
.new_report fieldset#filters table td > label {
left: 3px;
top: 3px;
position: relative;
@ -114,7 +114,7 @@
/* Overwriting styling for headlines within the query. */
/* TODO: Font-size seems to be a bit odd. Needs some love. */
.new_cost_query fieldset h3 {
.new_report fieldset h3 {
font-size: 1.17em;
border: none;
}
@ -209,11 +209,11 @@
margin-top: 6px;
}
.new_cost_query fieldset#filters table tr.filter:hover {
.new_report fieldset#filters table tr.filter:hover {
background: #aaa;
}
.new_cost_query fieldset#filters table tr.filter {
.new_report fieldset#filters table tr.filter {
color: #000;
background: #ededed;
-webkit-border-radius: 5px;

@ -1,24 +1,11 @@
---
de:
units: Einheiten
label_entry: "Kosteneintrag"
label_less: "<"
label_greater: ">"
label_cost_entry_attributes: "Attribute am Kosteneintrag"
label_issue_attributes: "Attribute am Ticket"
label_spent_on_reporting: "Datum der Buchung"
label_week_reporting: "Woche der Buchung"
label_month_reporting: "Monat der Buchung"
label_year_reporting: "Jahr der Buchung"
label_report: "Report"
label_money: "Geld"
label_columns: "Spalten"
label_rows: "Zeilen"
label_is_project_with_subprojects: "ist (mit Unterprojekten)"
label_is_not_project_with_subprojects: "ist nicht (mit Unterprojekten)"
text_costs_are_rounded_note: "Die angezeigten Werte sind gerunded. Alle Berechnungen basieren auf ungerundete Werte."
information_restricted_depending_on_permission: "Abhängig von Ihren Berechtigungen können die Informationen auf dieser Seite eingeschränkt sein."
label_count: Anzahl
label_sum: Summe
@ -27,5 +14,3 @@ de:
validation_failure_date: "ist kein gültiges Datum"
validation_failure_integer: "ist keine ganze Zahl"
comments: "Kommentar"

@ -1,24 +1,11 @@
---
en:
units: Units
label_entry: "Cost Entry"
label_less: "<"
label_greater: ">"
label_cost_entry_attributes: "Cost Entry Attributes"
label_issue_attributes: "Issue Attributes"
label_spent_on_reporting: "Date (Spent)"
label_week_reporting: "Week (Spent)"
label_month_reporting: "Month (Spent)"
label_year_reporting: "Year (Spent)"
label_report: "Report"
label_money: "Money"
label_columns: "Columns"
label_rows: "Rows"
label_is_project_with_subprojects: "is (includes subprojects)"
label_is_not_project_with_subprojects: "is not (includes subprojects)"
text_costs_are_rounded_note: "Displayed values are rounded. All calculations are based on the non-rounded values."
information_restricted_depending_on_permission: "Depending on your permissions this page might contain restricted information."
label_count: Count
label_sum: Sum
@ -27,5 +14,3 @@ en:
validation_failure_date: "is not a valid date"
validation_failure_integer: "is not a valid integer"
comments: "Comment"

@ -1,4 +0,0 @@
ActionController::Routing::Routes.draw do |map|
map.connect 'projects/:project_id/cost_reports.:format', :controller => 'cost_reports', :project_id => /.+/, :action => 'index'
map.connect 'projects/:project_id/cost_reports/:action/:id', :controller => 'cost_reports', :project_id => /.+/
end

@ -1,138 +0,0 @@
<style type="text/css" media="screen">
table {
empty-cells: show;
border-collapse: collapse;
}
td {
min-width: 50px;
text-align: center;
font-family: Arial;
background-color: #eee;
border: dashed 1px #000;
}
</style>
<table cellspacing="0" cellpadding="5">
<tr>
<td></td>
<td colspan='3'>a</td>
<td colspan='3'>b</td>
<td colspan='3'>c</td>
<td colspan='3'></td>
</tr>
<tr>
<td rowspan='3'>d</td>
<td>x</td>
<td>y</td>
<td>z</td>
<td>x</td>
<td>y</td>
<td>z</td>
<td>x</td>
<td>y</td>
<td>z</td>
<td>s</td>
<td rowspan='3'>s</td>
<td rowspan='6'>s</td>
</tr>
<tr>
<!-- <td rowspan='3'>d</td> -->
<td>x</td>
<td>y</td>
<td>z</td>
<td>x</td>
<td>y</td>
<td>z</td>
<td>x</td>
<td>y</td>
<td>z</td>
<td>s</td>
<!-- <td rowspan='3'>s</td> -->
<!-- <td rowspan='6'>s</td> -->
</tr>
<tr>
<!-- <td rowspan='3'>d</td> -->
<td>x</td>
<td>y</td>
<td>z</td>
<td>x</td>
<td>y</td>
<td>z</td>
<td>x</td>
<td>y</td>
<td>z</td>
<td>s</td>
<!-- <td rowspan='3'>s</td> -->
<!-- <td rowspan='6'>s</td> -->
</tr>
<tr>
<td rowspan='3'>e</td>
<td>x</td>
<td>y</td>
<td>z</td>
<td>x</td>
<td>y</td>
<td>z</td>
<td>x</td>
<td>y</td>
<td>z</td>
<td>s</td>
<td rowspan='3'>s</td>
<!-- <td rowspan='6'>s</td> -->
</tr>
<tr>
<!-- <td rowspan='3'>e</td> -->
<td>x</td>
<td>y</td>
<td>z</td>
<td>x</td>
<td>y</td>
<td>z</td>
<td>x</td>
<td>y</td>
<td>z</td>
<td>s</td>
<!-- <td rowspan='3'>s</td> -->
<!-- <td rowspan='6'>s</td> -->
</tr>
<tr>
<!-- <td rowspan='3'>e</td> -->
<td>x</td>
<td>y</td>
<td>z</td>
<td>x</td>
<td>y</td>
<td>z</td>
<td>x</td>
<td>y</td>
<td>z</td>
<td>s</td>
<!-- <td rowspan='3'>s</td> -->
<!-- <td rowspan='6'>s</td> -->
</tr>
<tr>
<td></td>
<td>s</td>
<td>s</td>
<td>s</td>
<td>s</td>
<td>s</td>
<td>s</td>
<td>s</td>
<td>s</td>
<td>s</td>
<td colspan='3'></td>
</tr>
<tr>
<td></td>
<td colspan='3'>s</td>
<td colspan='3'>s</td>
<td colspan='3'>s</td>
<td colspan='3'></td>
</tr>
<tr>
<td></td>
<td colspan='9'>s</td>
<td colspan='3'></td>
</tr>
</table>

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save