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. 72
      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. 10
      app/models/queries/orders/available_orders.rb
  26. 90
      app/models/queries/orders/base.rb
  27. 6
      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 end
attr_accessor :filters, :orders attr_accessor :filters, :orders
attr_reader :group_by
include Queries::AvailableFilters include Queries::Filters::AvailableFilters
include Queries::AvailableOrders include Queries::Orders::AvailableOrders
include Queries::GroupBys::AvailableGroupBys
include ActiveModel::Validations include ActiveModel::Validations
validate :filters_valid, validate :filters_valid,
:sortation_valid :sortation_valid,
:group_by_valid
def initialize(user: nil) def initialize(user: nil)
@filters = [] @filters = []
@orders = [] @orders = []
@group_by = nil
@user = user @user = user
end end
@ -60,6 +64,19 @@ class Queries::BaseQuery
end end
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) def where(attribute, operator, values)
filter = filter_for(attribute) filter = filter_for(attribute)
filter.operator = operator filter.operator = operator
@ -81,6 +98,12 @@ class Queries::BaseQuery
self self
end end
def group(attribute)
self.group_by = group_by_for(attribute)
self
end
def default_scope def default_scope
self.class.model.all self.class.model.all
end end
@ -100,6 +123,7 @@ class Queries::BaseQuery
protected protected
attr_accessor :user attr_accessor :user
attr_writer :group_by
def filters_valid def filters_valid
filters.each do |filter| filters.each do |filter|
@ -117,6 +141,12 @@ class Queries::BaseQuery
end end
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) def add_error(local_attribute, attribute_name, object)
messages = object messages = object
.errors .errors
@ -145,7 +175,7 @@ class Queries::BaseQuery
end end
def apply_orders(scope) def apply_orders(scope)
orders.each do |order| build_orders.each do |order|
scope = scope.merge(order.scope) scope = scope.merge(order.scope)
end end
@ -156,6 +186,40 @@ class Queries::BaseQuery
already_ordered_by_id?(scope) ? scope : scope.order(id: :desc) already_ordered_by_id?(scope) ? scope : scope.order(id: :desc)
end 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) def already_ordered_by_id?(scope)
scope.order_values.any? do |order| scope.order_values.any? do |order|
order.respond_to?(:value) && order.value.respond_to?(:relation) && order.respond_to?(:value) && order.value.respond_to?(:relation) &&

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = Capability
def self.key 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]) create!(name, filter_hash[field])
rescue ::Queries::Filters::InvalidError rescue ::Queries::Filters::InvalidError
Rails.logger.error "Failed to constantize field filter #{field} from hash." Rails.logger.error "Failed to constantize field filter #{field} from hash."
::Queries::NotExistingFilter.create!(field) ::Queries::Filters::NotExistingFilter.create!(field)
end end
end 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. # 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 self.model = Group
def self.key def self.key

@ -27,7 +27,7 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = Principal
def self.key def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = Principal
def self.key def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = Member
def self.key def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = Member
def self.key def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = Member
def self.key def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = Member
def self.key def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = News
def self.key def self.key

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

@ -28,59 +28,18 @@
# See COPYRIGHT and LICENSE files for more details. # See COPYRIGHT and LICENSE files for more details.
#++ #++
class Queries::BaseOrder class Queries::Notifications::GroupBys::GroupByProject < Queries::GroupBys::Base
include ActiveModel::Validations self.model = Notification
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 def self.key
raise NotImplementedError :project
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 end
def name def name
attribute :project_id
end end
private def association_class
Project
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

@ -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. # 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 self.model = Notification
def self.key def self.key

