require_dependency 'query' class CostQueryColumn < QueryColumn attr_reader :scope def initialize(name, options={}) self.scope = (optione.delete(:scope) || :issues) super end end class CostQueryCustomFieldColumn < QueryCustomFieldColumn attr_accessor :scope def initialize(custom_field) self.reader = :issues super end end class Filter include GLoc def initialize(scope, column_name, column) @scope = scope @column_name = column_name @column = column @enabled = true default_operator = CostQuery.filter_types[@column[:type]][:default] @operator = default_operator if default_operator end attr_reader :scope, :column_name, :column attr_reader :values, :sql_values def values=(v) values = v.is_a?(Array) ? v : [v] sql_values = values.dup if column[:flags].include? :user sql_values.push(User.current.logged? ? User.current.id.to_s : "0") if sql_values.delete("me") end if available_values available_value_keys = available_values.collect {|o| o[1].to_s } sql_values.each do |value| unless (available_value_keys.include? value.to_s) or (value.to_s == "") raise ArgumentError.new("Forbidden value (#{value.inspect} not in #{available_value_keys.inspect})") end end end @values = values @sql_values = sql_values end attr_reader :operator def operator=(o) raise ArgumentError.new("Forbidden operator #{o}") unless available_operators.include? o @operator = o end attr_accessor :enabled def type_name @column[:type] end def filter_type CostQuery.filter_types[type_name] end def label @column[:name] || l(("field_"+@column_name.gsub(/\_id$/, "")).to_sym) end def available_operators filter_type[:operators] end def available_values @column[:values] end def new_record? return true end end class CostQuery < ActiveRecord::Base include GLoc belongs_to :user belongs_to :project serialize :filters serialize :group_by attr_protected :user_id, :project_id, :created_at, :updated_at def after_initialize self.display_time_entries = true if display_time_entries.nil? self.display_cost_entries = true if display_cost_entries.nil? self.group_by ||= {} end def self.operators # These are the operators used by filter types. operators = {} issue_operators = Query.operators issue_operators.each_pair do |op, label| simple = (["!*", "*", "t", "w", "o", "c"].include? op) operators[op] = {:label => label, :simple => simple} end operators.merge( { "=n" => {:label => :label_equals, :simple => false}, "0" => {:label => :label_none, :simple => true}, "y" => {:label => :label_yes, :simple => true}, "n" => {:label => :label_no, :simple => true}, " {:label => :label_less_or_equal, :simple => false}, ">d" => {:label => :label_greater_or_equal, :simple => false}, "<>d" => {:label => :label_between, :simple => false}, "=d" => {:label => :label_date_on, :simple => false} } ) end def self.filter_types return @filter_types if @filter_types filter_types = Query.operators_by_filter_type.inject({}) do |r, f| multiple = !([:list, :list_status, :list_optional, :list_subproject].include? f[0]) r[f[0]] = {:operators => f[1], :multiple => multiple} r end @filter_types = filter_types.merge( { :integer_zero => {:operators => [ "=n", ">=", "<=", "0", "*" ], :multiple => true}, :boolean => {:operators => [ "y", "n" ], :multiple => false}, :date_exact => {:operators => [ "d", "<>d", "=d", "t", "w"], :multiple => true, :default => "w"} } ) end def available_filters # This available_filters is different from the Redmine one # available_filters[:issues] # --> filters on issue fields. These are the one from redmine itself # available_filters[:costs] # --> filters on cost and time entries return @available_filters if @available_filters @available_filters = { :costs => { "cost_type_id" => { :type => :list_optional, :order => 2, :applies => [:cost_entries], :flags => [], :db_table => CostType.table_name, :db_field => "id", :values => CostType.find(:all, :order => 'name').collect{|s| [s.name, s.id.to_s] }}, "activity_id" => { :type => :list_optional, :order => 3, :applies => [:time_entries], :flags => [], :db_table => TimeEntryActivity.table_name, :db_field => "id", :values => TimeEntryActivity.find(:all, :order => 'position').collect{|s| [s.name, s.id.to_s] }}, "created_on" => { :type => :date_exact, :applies => [:time_entries, :cost_entries], :flags => [], :order => 4 }, "updated_on" => { :type => :date_exact, :applies => [:time_entries, :cost_entries], :flags => [], :order => 5 }, "spent_on" => { :type => :date_exact, :applies => [:time_entries, :cost_entries], :flags => [], :order => 6}, "overridden_costs" => { :type => :boolean, :applies => [:time_entries, :cost_entries], :flags => [], :order => 7 }, # FIXME: Issues are not selected properly according to project selection "issue_id" => { :type => :list_optional, :order => 8, :applies => [:cost_entries, :time_entries], :flags => [], :db_table => Issue.table_name, :db_field => "id", :values => Issue.find(:all, :order => :id, :include => :tracker).collect{|s| ["#{s.tracker} ##{s.id}: #{s.subject}", s.id.to_s] }}, } } tmp_query = Query.new(:project => project, :name => "_") @available_filters[:issues] = tmp_query.available_filters # flag columns that contain filters for user columns @available_filters[:issues].each_pair do |k,v| v[:flags] = [] v[:flags] << :user if %w(assigned_to_id author_id watcher_id).include?(k) if k =~ /^cf_(\d+)$/ # custom field v[:db_table] = CustomValue.table_name v[:db_field] = 'value' v[:db_field_id] = $1 # this is the numeric part in the regex above v[:flags] << :custom_field elsif k == "watcher_id" v[:db_table] = Watcher.table_name v[:db_field] = 'user_id' v[:flags] << :watcher else if ["labor_costs", "material_costs", "overall_costs"].include? k v[:type] = :integer_zero end if [:date_past, :date].include? v[:type] v[:type] = :date_exact end v[:db_table] = Issue.table_name v[:db_field] = k end end if @available_filters[:issues]["author_id"] # add a filter on cost entries for user_id if it is available user_values = @available_filters[:issues]["author_id"][:values] @available_filters[:costs]["user_id"] = {:type => :list_optional, :order => 1, :applies => [:time_entries, :cost_entries], :values => user_values, :flags => [:user]} end @available_filters end def create_filter(scope, column_name) column = available_filters[scope][column_name] column ? Filter.new(scope, column_name, column) : nil end def create_filter_from_hash(filter_hash = {}) scope = filter_hash[:scope].to_sym column_name = filter_hash[:column_name] column = available_filters[scope][column_name] f = Filter.new(scope, column_name, column) f.enabled = filter_hash[:enabled] unless filter_hash[:enabled].nil? f.operator = filter_hash[:operator] unless filter_hash[:operator].nil? f.values = filter_hash[:values] unless filter_hash[:values].nil? f end def has_filter?(scope, column_name) # returns the first matching filter or nil return nil unless filters match = filters.select {|f| f[:scope] == scope.to_s && f[:column_name] == column_name.to_s} return match.blank? ? nil : match[0] end MAGIC_GROUP_KEYS = [:block, :time, :display, :db_field, :other_group] def self.grouping_column(*names, &block) options = names.extract_options! names.each do |name| group_by_columns[name] = options.with_indifferent_access.merge( :block => block, :scope => grouping_scope ) group_by_columns[name][:db_field] ||= name group_by_columns[name][:display] ||= Proc.new { |e| e } group_by_columns[name][:other_group] ||= "#{l :group_by_others}" end end def self.grouping_scope(type = nil) @grouping_scope = type || @grouping_scope yield if block_given? @grouping_scope end def self.group_by_columns @group_by_columns ||= {}.with_indifferent_access end def self.get_name(key, value) return group_by_columns[key][:other_group] unless value group_by_columns[key][:display].call value end def self.from_field(klass, field) Proc.new do |id| a = klass.find_by_id(id) (a ? a.send(field) : id).to_s end end grouping_scope(:issues) do grouping_column :tracker_id, :display => from_field(Tracker, :name) grouping_column :fixed_version_id, :display => from_field(Version, :name) grouping_column :cost_object_id, :display => from_field(CostObject, :subject) grouping_column :subproject_id, :display => from_field(Project, :name), :db_field => :project_id end grouping_scope(:costs) do grouping_column :user_id, :display => from_field(User, :name) grouping_column :issue_id, :display => from_field(Issue, :subject), :other_group => "#{l(:caption_booked_on_project)}" grouping_column :cost_type_id, :display => from_field(CostType, :name), :other_group => l(:caption_labor_costs) grouping_column :activity_id, :display => from_field(TimeEntryActivity, :name) grouping_column(:spent_on, :tyear, :tmonth, :tweek, :time => true) do |column, fields| values = [] if fields["spent_on"] values = [fields["spent_on"].to_date] * 2 elsif fields["tyear"] if fields["tmonth"] start_of_month = Date.civil(fields["tyear"].to_i, fields["tmonth"].to_i , 1) values = [start_of_month.to_s, start_of_month.end_of_month.to_s] elsif fields["tweek"] start_of_week = Date.commercial(fields["tyear"].to_i, fields["tweek"].to_i, 1) values = [start_of_week.to_s, start_of_week.end_of_week.to_s] else start_of_year = Date.civil(fields["tyear"].to_i, 1, 1) values = [start_of_year.to_s, start_of_year.end_of_year.to_s] end end raise "Invalid group by values" if values.blank? { :operator => "<>d", :values => values, :column_name => :spent_on } end end def filter_from_group_by(fields) column_name = group_by[:name].to_sym data = self.class.group_by_columns[column_name].dup options = {} MAGIC_GROUP_KEYS.each do |key| options[key] = data.delete key end block = options[:block] || Proc.new { {} } equals_hash = { :enabled => 1, :operator => "=", :column_name => column_name, :values => fields[column_name.to_s], } # TODO: not all filters have this filter operator. We have to always select the correct operator none_hash = { :enabled => 1, :operator => "!*", :column_name => column_name, :values => nil, } hash = fields[column_name.to_s].nil? ? none_hash : equals_hash hash.merge(data).merge(block.call(column_name, fields)) end def group_by_columns_for_select self.class.group_by_columns.inject([["", ""]]) do |list, (column_name, values)| filter = create_filter(values[:scope], column_name.to_s) list << [filter.label, column_name] if filter list end end def time_groups # returns an array of group_by names where time == true self.class.group_by_columns.inject([]) do |list, (column_name, values)| list << column_name if values[:time] list end end def projects return @projects unless @projects.blank? projects = [project] if project && !project.children.active.empty? if subprojects = has_filter?(:issues, "subproject_id") subprojects = create_filter_from_hash(subprojects) case subprojects.operator when "=" # include the selected subprojects projects += Project.find_by_id(subprojects.values.each(&:to_i)) when "!*" # main project only else # all subprojects projects += project.descendants end elsif Setting.display_subprojects_issues? projects += project.descendants end elsif project # show only the current project else projects = [] end @projects = projects end def project_statement "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})" end def group_by_fields() # returns the group_by of the current group by query # These fields are one of the keys of group_by_columns return [] unless !group_by[:name].blank? && (data = group_by_columns[group_by[:name].to_sym]) if data[:time] # We have a group_by_time return case group_by[:granularity] when "year" then ["tyear"] when "month" then ["tyear", "tmonth"] when "week" then ["tyear", "tweek"] else ["spent_on"] end else [group_by[:name]] end end def group_by_columns self.class.group_by_columns end def sql_data_for(entry_scope) case entry_scope when :cost_entries model = CostEntry when :time_entries model = TimeEntry from_include_issue = false end my_fields, nil_fields, grouping_fields = [], [], [] group_by_fields.each do |field| group_by_column = group_by_columns[field.to_sym] klass = group_by_column[:scope] == :issues ? Issue : model db_field = group_by_column[:db_field] if klass.column_names.include? db_field.to_s grouping_fields << "#{klass.table_name}.#{db_field}" my_fields << "#{klass.table_name}.#{db_field} as #{field}" else nil_fields << "NULL as #{field}" end end group_by = "GROUP BY #{grouping_fields.join(", ")}" unless grouping_fields.blank? [model, (my_fields + nil_fields).join(", "), rate_permission_statement(entry_scope), from_statement(entry_scope), statement(entry_scope), group_by] end def rate_permission_statement(entry_scope) case entry_scope when :cost_entries statement = User.current.allowed_for(:view_cost_rates, projects) when :time_entries statement = User.current.allowed_for(:view_hourly_rates, projects) end end def from_statement(entry_scope, include_issue = false) case entry_scope when :cost_entries from = <<-EOS #{CostEntry.table_name} LEFT OUTER JOIN #{CostType.table_name} ON #{CostType.table_name}.id = #{CostEntry.table_name}.cost_type_id LEFT OUTER JOIN #{User.table_name} ON #{User.table_name}.id = #{CostEntry.table_name}.user_id LEFT OUTER JOIN #{Issue.table_name} ON #{Issue.table_name}.id = #{CostEntry.table_name}.issue_id LEFT OUTER JOIN #{Project.table_name} ON #{Project.table_name}.id = #{CostEntry.table_name}.project_id EOS when :time_entries from = <<-EOS #{TimeEntry.table_name} LEFT OUTER JOIN #{TimeEntryActivity.table_name} ON #{TimeEntryActivity.table_name}.id = #{TimeEntry.table_name}.activity_id LEFT OUTER JOIN #{User.table_name} ON #{User.table_name}.id = #{TimeEntry.table_name}.user_id LEFT OUTER JOIN #{Issue.table_name} ON #{Issue.table_name}.id = #{TimeEntry.table_name}.issue_id LEFT OUTER JOIN #{Project.table_name} ON #{Project.table_name}.id = #{TimeEntry.table_name}.project_id EOS end end def statement(entry_scope) # entry_scope can currently be one of :cost_entries, :time_entries # To not mix this with the scope (aka :issues vs. :costs) issue_filter_clauses = [] entry_filter_clauses = [] # allow blank issue_ids if true issue_nil_filter = true if filters and valid? filters.each do |filter| filter = create_filter_from_hash(filter) next if filter.column_name == "subproject_id" sql = '' case filter.scope when :issues if filter.column[:flags].include? :custom_field sql << "#{Issue.table_name}.id IN (" sql << " SELECT #{Issue.table_name}.id FROM #{Issue.table_name} LEFT OUTER JOIN #{filter.column[:db_table]} ON #{filter.column[:db_table]}.customized_type='Issue' AND #{filter.column[:db_table]}.customized_id=#{Issue.table_name}.id AND #{filter.column[:db_table]}.custom_field_id=#{filter.column[:db_field_id]} WHERE " sql << sql_for_filter(filter, nil, true) sql << ")" elsif filter.column[:flags].include? :watcher sql << "#{Issue.table_name}.id #{ field.operator == '=' ? 'IN' : 'NOT IN' } (" sql << " SELECT #{filter.column[:db_table]}.watchable_id FROM #{filter.column[:db_table]} WHERE #{filter.column[:db_table]}.watchable_type='Issue' AND " sql << sql_for_filter(filter) sql << ")" else sql << '(' + sql_for_filter(filter) + ')' end issue_filter_clauses << sql when :costs issue_nil_filter = false if filter.column_name == "issue_id" sql << '(' + sql_for_filter(filter, entry_scope) + ')' entry_filter_clauses << sql end end end issue_filter_clauses = ["1=1"] if issue_filter_clauses.blank? # FIXME: This is a hack calling a private ActiveRecord methods # from http://pivotallabs.com/users/jsusser/blog/articles/567-hacking-a-subselect-in-activerecord from = "#{Issue.table_name}" from << " LEFT OUTER JOIN #{User.table_name} ON #{User.table_name}.id = #{Issue.table_name}.assigned_to_id" from << " LEFT OUTER JOIN #{IssueStatus.table_name} ON #{IssueStatus.table_name}.id = #{Issue.table_name}.status_id" from << " LEFT OUTER JOIN #{Tracker.table_name} ON #{Tracker.table_name}.id = #{Issue.table_name}.tracker_id" from << " LEFT OUTER JOIN #{Project.table_name} ON #{Project.table_name}.id = #{Issue.table_name}.project_id" from << " LEFT OUTER JOIN #{IssuePriority.table_name} ON #{IssuePriority.table_name}.id = #{Issue.table_name}.priority_id" from << " LEFT OUTER JOIN #{IssueCategory.table_name} ON #{IssueCategory.table_name}.id = #{Issue.table_name}.category_id" from << " LEFT OUTER JOIN #{Version.table_name} ON #{Version.table_name}.id = #{Issue.table_name}.fixed_version_id" issue_ids = Issue.send( :construct_finder_sql, :select => "#{Issue.table_name}.id", #:include => [ :assigned_to, :status, :tracker, :project, :priority, :category, :fixed_version ], :from => from, :conditions => (issue_filter_clauses << project_statement).join(' AND ')) case entry_scope when :cost_entries clause = ["#{CostEntry.table_name}.issue_id IN (#{issue_ids})"] clause << "#{CostEntry.table_name}.issue_id IS NULL" if issue_nil_filter entry_filter_clauses << "(#{clause.join(" OR ")})" when :time_entries clause = ["#{TimeEntry.table_name}.issue_id IN (#{issue_ids})"] clause << "#{TimeEntry.table_name}.issue_id IS NULL" if issue_nil_filter entry_filter_clauses << "(#{clause.join(" OR ")})" end entry_filter_clauses << User.current.allowed_for("view_#{entry_scope}".to_sym, projects) entry_filter_clauses.join(' AND ') end def sort_criteria=(arg) c = [] if arg.is_a?(Hash) arg = arg.keys.sort.collect {|k| arg[k]} end c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, o == 'desc' ? o : 'asc']} write_attribute(:sort_criteria, c) end def sort_criteria read_attribute(:sort_criteria) || [] end def sort_criteria_key(arg) sort_criteria && sort_criteria[arg] && sort_criteria[arg].first end def sort_criteria_order(arg) sort_criteria && sort_criteria[arg] && sort_criteria[arg].last end private def sql_for_filter(filter, entry_scope = nil, string_as_null = false) db_table = filter.column[:db_table] if filter.scope == :costs && !db_table case entry_scope when :cost_entries db_table = CostEntry.table_name when :time_entries db_table = TimeEntry.table_name else raise "Need a valid entry scope. Got #{entry_scope.inspect}" unless entry_scope end end if filter.scope == :costs && (!filter.column[:applies].include? entry_scope) # the current filter does not match the entry_scope so we just ignore it return "1=1" end db_field = filter.column[:db_field] || filter.column_name # Does not work for Redmine 0.8 #@sql_for_filter_query = Query.new(:name => "_") unless @sql_for_filter_query #sql = @sql_for_filter_query.send( # :sql_for_field, # filter.column_name, filter.operator, filter.sql_values, db_table, db_field, string_as_null) sql = sql_for_field(filter.column_name, filter.operator, filter.sql_values, db_table, db_field, string_as_null) return sql unless sql == "1=1" # We have an operator that was added by us. So we provide the logic here case filter.operator when "0" sql = "#{db_table}.#{db_field} = 0" when "y" sql = "#{db_table}.#{db_field} IS NOT NULL" when "n" sql = "#{db_table}.#{db_field} IS NULL" when "=n" sql = "#{db_table}.#{db_field} = #{CostRate.clean_currency(filter.sql_values).to_f.to_s}" when "<>d" begin date1 = filter.sql_values.first.to_date date2 = filter.sql_values.last.to_date sql = "#{db_table}.#{db_field} BETWEEN '#{connection.quoted_date(date1)}' AND '#{connection.quoted_date(date2)}'" rescue end when ">d" begin date = filter.sql_values.first.to_date sql = "#{db_table}.#{db_field} >= '#{connection.quoted_date(date)}'" rescue end when " ''" if is_custom_filter when ">=" sql = "#{db_table}.#{db_field} >= #{value.first.to_i}" when "<=" sql = "#{db_table}.#{db_field} <= #{value.first.to_i}" when "o" sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_false}" if field == "status_id" when "c" sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_true}" if field == "status_id" when ">t-" sql = date_range_clause(db_table, db_field, - value.first.to_i, 0) when "t+" sql = date_range_clause(db_table, db_field, value.first.to_i, nil) when " '%s'" % [connection.quoted_date((Date.yesterday + from).to_time.end_of_day)]) end if to s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date((Date.today + to).to_time.end_of_day)]) end s.join(' AND ') end end