Merge branch 'feature/notifications-group-by' into feature/38520-Sidebar-in-Notification-Center-with-project-filter

pull/9581/head
Benjamin Bädorf 3 years ago
commit f4f247b456
No known key found for this signature in database
GPG Key ID: 069CA2D117AB5CCF
  1. 127
      app/models/queries/available_filters.rb
  2. 82
      app/models/queries/base_query.rb
  3. 2
      app/models/queries/capabilities/orders/id_order.rb
  4. 131
      app/models/queries/filters/available_filters.rb
  5. 85
      app/models/queries/filters/not_existing_filter.rb
  6. 2
      app/models/queries/filters/serializable.rb
  7. 51
      app/models/queries/group_bys/available_group_bys.rb
  8. 82
      app/models/queries/group_bys/base.rb
  9. 47
      app/models/queries/group_bys/not_existing_group_by.rb
  10. 2
      app/models/queries/groups/orders/default_order.rb
  11. 2
      app/models/queries/individual_principals/orders/group_order.rb
  12. 2
      app/models/queries/individual_principals/orders/name_order.rb
  13. 2
      app/models/queries/members/orders/default_order.rb
  14. 2
      app/models/queries/members/orders/email_order.rb
  15. 2
      app/models/queries/members/orders/name_order.rb
  16. 2
      app/models/queries/members/orders/status_order.rb
  17. 2
      app/models/queries/news/orders/default_order.rb
  18. 7
      app/models/queries/notifications.rb
  19. 53
      app/models/queries/notifications/group_bys/group_by_project.rb
  20. 41
      app/models/queries/notifications/group_bys/group_by_reason.rb
  21. 2
      app/models/queries/notifications/orders/default_order.rb
  22. 51
      app/models/queries/notifications/orders/project_order.rb
  23. 2
      app/models/queries/notifications/orders/read_ian_order.rb
  24. 6
      app/models/queries/notifications/orders/reason_order.rb
  25. 28
      app/models/queries/orders/available_orders.rb
  26. 90
      app/models/queries/orders/base.rb
  27. 20
      app/models/queries/orders/not_existing_order.rb
  28. 2
      app/models/queries/placeholder_users/orders/default_order.rb
  29. 2
      app/models/queries/principals/orders/name_order.rb
  30. 2
      app/models/queries/projects/orders/custom_field_order.rb
  31. 2
      app/models/queries/projects/orders/default_order.rb
  32. 2
      app/models/queries/projects/orders/latest_activity_at_order.rb
  33. 2
      app/models/queries/projects/orders/name_order.rb
  34. 2
      app/models/queries/projects/orders/project_status_order.rb
  35. 2
      app/models/queries/projects/orders/required_disk_space_order.rb
  36. 11
      app/models/queries/register.rb
  37. 2
      app/models/queries/relations/orders/default_order.rb
  38. 2
      app/models/queries/users/orders/default_order.rb
  39. 2
      app/models/queries/versions/orders/name_order.rb
  40. 2
      app/models/queries/versions/orders/semver_name_order.rb
  41. 6
      app/models/queries/work_packages/filter_serializer.rb
  42. 2
      app/models/query.rb
  43. 4
      app/services/api/v3/work_package_collection_from_query_service.rb
  44. 9
      app/services/params_to_query_service.rb
  45. 12
      docs/api/apiv3/paths/notifications.yml
  46. 34
      lib/api/decorators/aggregation_group.rb
  47. 9
      lib/api/decorators/collection.rb
  48. 4
      lib/api/decorators/offset_paginated_collection.rb
  49. 9
      lib/api/v3/utilities/endpoints/index.rb
  50. 3
      lib/api/v3/utilities/path_helper.rb
  51. 2
      lib/api/v3/utilities/resource_link_generator.rb
  52. 73
      lib/api/v3/work_packages/work_package_aggregation_group.rb
  53. 7
      lib/api/v3/work_packages/work_package_collection_representer.rb
  54. 2
      modules/costs/app/models/queries/time_entries/orders/default_order.rb
  55. 2
      modules/documents/app/models/queries/documents/orders/default_order.rb
  56. 84
      spec/lib/api/v3/notifications/notification_collection_representer_spec.rb
  57. 68
      spec/models/queries/filters/available_filters_spec.rb
  58. 70
      spec/models/queries/notifications/notification_query_spec.rb
  59. 72
      spec/requests/api/v3/notifications/index_resource_spec.rb
  60. 13
      spec/services/api/v3/work_package_collection_from_query_service_spec.rb

@ -1,127 +0,0 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
require_dependency 'queries/filters'
module Queries::AvailableFilters
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def registered_filters
Queries::Register.filters[self]
end
def find_registered_filter(key)
registered_filters.detect do |f|
f.key === key.to_sym
end
end
end
def available_filters
uninitialized = registered_filters - already_initialized_filters
uninitialized.each do |filter|
initialize_filter(filter)
end
initialized_filters.select(&:available?)
end
def filter_for(key, no_memoization = false)
filter = get_initialized_filter(key, no_memoization)
raise ::Queries::Filters::MissingError if filter.nil?
filter
rescue ::Queries::Filters::InvalidError => e
Rails.logger.error "Failed to register filter for #{key}: #{e} \n" \
"Falling back to non-existing filter."
non_existing_filter(key)
rescue ::Queries::Filters::MissingError => e
Rails.logger.error "Failed to find filter for #{key}: #{e} \n" \
"Falling back to non-existing filter."
non_existing_filter(key)
end
private
def non_existing_filter(key)
Queries::NotExistingFilter.create!(name: key)
end
def get_initialized_filter(key, no_memoization)
filter = find_registered_filter(key)
return unless filter
if no_memoization
filter.create!(name: key)
else
initialize_filter(filter)
find_initialized_filter(key)
end
end
def initialize_filter(filter)
return if already_initialized_filters.include?(filter)
already_initialized_filters << filter
new_filters = filter.all_for(context)
initialized_filters.push(*Array(new_filters))
end
def find_registered_filter(key)
self.class.find_registered_filter(key)
end
def find_initialized_filter(key)
initialized_filters.detect do |f|
f.name == key.to_sym
end
end
def already_initialized_filters
@already_initialized_filters ||= []
end
def initialized_filters
@initialized_filters ||= []
end
def registered_filters
self.class.registered_filters
end
end