@ -28,54 +28,23 @@
# See COPYRIGHT and LICENSE files for more details. # See COPYRIGHT and LICENSE files for more details.
#++ #++
class Queries::NotExistingFilter < Queries::Filters::Base class Queries::Notifications::Orders::ProjectOrder < Queries::Orders::Base
def available? self.model = Notification
false
end
def type
:inexistent
end
def self.key def self.key
:not_existent :project
end end
def human_name def joins
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')
end end
# deactivating superclass validation protected
def validate_inclusion_of_operator; end
def to_hash def order
{ order_string = "projects.name"
non_existent_filter: { order_string += " DESC" if direction == :desc
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 model.order(order_string)
nil
end end
end end

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = Notification
def self.key def self.key

@ -28,10 +28,14 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = Notification
def self.key def self.key
:reason :reason
end end
def name
:reason_ian
end
end end

@ -28,9 +28,11 @@
# See COPYRIGHT and LICENSE files for more details. # See COPYRIGHT and LICENSE files for more details.
#++ #++
module Queries::AvailableOrders module Queries
module Orders
module AvailableOrders
def order_for(key) def order_for(key)
(find_registered_order(key) || Queries::NotExistingOrder).new(key) (find_registered_order(key) || ::Queries::Orders::NotExistingOrder).new(key)
end end
private private
@ -42,6 +44,8 @@ module Queries::AvailableOrders
end end
def orders_register def orders_register
Queries::Register.orders[self.class] ::Queries::Register.orders[self.class]
end
end
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,7 +28,9 @@
# See COPYRIGHT and LICENSE files for more details. # See COPYRIGHT and LICENSE files for more details.
#++ #++
class Queries::NotExistingOrder < Queries::BaseOrder module Queries
module Orders
class NotExistingOrder < Base
validate :always_false validate :always_false
def self.key def self.key
@ -41,3 +43,5 @@ class Queries::NotExistingOrder < Queries::BaseOrder
errors.add :base, I18n.t(:'activerecord.errors.messages.does_not_exist') errors.add :base, I18n.t(:'activerecord.errors.messages.does_not_exist')
end end
end end
end
end

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = PlaceholderUser
def self.key def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = Principal
def self.key def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = Project.all
validates :custom_field, presence: { message: I18n.t(:'activerecord.errors.messages.does_not_exist') } 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. # 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 self.model = Project
def self.key def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = Project
def self.key def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = Project
def self.key def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = Project
def self.key def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = Project
def self.key def self.key

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

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

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = User
def self.key def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = Version
def self.key def self.key

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = Version
def self.key def self.key

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

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

@ -104,7 +104,9 @@ module API
sums = generate_group_sums sums = generate_group_sums
results.work_package_count_by_group.map do |group, count| 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
end end

@ -40,6 +40,7 @@ class ParamsToQueryService
query = apply_filters(query, params) query = apply_filters(query, params)
apply_order(query, params) apply_order(query, params)
apply_group_by(query, params)
end end
private private
@ -74,6 +75,14 @@ class ParamsToQueryService
query.order(hash_sort) query.order(hash_sort)
end 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: # Expected format looks like:
# [ # [
# { # {

@ -32,6 +32,18 @@ get:
required: false required: false
schema: schema:
type: string 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: |- - description: |-
JSON specifying filter conditions. 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: 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 API
module Decorators module Decorators
class AggregationGroup < Single class AggregationGroup < Single
def initialize(group_key, count, query:, current_user:, sums: nil) def initialize(group_key, count, query:, current_user:)
@count = count @count = count
@sums = sums
@query = query @query = query
if group_key.is_a?(Array) if group_key.is_a?(Array)
@ -55,15 +54,6 @@ module API
end end
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, property :value,
exec_context: :decorator, exec_context: :decorator,
render_nil: true render_nil: true
@ -73,23 +63,11 @@ module API
getter: ->(*) { count }, getter: ->(*) { count },
render_nil: true 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? def model_required?
false false
end end
attr_reader :sums, attr_reader :count,
:count,
:query :query
## ##
@ -104,17 +82,13 @@ module API
} }
end end
if group_key.empty? if group_key.present?
nil
else
group_key.map(&:name).sort.join(", ") group_key.map(&:name).sort.join(", ")
end end
end end
def value def value
if query.group_by_column.name == :done_ratio if represented == true || represented == false
"#{represented}%"
elsif represented == true || represented == false
represented represented
else else
represented ? represented.to_s : nil represented ? represented.to_s : nil

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

