mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
b009937e9a
This behaviour leads to surprising side effects. E.g. by removing a column the default columns might be matched suddenly so that then the columns change if the default ones are changed afterwards. Additionally, it becomes impossible to remove the project column from the global work package list. The intend of relying on the default columns is still working for newly created queries as well as for default column (e.g. "all open").
492 lines
13 KiB
Ruby
492 lines
13 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
#-- copyright
|
|
# OpenProject is an open source project management software.
|
|
# Copyright (C) the OpenProject GmbH
|
|
#
|
|
# This program is free software; you can redistribute it and/or
|
|
# modify it under the terms of the GNU General Public License version 3.
|
|
#
|
|
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
|
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
|
# Copyright (C) 2010-2013 the ChiliProject Team
|
|
#
|
|
# This program is free software; you can redistribute it and/or
|
|
# modify it under the terms of the GNU General Public License
|
|
# as published by the Free Software Foundation; either version 2
|
|
# of the License, or (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program; if not, write to the Free Software
|
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
#
|
|
# See COPYRIGHT and LICENSE files for more details.
|
|
#++
|
|
|
|
class Query < ApplicationRecord
|
|
include Timelines
|
|
include Timestamps
|
|
include Highlighting
|
|
include ManualSorting
|
|
include Queries::Filters::AvailableFilters
|
|
|
|
belongs_to :project
|
|
belongs_to :user
|
|
has_many :views,
|
|
dependent: :destroy
|
|
has_many :ical_token_query_assignments
|
|
has_many :ical_tokens,
|
|
through: :ical_token_query_assignments,
|
|
class_name: "Token::ICal"
|
|
has_many :export_settings, dependent: :destroy
|
|
# no `dependent: :destroy` as the ical_tokens are destroyed in the following before_destroy callback
|
|
# dependent: :destroy is not possible as this would only delete the ical_token_query_assignments
|
|
before_destroy :destroy_ical_tokens
|
|
|
|
serialize :filters, coder: Queries::WorkPackages::FilterSerializer
|
|
serialize :column_names, type: Array
|
|
serialize :sort_criteria, type: Array
|
|
|
|
validates :include_subprojects,
|
|
inclusion: [true, false]
|
|
|
|
validate :validate_work_package_filters
|
|
validate :validate_columns
|
|
validate :validate_sort_criteria
|
|
validate :validate_group_by
|
|
validate :validate_show_hierarchies
|
|
validate :validate_timestamps
|
|
|
|
include Scopes::Scoped
|
|
scopes :visible,
|
|
:having_views
|
|
|
|
scope(:global, -> { where(project_id: nil) })
|
|
|
|
def self.new_default(attributes = nil)
|
|
new(attributes).tap do |query|
|
|
query.add_default_filter
|
|
query.set_default_sort
|
|
query.show_hierarchies = true
|
|
query.include_subprojects = Setting.display_subprojects_work_packages?
|
|
end
|
|
end
|
|
|
|
##
|
|
# Ensure the filters receive
|
|
# the query context as this appears to be lost
|
|
# whenever the field is reloaded from the serialized value
|
|
def filters
|
|
super.tap do |filters|
|
|
filters.each do |filter|
|
|
filter.context = self
|
|
end
|
|
end
|
|
end
|
|
|
|
def set_default_sort
|
|
return if sort_criteria.any?
|
|
|
|
self.sort_criteria = [%w[id asc]]
|
|
end
|
|
|
|
def context
|
|
self
|
|
end
|
|
|
|
def to_s
|
|
name
|
|
end
|
|
|
|
def add_default_filter
|
|
return if filters.present?
|
|
|
|
add_filter("status_id", "o", [""])
|
|
end
|
|
|
|
def validate_work_package_filters
|
|
filters.each do |filter|
|
|
unless filter.valid?
|
|
errors.add :base, filter.error_messages
|
|
end
|
|
end
|
|
end
|
|
|
|
def validate_columns
|
|
available_names = displayable_columns.map { |c| c.name.to_sym }
|
|
|
|
(column_names - available_names).each do |name|
|
|
errors.add :column_names,
|
|
:invalid,
|
|
value: name
|
|
end
|
|
end
|
|
|
|
def validate_sort_criteria
|
|
available_criteria = sortable_columns.map(&:name).map(&:to_s)
|
|
|
|
sort_criteria.each do |name, _dir|
|
|
unless available_criteria.include? name.to_s
|
|
errors.add :sort_criteria, :invalid, value: name
|
|
end
|
|
end
|
|
end
|
|
|
|
def validate_group_by
|
|
unless group_by.blank? || groupable_columns.map { |c| c.name.to_s }.include?(group_by.to_s)
|
|
errors.add :group_by, :invalid, value: group_by
|
|
end
|
|
end
|
|
|
|
def validate_show_hierarchies
|
|
if show_hierarchies && group_by.present?
|
|
errors.add :show_hierarchies, :group_by_hierarchies_exclusive, group_by:
|
|
end
|
|
end
|
|
|
|
def validate_timestamps
|
|
forbidden_timestamps = timestamps - allowed_timestamps
|
|
if forbidden_timestamps.any?
|
|
errors.add :timestamps, :forbidden, values: forbidden_timestamps.join(", ")
|
|
end
|
|
end
|
|
|
|
def hidden
|
|
views.empty?
|
|
end
|
|
|
|
# Try to fix an invalid query
|
|
#
|
|
# Fixes:
|
|
# * filters:
|
|
# Reduces the filter's values to those that are valid.
|
|
# If the filter remains invalid, it is removed.
|
|
# * group_by:
|
|
# Removes the group by if it is invalid
|
|
# * sort_criteria
|
|
# Removes all invalid criteria
|
|
# * columns
|
|
# Removes all invalid columns
|
|
#
|
|
# If the query has been valid or if the error
|
|
# is not one of the addressed, the query is unchanged.
|
|
def valid_subset!
|
|
valid_filter_subset!
|
|
valid_group_by_subset!
|
|
valid_sort_criteria_subset!
|
|
valid_column_subset!
|
|
valid_timestamps_subset!
|
|
end
|
|
|
|
def add_filter(field, operator, values)
|
|
filter = filter_for(field)
|
|
|
|
filter.operator = operator
|
|
filter.values = values
|
|
|
|
filters << filter
|
|
end
|
|
|
|
def filter_for(field)
|
|
filter = (filters || []).detect { |f| f.field.to_s == field.to_s } || super
|
|
|
|
filter.context = self
|
|
|
|
filter
|
|
end
|
|
|
|
# Removes the filter with the given name
|
|
# from the query without persisting the change.
|
|
#
|
|
# @param [String] name the filter to remove
|
|
def remove_filter(name)
|
|
filters.delete_if { |f| f.field.to_s == name.to_s }
|
|
end
|
|
|
|
def normalized_name
|
|
name.parameterize.underscore
|
|
end
|
|
|
|
def available_columns
|
|
if @available_columns &&
|
|
(@available_columns_project == (project&.cache_key_with_version || 0))
|
|
return @available_columns
|
|
end
|
|
|
|
@available_columns_project = project&.cache_key_with_version || 0
|
|
@available_columns = ::Query.available_columns(project)
|
|
end
|
|
|
|
def self.available_columns(project = nil)
|
|
Queries::Register
|
|
.selects[self]
|
|
.map { |col| col.instances(project) }
|
|
.flatten
|
|
end
|
|
|
|
def self.displayable_columns
|
|
available_columns.select(&:displayable?)
|
|
end
|
|
|
|
def self.groupable_columns
|
|
available_columns.select(&:groupable)
|
|
end
|
|
|
|
def self.sortable_columns
|
|
available_columns.select(&:sortable)
|
|
end
|
|
|
|
def displayable_columns
|
|
available_columns.select(&:displayable?)
|
|
end
|
|
|
|
# Returns an array of columns that can be used to group the results
|
|
def groupable_columns
|
|
available_columns.select(&:groupable)
|
|
end
|
|
|
|
# Returns an array of columns that can be used to sort the results
|
|
def sortable_columns
|
|
available_columns.select(&:sortable)
|
|
end
|
|
|
|
# Returns a Hash of sql columns for sorting by column
|
|
def sortable_key_by_column_name
|
|
column_sortability = sortable_columns.inject({}) do |h, column|
|
|
h[column.name.to_s] = column.sortable
|
|
h
|
|
end
|
|
|
|
{ "id" => "#{WorkPackage.table_name}.id" }
|
|
.merge(column_sortability)
|
|
end
|
|
|
|
def summed_up_columns
|
|
available_columns.select(&:summable?)
|
|
end
|
|
|
|
def columns
|
|
column_list = if has_default_columns?
|
|
column_list = Setting.work_package_list_default_columns.dup.map(&:to_sym)
|
|
# Adds the project column by default for cross-project lists
|
|
column_list += [:project] if project.nil? && column_list.exclude?(:project)
|
|
column_list
|
|
else
|
|
column_names
|
|
end
|
|
|
|
# preserve the order
|
|
column_list.filter_map { |name| displayable_columns.find { |col| col.name == name.to_sym } }
|
|
end
|
|
|
|
def column_names=(names)
|
|
col_names = Array(names)
|
|
.compact_blank
|
|
.map(&:to_sym)
|
|
|
|
write_attribute(:column_names, col_names)
|
|
end
|
|
|
|
def has_column?(column)
|
|
column_names&.include?(column.name)
|
|
end
|
|
|
|
def has_default_columns?
|
|
column_names.empty?
|
|
end
|
|
|
|
def sort_criteria=(arg)
|
|
if arg.is_a?(Hash)
|
|
arg = arg.keys.sort.map { |k| arg[k] }
|
|
end
|
|
c = arg.reject { |k, _o| k.to_s.blank? }.slice(0, 3).map { |k, o| [k.to_s, o == "desc" ? o : "asc"] }
|
|
write_attribute(:sort_criteria, c)
|
|
end
|
|
|
|
def sort_criteria
|
|
(read_attribute(:sort_criteria) || []).tap do |criteria|
|
|
criteria.map! do |attr, direction|
|
|
attr = "id" if attr == "parent"
|
|
[attr, direction]
|
|
end
|
|
end
|
|
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
|
|
|
|
def sort_criteria_columns
|
|
sort_criteria
|
|
.filter_map do |attribute, direction|
|
|
attribute = attribute.to_sym
|
|
col = sort_criteria_column(attribute)
|
|
|
|
[col, direction] if col
|
|
end
|
|
end
|
|
|
|
def sort_criteria_column(attribute)
|
|
sortable_columns
|
|
.detect { |candidate| candidate.name == attribute }
|
|
end
|
|
|
|
def ordered?
|
|
sort_criteria.any?
|
|
end
|
|
|
|
# Returns true if the query is a grouped query
|
|
def grouped?
|
|
!group_by_column.nil?
|
|
end
|
|
|
|
def display_sums?
|
|
display_sums
|
|
end
|
|
|
|
def group_by_column
|
|
groupable_columns.detect { |c| c.groupable && c.name.to_s == group_by }
|
|
end
|
|
|
|
def group_by_statement
|
|
group_by_column&.groupable
|
|
end
|
|
|
|
def group_by_select
|
|
group_by_column&.groupable_select || group_by_statement
|
|
end
|
|
|
|
def group_by_join_statement
|
|
group_by_column&.groupable_join
|
|
end
|
|
|
|
def statement
|
|
return "1=0" unless valid?
|
|
|
|
statement_filters
|
|
.map { |filter| "(#{filter.where})" }
|
|
.compact_blank
|
|
.join(" AND ")
|
|
end
|
|
|
|
# Returns the result set
|
|
def results
|
|
Results.new(self)
|
|
end
|
|
|
|
# Returns the journals
|
|
# Valid options are :order, :offset, :limit
|
|
# NOTE: Internal comments are NEVER included "FOR NOW". This is a stop gap measure before we
|
|
# evaluate whether we want to maintain the journals atom export or not.
|
|
def work_package_journals(options = {}) # rubocop:disable Metrics/AbcSize
|
|
Journal.includes(:user)
|
|
.where(journable_type: WorkPackage.to_s, restricted: false)
|
|
.joins("INNER JOIN work_packages ON work_packages.id = journals.journable_id")
|
|
.joins("INNER JOIN projects ON work_packages.project_id = projects.id")
|
|
.joins("INNER JOIN users AS authors ON work_packages.author_id = authors.id")
|
|
.joins("INNER JOIN types ON work_packages.type_id = types.id")
|
|
.joins("INNER JOIN statuses ON work_packages.status_id = statuses.id")
|
|
.order(options[:order])
|
|
.limit(options[:limit])
|
|
.offset(options[:offset])
|
|
.references(:users)
|
|
.merge(WorkPackage.visible)
|
|
rescue ::ActiveRecord::StatementInvalid => e
|
|
raise ::Query::StatementInvalid.new(e.message)
|
|
end
|
|
|
|
def project_limiting_filter
|
|
return if project_filter_set?
|
|
|
|
subproject_filter = Queries::WorkPackages::Filter::SubprojectFilter.create!
|
|
subproject_filter.context = self
|
|
|
|
subproject_filter.operator = if include_subprojects?
|
|
"*"
|
|
else
|
|
"!*"
|
|
end
|
|
subproject_filter
|
|
end
|
|
|
|
def export_settings_for(format)
|
|
export_settings.where(format:).first_or_initialize
|
|
end
|
|
|
|
private
|
|
|
|
##
|
|
# Determine whether there are explicit filters
|
|
# on whether work packages from
|
|
# * subprojects
|
|
# * other projects
|
|
# are used.
|
|
def project_filter_set?
|
|
filters.any? do |filter|
|
|
filter.is_a?(::Queries::WorkPackages::Filter::SubprojectFilter) ||
|
|
filter.is_a?(::Queries::WorkPackages::Filter::ProjectFilter)
|
|
end
|
|
end
|
|
|
|
def for_all?
|
|
@for_all ||= project.nil?
|
|
end
|
|
|
|
def statement_filters
|
|
if project
|
|
filters + [project_limiting_filter].compact
|
|
else
|
|
filters
|
|
end
|
|
end
|
|
|
|
def allowed_timestamps
|
|
Timestamp.allowed(timestamps)
|
|
end
|
|
|
|
def valid_filter_subset!
|
|
filters.each(&:valid_values!).select! do |filter|
|
|
filter.available? && filter.valid?
|
|
end
|
|
end
|
|
|
|
def valid_group_by_subset!
|
|
unless groupable_columns.map(&:name).map(&:to_s).include?(group_by.to_s)
|
|
self.group_by = nil
|
|
end
|
|
end
|
|
|
|
def valid_sort_criteria_subset!
|
|
available_criteria = sortable_columns.map(&:name).map(&:to_s)
|
|
|
|
sort_criteria.select! do |criteria|
|
|
available_criteria.include? criteria.first.to_s
|
|
end
|
|
end
|
|
|
|
def valid_column_subset!
|
|
available_names = displayable_columns.map(&:name).map(&:to_sym)
|
|
|
|
self.column_names &= available_names
|
|
end
|
|
|
|
def valid_timestamps_subset!
|
|
self.timestamps &= allowed_timestamps
|
|
end
|
|
|
|
# dependent::destroy does not work for has_many :through associations
|
|
# only the ical_token_query_assignments would be destroyed
|
|
def destroy_ical_tokens
|
|
ical_tokens.each(&:destroy)
|
|
end
|
|
end
|