@ -38,17 +38,21 @@ class Queries::BaseQuery
end
attr_accessor :filters, :orders
attr_reader :group_by
include Queries::AvailableFilters
include Queries::AvailableOrders
include Queries::Filters::AvailableFilters
include Queries::Orders::AvailableOrders
include Queries::GroupBys::AvailableGroupBys
include ActiveModel::Validations
validate :filters_valid,
:sortation_valid
:sortation_valid,
:group_by_valid
def initialize(user: nil)
@filters = []
@orders = []
@group_by = nil
@user = user
end
@ -60,6 +64,19 @@ class Queries::BaseQuery
end
end
def groups
return nil if group_by.nil?
return empty_scope unless valid?
apply_group_by(apply_filters(default_scope))
.select(group_by.name, Arel.sql('COUNT(*)'))
end
def group_values
groups_hash = groups.pluck(group_by.name, Arel.sql('COUNT(*)')).to_h
instantiate_group_keys groups_hash
end
def where(attribute, operator, values)
filter = filter_for(attribute)
filter.operator = operator
@ -81,6 +98,12 @@ class Queries::BaseQuery
self
end
def group(attribute)
self.group_by = group_by_for(attribute)
self
end
def default_scope
self.class.model.all
end
@ -100,6 +123,7 @@ class Queries::BaseQuery
protected
attr_accessor :user
attr_writer :group_by
def filters_valid
filters.each do |filter|
@ -117,13 +141,19 @@ class Queries::BaseQuery
end
end
def group_by_valid
return if group_by.nil? || group_by.valid?
add_error(:group_by, group_by.name, group_by)
end
def add_error(local_attribute, attribute_name, object)
messages = object
.errors
.messages
.values
.flatten
.join(" #{I18n.t('support.array.sentence_connector')} ")
.errors
.messages
.values
.flatten
.join(" #{I18n.t('support.array.sentence_connector')} ")
errors.add local_attribute, errors.full_message(attribute_name, messages)
end
@ -145,7 +175,7 @@ class Queries::BaseQuery
end
def apply_orders(scope)
orders.each do |order|
build_orders.each do |order|
scope = scope.merge(order.scope)
end
@ -156,6 +186,40 @@ class Queries::BaseQuery
already_ordered_by_id?(scope) ? scope : scope.order(id: :desc)
end
def apply_group_by(scope)
return scope if group_by.nil?
scope
.merge(group_by.scope)
.order(group_by.name)
end
def build_orders
return orders if group_by.nil? || has_group_by_order?
[group_by_order] + orders
end
def has_group_by_order?
!!group_by && orders.detect { |order| order.class.key == group_by.order_key }
end
def group_by_order
order_for(group_by.order_key).tap do |order|
order.direction = :asc
end
end
def instantiate_group_keys(groups)
return groups unless group_by&.association_class
ar_keys = group_by.association_class.where(id: groups.keys.compact)
groups.transform_keys do |key|
ar_keys.detect { |ar_key| ar_key.id == key } || "#{key} #{I18n.t(:label_not_found)}"
end
end
def already_ordered_by_id?(scope)
scope.order_values.any? do |order|
order.respond_to?(:value) && order.value.respond_to?(:relation) &&

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::Capabilities::Orders::IdOrder < Queries::BaseOrder
class Queries::Capabilities::Orders::IdOrder < Queries::Orders::Base
self.model = Capability
def self.key

@ -0,0 +1,131 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
require_dependency 'queries/filters'
module Queries
module Filters
module AvailableFilters
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def registered_filters
::Queries::Register.filters[self]
end
def find_registered_filter(key)
registered_filters.detect do |f|
f.key === key.to_sym
end
end
end
def available_filters
uninitialized = registered_filters - already_initialized_filters
uninitialized.each do |filter|
initialize_filter(filter)
end
initialized_filters.select(&:available?)
end
def filter_for(key, no_memoization: false)
filter = get_initialized_filter(key, no_memoization)
raise ::Queries::Filters::MissingError if filter.nil?
filter
rescue ::Queries::Filters::InvalidError => e
Rails.logger.error "Failed to register filter for #{key}: #{e} \n" \
"Falling back to non-existing filter."
non_existing_filter(key)
rescue ::Queries::Filters::MissingError => e
Rails.logger.error "Failed to find filter for #{key}: #{e} \n" \
"Falling back to non-existing filter."
non_existing_filter(key)
end
private
def non_existing_filter(key)
::Queries::Filters::NotExistingFilter.create!(name: key)
end
def get_initialized_filter(key, no_memoization)
filter = find_registered_filter(key)
return unless filter
if no_memoization
filter.create!(name: key)
else
initialize_filter(filter)
find_initialized_filter(key)
end
end
def initialize_filter(filter)
return if already_initialized_filters.include?(filter)
already_initialized_filters << filter
new_filters = filter.all_for(context)
initialized_filters.push(*Array(new_filters))
end
def find_registered_filter(key)
self.class.find_registered_filter(key)
end
def find_initialized_filter(key)
initialized_filters.detect do |f|
f.name == key.to_sym
end
end
def already_initialized_filters
@already_initialized_filters ||= []
end
def initialized_filters
@initialized_filters ||= []
end
def registered_filters
self.class.registered_filters
end
end
end
end