@ -37,7 +37,7 @@ module API
relation.base_class.per_page relation.base_class.per_page
end 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 @self_link_base = self_link
@query = query @query = query
@page = page.to_i > 0 ? page.to_i : 1 @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) full_self_link = make_page_link(page: @page, page_size: @per_page)
paged = paged_models(models) 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 end
link :jumpTo do link :jumpTo do

@ -88,6 +88,7 @@ module API
query: resulting_params, query: resulting_params,
page: resulting_params[:offset], page: resulting_params[:offset],
per_page: resulting_params[:pageSize], per_page: resulting_params[:pageSize],
groups: calculate_groups(query),
current_user: User.current) current_user: User.current)
end end
@ -105,6 +106,14 @@ module API
end end
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) def calculate_default_params(query)
::API::Decorators::QueryParamsRepresenter ::API::Decorators::QueryParamsRepresenter
.new(query) .new(query)

@ -490,10 +490,11 @@ module API
"#{project(project_id)}/work_packages" "#{project(project_id)}/work_packages"
end 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 = { query_params = {
filters: filters&.to_json, filters: filters&.to_json,
sortBy: sort_by&.to_json, sortBy: sort_by&.to_json,
groupBy: group_by,
pageSize: page_size pageSize: page_size
}.reject { |_, v| v.blank? } }.reject { |_, v| v.blank? }

@ -51,6 +51,8 @@ module API
# since not all things are equally named between APIv3 and the rails code, # since not all things are equally named between APIv3 and the rails code,
# we need to convert some names manually # we need to convert some names manually
case record case record
when Project
:project
when IssuePriority when IssuePriority
:priority :priority
when AnonymousUser, DeletedUser, SystemUser 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, per_page: nil,
embed_schemas: false) embed_schemas: false)
@project = project @project = project
@groups = groups
@total_sums = total_sums @total_sums = total_sums
@embed_schemas = embed_schemas @embed_schemas = embed_schemas
@ -49,6 +48,7 @@ module API
query: query, query: query,
page: page, page: page,
per_page: per_page, per_page: per_page,
groups: groups,
current_user: current_user) current_user: current_user)
# In order to optimize performance we # In order to optimize performance we
@ -145,10 +145,6 @@ module API
embedded: true, embedded: true,
render_nil: false render_nil: false
property :groups,
exec_context: :decorator,
render_nil: false
property :total_sums, property :total_sums,
exec_context: :decorator, exec_context: :decorator,
getter: ->(*) { getter: ->(*) {
@ -281,7 +277,6 @@ module API
end end
attr_reader :project, attr_reader :project,
:groups,
:total_sums, :total_sums,
:embed_schemas :embed_schemas
end end

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details. # 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 self.model = TimeEntry
def self.key def self.key

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

@ -114,7 +114,11 @@ describe Queries::Notifications::NotificationQuery, type: :model do
describe '#results' do describe '#results' do
it 'is the same as handwriting the query' 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 expect(instance.results.to_sql).to eql expected
end end
@ -128,7 +132,11 @@ describe Queries::Notifications::NotificationQuery, type: :model do
describe '#results' do describe '#results' do
it 'is the same as handwriting the query' 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 expect(instance.results.to_sql).to eql expected
end end
@ -154,4 +162,62 @@ describe Queries::Notifications::NotificationQuery, type: :model do
end end
end 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 end

@ -43,8 +43,18 @@ describe ::API::V3::Notifications::NotificationsAPI,
member_in_project: work_package.project, member_in_project: work_package.project,
member_with_permissions: %i[view_work_packages] member_with_permissions: %i[view_work_packages]
end end
shared_let(:notification1) { FactoryBot.create :notification, recipient: recipient, resource: work_package } shared_let(:notification1) do
shared_let(:notification2) { FactoryBot.create :notification, recipient: recipient, resource: work_package } 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] } let(:notifications) { [notification1, notification2] }
@ -129,6 +139,64 @@ describe ::API::V3::Notifications::NotificationsAPI,
end end
end 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 end
describe 'admin user' do describe 'admin user' do

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

Loading…
Cancel
Save