@ -0,0 +1,85 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
module Queries
module Filters
class NotExistingFilter < Base
def available?
false
end
def type
:inexistent
end
def self.key
:not_existent
end
def human_name
name.to_s.presence || type
end
validate :always_false
def always_false
errors.add :base, I18n.t(:'activerecord.errors.messages.does_not_exist')
end
# deactivating superclass validation
def validate_inclusion_of_operator; end
def to_hash
{
non_existent_filter: {
operator: operator,
values: values
}
}
end
def scope
# TODO: remove switch once the WP query is a
# subclass of Queries::Base
model = if context.respond_to?(:model)
context.model
else
WorkPackage
end
model.unscoped
end
def attributes_hash
nil
end
end
end
end

@ -41,7 +41,7 @@ module Queries
create!(name, filter_hash[field])
rescue ::Queries::Filters::InvalidError
Rails.logger.error "Failed to constantize field filter #{field} from hash."
::Queries::NotExistingFilter.create!(field)
::Queries::Filters::NotExistingFilter.create!(field)
end
end
end

@ -0,0 +1,51 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
module Queries
module GroupBys
module AvailableGroupBys
def group_by_for(key)
(find_registered_group_by(key) || ::Queries::GroupBys::NotExistingGroupBy).new(key)
end
private
def find_registered_group_by(key)
group_by_register.detect do |s|
s.key === key.to_sym
end
end
def group_by_register
::Queries::Register.group_bys[self.class]
end
end
end
end

@ -0,0 +1,82 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
module Queries
module GroupBys
class Base
include ActiveModel::Validations
def self.i18n_scope
:activerecord
end
class_attribute :model
attr_accessor :attribute
def initialize(attribute)
self.attribute = attribute
end
def self.key
raise NotImplementedError
end
def association_class
nil
end
def scope
scope = model
scope = model.joins(joins) if joins
group_by scope
end
def name
attribute
end
def joins
nil
end
# Default to the same key for order
# as the one for group
def order_key
self.class.key
end
protected
def group_by(scope)
scope.group(name)
end
end
end
end

@ -0,0 +1,47 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
module Queries
module GroupBys
class NotExistingGroupBy < Base
validate :always_false
def self.key
:inexistent
end
private
def always_false
errors.add :base, I18n.t(:'activerecord.errors.messages.does_not_exist')
end
end
end
end

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::Groups::Orders::DefaultOrder < Queries::BaseOrder
class Queries::Groups::Orders::DefaultOrder < Queries::Orders::Base
self.model = Group
def self.key

@ -27,7 +27,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::IndividualPrincipals::Orders::GroupOrder < Queries::BaseOrder
class Queries::IndividualPrincipals::Orders::GroupOrder < Queries::Orders::Base
self.model = Principal
def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::IndividualPrincipals::Orders::NameOrder < Queries::BaseOrder
class Queries::IndividualPrincipals::Orders::NameOrder < Queries::Orders::Base
self.model = Principal
def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::Members::Orders::DefaultOrder < Queries::BaseOrder
class Queries::Members::Orders::DefaultOrder < Queries::Orders::Base
self.model = Member
def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::Members::Orders::EmailOrder < Queries::BaseOrder
class Queries::Members::Orders::EmailOrder < Queries::Orders::Base
self.model = Member
def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::Members::Orders::NameOrder < Queries::BaseOrder
class Queries::Members::Orders::NameOrder < Queries::Orders::Base
self.model = Member
def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::Members::Orders::StatusOrder < Queries::BaseOrder
class Queries::Members::Orders::StatusOrder < Queries::Orders::Base
self.model = Member
def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::News::Orders::DefaultOrder < Queries::BaseOrder
class Queries::News::Orders::DefaultOrder < Queries::Orders::Base
self.model = News
def self.key

@ -39,8 +39,15 @@ module Queries::Notifications
[Queries::Notifications::Orders::DefaultOrder,
Queries::Notifications::Orders::ReasonOrder,
Queries::Notifications::Orders::ProjectOrder,
Queries::Notifications::Orders::ReadIanOrder].each do |order|
Queries::Register.order Queries::Notifications::NotificationQuery,
order
end
[Queries::Notifications::GroupBys::GroupByReason,
Queries::Notifications::GroupBys::GroupByProject].each do |group|
Queries::Register.group_by Queries::Notifications::NotificationQuery,
group
end
end

@ -28,59 +28,18 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::BaseOrder
include ActiveModel::Validations
VALID_DIRECTIONS = %i(asc desc).freeze
def self.i18n_scope
:activerecord
end
validates :direction, inclusion: { in: VALID_DIRECTIONS }
class_attribute :model
attr_accessor :direction,
:attribute
def initialize(attribute)
self.attribute = attribute
end
class Queries::Notifications::GroupBys::GroupByProject < Queries::GroupBys::Base
self.model = Notification
def self.key
raise NotImplementedError
end
def scope
scope = order
scope = scope.joins(joins) if joins
scope = scope.left_outer_joins(left_outer_joins) if left_outer_joins
scope
:project
end
def name
attribute
end
private
def order
model.order(name => direction)
end
def joins
nil
end
def left_outer_joins
nil
:project_id
end
def with_raise_on_invalid
if VALID_DIRECTIONS.include?(direction)
yield
else
raise ArgumentError, "Only one of #{VALID_DIRECTIONS} allowed. #{direction} is provided."
end
def association_class
Project
end
end

@ -0,0 +1,41 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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 Queries::Notifications::GroupBys::GroupByReason < Queries::GroupBys::Base
self.model = Notification
def self.key
:reason
end
def name
:reason_ian
end
end

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::Notifications::Orders::DefaultOrder < Queries::BaseOrder
class Queries::Notifications::Orders::DefaultOrder < Queries::Orders::Base
self.model = Notification
def self.key

@ -28,54 +28,23 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::NotExistingFilter < Queries::Filters::Base
def available?
false
end
def type
:inexistent
end
class Queries::Notifications::Orders::ProjectOrder < Queries::Orders::Base
self.model = Notification
def self.key
:not_existent
end
def human_name
name.to_s.blank? ? type : name.to_s
:project
end
validate :always_false
def always_false
errors.add :base, I18n.t(:'activerecord.errors.messages.does_not_exist')
def joins
:project
end
# deactivating superclass validation
def validate_inclusion_of_operator; end
def to_hash
{
non_existent_filter: {
operator: operator,
values: values
}
}
end
protected
def scope
# TODO: remove switch once the WP query is a
# subclass of Queries::Base
model = if context.respond_to?(:model)
context.model
else
WorkPackage
end
model.unscoped
end
def order
order_string = "projects.name"
order_string += " DESC" if direction == :desc
def attributes_hash
nil
model.order(order_string)
end
end

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::Notifications::Orders::ReadIanOrder < Queries::BaseOrder
class Queries::Notifications::Orders::ReadIanOrder < Queries::Orders::Base
self.model = Notification
def self.key

@ -28,10 +28,14 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::Notifications::Orders::ReasonOrder < Queries::BaseOrder
class Queries::Notifications::Orders::ReasonOrder < Queries::Orders::Base
self.model = Notification
def self.key
:reason
end
def name
:reason_ian
end
end

@ -28,20 +28,24 @@
# See COPYRIGHT and LICENSE files for more details.
#++
module Queries::AvailableOrders
def order_for(key)
(find_registered_order(key) || Queries::NotExistingOrder).new(key)
end
module Queries
module Orders
module AvailableOrders
def order_for(key)
(find_registered_order(key) || ::Queries::Orders::NotExistingOrder).new(key)
end
private
private
def find_registered_order(key)
orders_register.detect do |s|
s.key === key.to_sym
end
end
def find_registered_order(key)
orders_register.detect do |s|
s.key === key.to_sym
end
end
def orders_register
Queries::Register.orders[self.class]
def orders_register
::Queries::Register.orders[self.class]
end
end
end
end

@ -0,0 +1,90 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
module Queries
module Orders
class Base
include ActiveModel::Validations
VALID_DIRECTIONS = %i(asc desc).freeze
def self.i18n_scope
:activerecord
end
validates :direction, inclusion: { in: VALID_DIRECTIONS }
class_attribute :model
attr_accessor :direction,
:attribute
def initialize(attribute)
self.attribute = attribute
end
def self.key
raise NotImplementedError
end
def scope
scope = order
scope = scope.joins(joins) if joins
scope = scope.left_outer_joins(left_outer_joins) if left_outer_joins
scope
end
def name
attribute
end
private
def order
model.order(name => direction)
end
def joins
nil
end
def left_outer_joins
nil
end
def with_raise_on_invalid
if VALID_DIRECTIONS.include?(direction)
yield
else
raise ArgumentError, "Only one of #{VALID_DIRECTIONS} allowed. #{direction} is provided."
end
end
end
end
end

@ -28,16 +28,20 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::NotExistingOrder < Queries::BaseOrder
validate :always_false
module Queries
module Orders
class NotExistingOrder < Base
validate :always_false
def self.key
:inexistent
end
def self.key
:inexistent
end
private
private
def always_false
errors.add :base, I18n.t(:'activerecord.errors.messages.does_not_exist')
def always_false
errors.add :base, I18n.t(:'activerecord.errors.messages.does_not_exist')
end
end
end
end

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::PlaceholderUsers::Orders::DefaultOrder < Queries::BaseOrder
class Queries::PlaceholderUsers::Orders::DefaultOrder < Queries::Orders::Base
self.model = PlaceholderUser
def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::Principals::Orders::NameOrder < Queries::BaseOrder
class Queries::Principals::Orders::NameOrder < Queries::Orders::Base
self.model = Principal
def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::Projects::Orders::CustomFieldOrder < Queries::BaseOrder
class Queries::Projects::Orders::CustomFieldOrder < Queries::Orders::Base
self.model = Project.all
validates :custom_field, presence: { message: I18n.t(:'activerecord.errors.messages.does_not_exist') }

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::Projects::Orders::DefaultOrder < Queries::BaseOrder
class Queries::Projects::Orders::DefaultOrder < Queries::Orders::Base
self.model = Project
def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::Projects::Orders::LatestActivityAtOrder < Queries::BaseOrder
class Queries::Projects::Orders::LatestActivityAtOrder < Queries::Orders::Base
self.model = Project
def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::Projects::Orders::NameOrder < Queries::BaseOrder
class Queries::Projects::Orders::NameOrder < Queries::Orders::Base
self.model = Project
def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::Projects::Orders::ProjectStatusOrder < Queries::BaseOrder
class Queries::Projects::Orders::ProjectStatusOrder < Queries::Orders::Base
self.model = Project
def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::Projects::Orders::RequiredDiskSpaceOrder < Queries::BaseOrder
class Queries::Projects::Orders::RequiredDiskSpaceOrder < Queries::Orders::Base
self.model = Project
def self.key

@ -46,6 +46,14 @@ module Queries::Register
@orders[query] << order
end
def group_by(query, group_by)
@group_bys ||= Hash.new do |hash, group_key|
hash[group_key] = []
end
@group_bys[query] << group_by
end
def column(query, column)
@columns ||= Hash.new do |hash, column_key|
hash[column_key] = []
@ -60,6 +68,7 @@ module Queries::Register
attr_accessor :filters,
:orders,
:columns
:columns,
:group_bys
end
end

@ -31,7 +31,7 @@
module Queries
module Relations
module Orders
class DefaultOrder < ::Queries::BaseOrder
class DefaultOrder < ::Queries::Orders::Base
self.model = Relation
def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::Users::Orders::DefaultOrder < Queries::BaseOrder
class Queries::Users::Orders::DefaultOrder < Queries::Orders::Base
self.model = User
def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::Versions::Orders::NameOrder < Queries::BaseOrder
class Queries::Versions::Orders::NameOrder < Queries::Orders::Base
self.model = Version
def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::Versions::Orders::SemverNameOrder < Queries::BaseOrder
class Queries::Versions::Orders::SemverNameOrder < Queries::Orders::Base
self.model = Version
def self.key

@ -29,8 +29,8 @@
#++
module Queries::WorkPackages::FilterSerializer
extend Queries::AvailableFilters
extend Queries::AvailableFilters::ClassMethods
extend Queries::Filters::AvailableFilters
extend Queries::Filters::AvailableFilters::ClassMethods
def self.load(serialized_filter_hash)
return [] if serialized_filter_hash.nil?
@ -41,7 +41,7 @@ module Queries::WorkPackages::FilterSerializer
(YAML.load(yaml) || {}).each_with_object([]) do |(field, options), array|
options = options.with_indifferent_access
filter = filter_for(field, true)
filter = filter_for(field, no_memoization: true)
filter.operator = options['operator']
filter.values = options['values']
array << filter

@ -32,7 +32,7 @@ class Query < ApplicationRecord
include Timelines
include Highlighting
include ManualSorting
include Queries::AvailableFilters
include Queries::Filters::AvailableFilters
belongs_to :project
belongs_to :user

@ -104,7 +104,9 @@ module API
sums = generate_group_sums
results.work_package_count_by_group.map do |group, count|
::API::Decorators::AggregationGroup.new(group, count, query: query, sums: sums[group], current_user: current_user)
::API::V3::WorkPackages::WorkPackageAggregationGroup.new(
group, count, query: query, sums: sums[group], current_user: current_user
)
end
end

@ -40,6 +40,7 @@ class ParamsToQueryService
query = apply_filters(query, params)
apply_order(query, params)
apply_group_by(query, params)
end
private
@ -74,6 +75,14 @@ class ParamsToQueryService
query.order(hash_sort)
end
def apply_group_by(query, params)
return query unless params[:groupBy]
group_by = convert_attribute(params[:groupBy])
query.group(group_by)
end
# Expected format looks like:
# [
# {

@ -32,6 +32,18 @@ get:
required: false
schema:
type: string
- description: |-
string specifying group_by criteria.
+ reason: Group by notification reason
+ project: Sort by associated project
example: 'reason'
in: query
name: groupBy
required: false
schema:
type: string
- description: |-
JSON specifying filter conditions.
Accepts the same format as returned by the [queries](https://www.openproject.org/docs/api/endpoints/queries/) endpoint. Currently supported filters are:

@ -31,9 +31,8 @@
module API
module Decorators
class AggregationGroup < Single
def initialize(group_key, count, query:, current_user:, sums: nil)
def initialize(group_key, count, query:, current_user:)
@count = count
@sums = sums
@query = query
if group_key.is_a?(Array)
@ -55,15 +54,6 @@ module API
end
end
link :groupBy do
converted_name = convert_attribute(query.group_by_column.name)
{
href: api_v3_paths.query_group_by(converted_name),
title: query.group_by_column.caption
}
end
property :value,
exec_context: :decorator,
render_nil: true
@ -73,23 +63,11 @@ module API
getter: ->(*) { count },
render_nil: true
property :sums,
exec_context: :decorator,
getter: ->(*) {
::API::V3::WorkPackages::WorkPackageSumsRepresenter.create(sums, current_user) if sums
},
render_nil: false
def has_sums?
sums.present?
end
def model_required?
false
end
attr_reader :sums,
:count,
attr_reader :count,
:query
##
@ -104,17 +82,13 @@ module API
}
end
if group_key.empty?
nil
else
if group_key.present?
group_key.map(&:name).sort.join(", ")
end
end
def value
if query.group_by_column.name == :done_ratio
"#{represented}%"
elsif represented == true || represented == false
if represented == true || represented == false
represented
else
represented ? represented.to_s : nil

@ -33,8 +33,9 @@ module API
class Collection < ::API::Decorators::Single
include API::Utilities::UrlHelper
def initialize(models, total, self_link:, current_user:)
def initialize(models, total, self_link:, current_user:, groups: nil)
@total = total
@groups = groups
@self_link = self_link
super(models, current_user: current_user)
@ -69,6 +70,10 @@ module API
property :total, getter: ->(*) { @total }, exec_context: :decorator
property :count, getter: ->(*) { count }
property :groups,
exec_context: :decorator,
render_nil: false
collection :elements,
getter: ->(*) {
represented.map do |model|
@ -81,6 +86,8 @@ module API
def _type
'Collection'
end
attr_reader :groups
end
end
end

@ -37,7 +37,7 @@ module API
relation.base_class.per_page
end
def initialize(models, self_link:, current_user:, query: {}, page: nil, per_page: nil)
def initialize(models, self_link:, current_user:, query: {}, page: nil, per_page: nil, groups: nil)
@self_link_base = self_link
@query = query
@page = page.to_i > 0 ? page.to_i : 1
@ -46,7 +46,7 @@ module API
full_self_link = make_page_link(page: @page, page_size: @per_page)
paged = paged_models(models)
super(paged, models.count, self_link: full_self_link, current_user: current_user)
super(paged, models.count, self_link: full_self_link, current_user: current_user, groups: groups)
end
link :jumpTo do

@ -88,6 +88,7 @@ module API
query: resulting_params,
page: resulting_params[:offset],
per_page: resulting_params[:pageSize],
groups: calculate_groups(query),
current_user: User.current)
end
@ -105,6 +106,14 @@ module API
end
end
def calculate_groups(query)
return unless query.group_by
query.group_values.map do |group, count|
::API::Decorators::AggregationGroup.new(group, count, query: query, current_user: User.current)
end
end
def calculate_default_params(query)
::API::Decorators::QueryParamsRepresenter
.new(query)

@ -490,10 +490,11 @@ module API
"#{project(project_id)}/work_packages"
end
def self.path_for(path, filters: nil, sort_by: nil, page_size: nil)
def self.path_for(path, filters: nil, sort_by: nil, group_by: nil, page_size: nil)
query_params = {
filters: filters&.to_json,
sortBy: sort_by&.to_json,
groupBy: group_by,
pageSize: page_size
}.reject { |_, v| v.blank? }

@ -51,6 +51,8 @@ module API
# since not all things are equally named between APIv3 and the rails code,
# we need to convert some names manually
case record
when Project
:project
when IssuePriority
:priority
when AnonymousUser, DeletedUser, SystemUser

@ -0,0 +1,73 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
module API
module V3
module WorkPackages
class WorkPackageAggregationGroup < ::API::Decorators::AggregationGroup
def initialize(group_key, count, query:, current_user:, sums: nil)
@sums = sums
super(group_key, count, query: query, current_user: current_user)
end
property :sums,
exec_context: :decorator,
getter: ->(*) {
::API::V3::WorkPackages::WorkPackageSumsRepresenter.create(sums, current_user) if sums
},
render_nil: false
link :groupBy do
converted_name = convert_attribute(query.group_by_column.name)
{
href: api_v3_paths.query_group_by(converted_name),
title: query.group_by_column.caption
}
end
def has_sums?
sums.present?
end
attr_reader :sums
def value
if query.group_by_column.name == :done_ratio
"#{represented}%"
else
super
end
end
end
end
end
end

@ -40,7 +40,6 @@ module API
per_page: nil,
embed_schemas: false)
@project = project
@groups = groups
@total_sums = total_sums
@embed_schemas = embed_schemas
@ -49,6 +48,7 @@ module API
query: query,
page: page,
per_page: per_page,
groups: groups,
current_user: current_user)
# In order to optimize performance we
@ -145,10 +145,6 @@ module API
embedded: true,
render_nil: false
property :groups,
exec_context: :decorator,
render_nil: false
property :total_sums,
exec_context: :decorator,
getter: ->(*) {
@ -281,7 +277,6 @@ module API
end
attr_reader :project,
:groups,
:total_sums,
:embed_schemas
end

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::TimeEntries::Orders::DefaultOrder < Queries::BaseOrder
class Queries::TimeEntries::Orders::DefaultOrder < Queries::Orders::Base
self.model = TimeEntry
def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Queries::Documents::Orders::DefaultOrder < Queries::BaseOrder
class Queries::Documents::Orders::DefaultOrder < Queries::Orders::Base
self.model = Document
def self.key

@ -0,0 +1,84 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 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.
#++
require 'spec_helper'
describe ::API::V3::Notifications::NotificationCollectionRepresenter do
let(:self_base_link) { '/api/v3/notifications' }
let(:user) { FactoryBot.build_stubbed :user }
let(:notifications) do
FactoryBot.build_stubbed_list(:notification,
3).tap do |items|
allow(items)
.to receive(:per_page)
.with(page_size)
.and_return(items)
allow(items)
.to receive(:page)
.with(page)
.and_return(items)
end
end
let(:current_user) { FactoryBot.build_stubbed(:user) }
let(:representer) do
described_class.new(notifications,
self_link: self_base_link,
per_page: page_size,
page: page,
groups: groups,
current_user: current_user)
end
let(:total) { 3 }
let(:page) { 1 }
let(:page_size) { 2 }
let(:actual_count) { 3 }
let(:collection_inner_type) { 'Notification' }
let(:groups) { nil }
include API::V3::Utilities::PathHelper
describe 'generation' do
subject(:collection) { representer.to_json }
it_behaves_like 'offset-paginated APIv3 collection', 3, 'notifications', 'Notification'
context 'when passing groups' do
let(:groups) do
[
{ value: 'mentioned', count: 34 },
{ value: 'involved', count: 5 }
]
end
it 'renders the groups object as json' do
expect(subject).to be_json_eql(groups.to_json).at_path('groups')
end
end
end
end

@ -28,7 +28,7 @@
require 'spec_helper'
describe Queries::AvailableFilters, type: :model do
describe Queries::Filters::AvailableFilters, type: :model do
let(:context) { FactoryBot.build_stubbed(:project) }
let(:register) { Queries::FilterRegister }
@ -39,7 +39,7 @@ describe Queries::AvailableFilters, type: :model do
self.context = context
end
include Queries::AvailableFilters
include Queries::Filters::AvailableFilters
end
let(:includer) do
@ -53,24 +53,24 @@ describe Queries::AvailableFilters, type: :model do
end
describe '#filter_for' do
let(:filter_1_available) { true }
let(:filter_2_available) { true }
let(:filter_1_key) { :filter_1 }
let(:filter_2_key) { /f_\d+/ }
let(:filter_1_name) { :filter_1 }
let(:filter_2_name) { :f_1 }
let(:registered_filters) { [filter_1, filter_2] }
let(:filter1_available) { true }
let(:filter2_available) { true }
let(:filter1_key) { :filter1 }
let(:filter2_key) { /f_\d+/ }
let(:filter1_name) { :filter1 }
let(:filter2_name) { :f1 }
let(:registered_filters) { [filter1, filter_2] }
let(:filter_1_instance) do
instance = double("filter_1_instance")
let(:filter1_instance) do
instance = double("filter1_instance") # rubocop:disable Rspec/VerifiedDoubles
allow(instance)
.to receive(:available?)
.and_return(:filter_1_available)
.and_return(:filter1_available)
allow(instance)
.to receive(:name)
.and_return(:filter_1)
.and_return(filter1_name)
allow(instance)
.to receive(:name=)
@ -78,35 +78,35 @@ describe Queries::AvailableFilters, type: :model do
instance
end
let(:filter_1) do
filter = double('filter_1')
let(:filter1) do
filter = double('filter1') # rubocop:disable Rspec/VerifiedDoubles
allow(filter)
.to receive(:key)
.and_return(:filter_1)
.and_return(filter1_key)
allow(filter)
.to receive(:create!)
.and_return(filter_1_instance)
.and_return(filter1_instance)
allow(filter)
.to receive(:all_for)
.with(context)
.and_return(filter_1_instance)
.and_return(filter1_instance)
filter
end
let(:filter_2_instance) do
instance = double("filter_2_instance")
instance = double("filter_2_instance") # rubocop:disable Rspec/VerifiedDoubles
allow(instance)
.to receive(:available?)
.and_return(:filter_2_available)
.and_return(filter2_available)
allow(instance)
.to receive(:name)
.and_return(:f_1)
.and_return(:f1)
allow(instance)
.to receive(:name=)
@ -115,11 +115,11 @@ describe Queries::AvailableFilters, type: :model do
end
let(:filter_2) do
filter = double('filter_2')
filter = double('filter_2') # rubocop:disable Rspec/VerifiedDoubles
allow(filter)
.to receive(:key)
.and_return(/f_\d+/)
.and_return(/f\d+/)
allow(filter)
.to receive(:all_for)
@ -131,7 +131,7 @@ describe Queries::AvailableFilters, type: :model do
context 'for a filter identified by a symbol' do
let(:filter_3_available) { true }
let(:registered_filters) { [filter_3, filter_1, filter_2] }
let(:registered_filters) { [filter_3, filter1, filter_2] }
# As we use regexp to find the filters
# we have to ensure that a filter identified a substring symbol
@ -161,24 +161,24 @@ describe Queries::AvailableFilters, type: :model do
let(:filter_3_available) { false }
it 'returns an instance of the matching filter' do
expect(includer.filter_for(:filter_1)).to eql filter_1_instance
expect(includer.filter_for(:filter1)).to eql filter1_instance
end
it 'returns the NotExistingFilter if the name is not matched' do
expect(includer.filter_for(:not_a_filter_name)).to be_a Queries::NotExistingFilter
expect(includer.filter_for(:not_a_filter_name)).to be_a Queries::Filters::NotExistingFilter
end
end
context 'if not available' do
let(:filter_1_available) { false }
let(:filter1_available) { false }
let(:filter_3_available) { true }
it 'returns the NotExistingFilter if the name is not matched' do
expect(includer.filter_for(:not_a_filter_name)).to be_a Queries::NotExistingFilter
expect(includer.filter_for(:not_a_filter_name)).to be_a Queries::Filters::NotExistingFilter
end
it 'returns an instance of the matching filter if not caring for availablility' do
expect(includer.filter_for(:filter_1, true)).to eql filter_1_instance
expect(includer.filter_for(:filter1, no_memoization: true)).to eql filter1_instance
end
end
end
@ -186,23 +186,23 @@ describe Queries::AvailableFilters, type: :model do
context 'for a filter identified by a regexp' do
context 'if available' do
it 'returns an instance of the matching filter' do
expect(includer.filter_for(:f_1)).to eql filter_2_instance
expect(includer.filter_for(:f1)).to eql filter_2_instance
end
it 'returns the NotExistingFilter if the key is not matched' do
expect(includer.filter_for(:f_i1)).to be_a Queries::NotExistingFilter
expect(includer.filter_for(:fi1)).to be_a Queries::Filters::NotExistingFilter
end
it 'returns the NotExistingFilter if the key is matched but the name is not' do
expect(includer.filter_for(:f_2)).to be_a Queries::NotExistingFilter
expect(includer.filter_for(:f2)).to be_a Queries::Filters::NotExistingFilter
end
end
context 'is false if unavailable' do
let(:filter_2_available) { false }
let(:filter2_available) { false }
it 'returns the NotExistingFilter' do
expect(includer.filter_for(:f_i)).to be_a Queries::NotExistingFilter
expect(includer.filter_for(:fi)).to be_a Queries::Filters::NotExistingFilter
end
end
end

@ -114,7 +114,11 @@ describe Queries::Notifications::NotificationQuery, type: :model do
describe '#results' do
it 'is the same as handwriting the query' do
expected = "SELECT \"notifications\".* FROM \"notifications\" WHERE \"notifications\".\"recipient_id\" = #{recipient.id} ORDER BY \"notifications\".\"read_ian\" DESC, \"notifications\".\"id\" DESC"
expected = <<~SQL.squish
SELECT "notifications".* FROM "notifications"
WHERE "notifications"."recipient_id" = #{recipient.id}
ORDER BY "notifications"."read_ian" DESC, "notifications"."id" DESC
SQL
expect(instance.results.to_sql).to eql expected
end
@ -128,7 +132,11 @@ describe Queries::Notifications::NotificationQuery, type: :model do
describe '#results' do
it 'is the same as handwriting the query' do
expected = "SELECT \"notifications\".* FROM \"notifications\" WHERE \"notifications\".\"recipient_id\" = #{recipient.id} ORDER BY \"reason\" DESC, \"notifications\".\"id\" DESC"
expected = <<~SQL.squish
SELECT "notifications".* FROM "notifications"
WHERE "notifications"."recipient_id" = #{recipient.id}
ORDER BY "notifications"."reason_ian" DESC, "notifications"."id" DESC
SQL
expect(instance.results.to_sql).to eql expected
end
@ -154,4 +162,62 @@ describe Queries::Notifications::NotificationQuery, type: :model do
end
end
end
context 'with a reason group_by' do
before do
instance.group(:reason)
end
describe '#results' do
it 'is the same as handwriting the query' do
expected = <<~SQL.squish
SELECT "notifications"."reason_ian", COUNT(*) FROM "notifications"
WHERE "notifications"."recipient_id" = #{recipient.id}
GROUP BY "notifications"."reason_ian"
ORDER BY "notifications"."reason_ian" ASC
SQL
expect(instance.groups.to_sql).to eql expected
end
end
end
context 'with a project group_by' do
before do
instance.group(:project)
end
describe '#results' do
it 'is the same as handwriting the query' do
expected = <<~SQL.squish
SELECT "notifications"."project_id", COUNT(*) FROM "notifications"
WHERE "notifications"."recipient_id" = #{recipient.id}
GROUP BY "notifications"."project_id"
ORDER BY "notifications"."project_id" ASC
SQL
expect(instance.groups.to_sql).to eql expected
end
end
end
context 'with a non existing group_by' do
before do
instance.group(:does_not_exist)
end
describe '#results' do
it 'returns a query not returning anything' do
expected = Notification.where(Arel::Nodes::Equality.new(1, 0))
expect(instance.results.to_sql).to eql expected.to_sql
end
end
describe 'valid?' do
it 'is false' do
expect(instance).to be_invalid
end
end
end
end

@ -43,8 +43,18 @@ describe ::API::V3::Notifications::NotificationsAPI,
member_in_project: work_package.project,
member_with_permissions: %i[view_work_packages]
end
shared_let(:notification1) { FactoryBot.create :notification, recipient: recipient, resource: work_package }
shared_let(:notification2) { FactoryBot.create :notification, recipient: recipient, resource: work_package }
shared_let(:notification1) do
FactoryBot.create :notification,
recipient: recipient,
resource: work_package,
project: work_package.project
end
shared_let(:notification2) do
FactoryBot.create :notification,
recipient: recipient,
resource: work_package,
project: work_package.project
end
let(:notifications) { [notification1, notification2] }
@ -129,6 +139,64 @@ describe ::API::V3::Notifications::NotificationsAPI,
end
end
end
context 'with a reason groupBy' do
let(:involved_notification) { FactoryBot.create :notification, recipient: recipient, reason_ian: :involved }
let(:notifications) { [notification1, notification2, involved_notification] }
let(:send_request) do
get api_v3_paths.path_for :notifications, group_by: :reason
end
let(:groups) { parsed_response['groups'] }
it_behaves_like 'API V3 collection response', 3, 3, 'Notification'
it 'contains the reason groups', :aggregate_failures do
expect(groups).to be_a Array
expect(groups.count).to eq 2
keyed = groups.index_by { |el| el['value'] }
expect(keyed.keys).to contain_exactly 'mentioned', 'involved'
expect(keyed['mentioned']['count']).to eq 2
expect(keyed['involved']['count']).to eq 1
end
end
context 'with a project groupBy' do
let(:work_package2) { FactoryBot.create :work_package }
let(:other_project_notification) do
FactoryBot.create :notification,
resource: work_package2,
project: work_package2.project,
recipient: recipient,
reason_ian: :involved
end
let(:notifications) { [notification1, notification2, other_project_notification] }
let(:send_request) do
get api_v3_paths.path_for :notifications, group_by: :project
end
let(:groups) { parsed_response['groups'] }
it_behaves_like 'API V3 collection response', 3, 3, 'Notification'
it 'contains the project groups', :aggregate_failures do
expect(groups).to be_a Array
expect(groups.count).to eq 2
keyed = groups.index_by { |el| el['value'] }
expect(keyed.keys).to contain_exactly work_package2.project.name, work_package.project.name
expect(keyed[work_package.project.name]['count']).to eq 2
expect(keyed[work_package2.project.name]['count']).to eq 1
expect(keyed.dig(work_package.project.name, '_links', 'valueLink')[0]['href'])
.to eq "/api/v3/projects/#{work_package.project.id}"
end
end
end
describe 'admin user' do

@ -159,14 +159,9 @@ describe ::API::V3::WorkPackageCollectionFromQueryService,
describe '#call' do
subject { instance.call(params) }
it 'is successful' do
is_expected
.to be_success
end
before do
stub_const('::API::V3::WorkPackages::WorkPackageCollectionRepresenter', mock_wp_representer)
stub_const('::API::Decorators::AggregationGroup', mock_aggregation_representer)
stub_const('::API::V3::WorkPackages::WorkPackageAggregationGroup', mock_aggregation_representer)
allow(::API::V3::UpdateQueryFromV3ParamsService)
.to receive(:new)
@ -174,11 +169,15 @@ describe ::API::V3::WorkPackageCollectionFromQueryService,
.and_return(mock_update_query_service)
end
it 'is successful' do
expect(subject).to be_success
end
context 'result' do
subject { instance.call(params).result }
it 'is a WorkPackageCollectionRepresenter' do
is_expected
expect(subject)
.to be_a(::API::V3::WorkPackages::WorkPackageCollectionRepresenter)
end

Loading…
Cancel
Save