Fix/wp sums in db (#8580)

* remove apparently unused methods

* have specs for wp query sums

* grouped sums in sql

* sql for total sums

* alter interface of all_grouped_sums

Since we now fetch all sums in one sql statement it no longer makes sense to fetch the group sums individually

* remove now unused method

* extract method

* Add material_costs to summing

* add labor_costs to group sums


* add overall costs to sum

* fix sum grouping descision

* fix summable? check

* remove work_package_list_summable_columns setting

Now all summable columns are always summed. The user no longer needs to select summing up in the settings. Selecting the column to be displayed and activiating sums will suffice

* fix flickering spec
pull/8583/head
ulferts 4 years ago committed by GitHub
parent b8942af11b
commit 38d2707946
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 41
      app/models/queries/columns/base.rb
  2. 87
      app/models/queries/work_packages/columns/custom_field_column.rb
  3. 16
      app/models/queries/work_packages/columns/work_package_column.rb
  4. 10
      app/models/query.rb
  5. 4
      app/models/query/results.rb
  6. 4
      app/models/query/results/group_by.rb
  7. 128
      app/models/query/results/sums.rb
  8. 147
      app/models/query/sums.rb
  9. 10
      app/models/work_package_custom_field.rb
  10. 15
      app/services/api/v3/work_package_collection_from_query_service.rb
  11. 6
      app/views/work_packages/settings/work_package_tracking.html.erb
  12. 1
      config/locales/en.yml
  13. 4
      config/settings.yml
  14. 8
      features/step_definitions/custom_field_steps.rb
  15. 11
      lib/api/v3/utilities/custom_field_sum_injector.rb
  16. 30
      lib/api/v3/work_packages/schema/work_package_sums_schema_representer.rb
  17. 33
      lib/api/v3/work_packages/work_package_sums_representer.rb
  18. 8
      modules/backlogs/lib/open_project/backlogs/engine.rb
  19. 58
      modules/backlogs/lib/open_project/backlogs/patches/api/work_package_sums_representer.rb
  20. 57
      modules/backlogs/lib/open_project/backlogs/patches/api/work_package_sums_schema_representer.rb
  21. 88
      modules/backlogs/spec/api/work_packages/schema/work_package_sums_schema_representer_spec.rb
  22. 80
      modules/backlogs/spec/api/work_packages/work_package_sums_representer_spec.rb
  23. 55
      modules/costs/lib/costs/engine.rb
  24. 49
      modules/costs/lib/costs/query_currency_column.rb
  25. 101
      modules/costs/spec/features/costs_table_sums.rb
  26. 98
      modules/costs/spec/lib/api/v3/work_packages/work_package_sums_representer_spec.rb
  27. 145
      modules/costs/spec/lib/costs/query_currency_column_spec.rb
  28. 80
      spec/features/work_packages/index_sums_spec.rb
  29. 99
      spec/lib/api/v3/work_packages/schema/work_package_sums_schema_representer_spec.rb
  30. 4
      spec/lib/api/v3/work_packages/work_package_collection_representer_spec.rb
  31. 79
      spec/lib/api/v3/work_packages/work_package_sums_representer_spec.rb
  32. 7
      spec/lib/open_project/omni_auth/authorization_spec.rb
  33. 55
      spec/models/queries/work_packages/columns/work_package_column_spec.rb
  34. 277
      spec/models/query/results_sums_integration_spec.rb
  35. 37
      spec/models/work_package_custom_field_spec.rb
  36. 21
      spec/requests/api/v3/work_packages/work_packages_by_project_resource_spec.rb
  37. 6
      spec/requests/api/v3/work_packages/work_packages_schemas_resource_spec.rb
  38. 11
      spec/services/api/v3/work_package_collection_from_query_service_spec.rb

@ -30,16 +30,17 @@
class Queries::Columns::Base
attr_reader :groupable,
:sortable,
:association
:sortable
attr_accessor :name,
:sortable_join,
:summable,
:null_handling,
:default_order
:default_order,
:association
alias_method :summable?, :summable
attr_writer :null_handling,
:summable_select,
:summable_work_packages_select
def initialize(name, options = {})
self.name = name
@ -48,6 +49,8 @@ class Queries::Columns::Base
sortable_join
groupable
summable
summable_select
summable_work_packages_select
association
null_handling
default_order).each do |attribute|
@ -75,10 +78,6 @@ class Queries::Columns::Base
@sortable = name_or_value_or_false(value)
end
def association=(value)
@association = value
end
# Returns true if the column is sortable, otherwise false
def sortable?
!!sortable
@ -89,8 +88,28 @@ class Queries::Columns::Base
!!groupable
end
def value(issue)
issue.send name
def summable?
summable || @summable_select || @summable_work_packages_select
end
def summable_select
@summable_select || name
end
def summable_work_packages_select
if @summable_work_packages_select == false
nil
elsif @summable_work_packages_select
@summable_work_packages_select
elsif summable&.respond_to?(:call)
nil
else
name
end
end
def value(model)
model.send name
end
def self.instances(_context = nil)

@ -32,29 +32,12 @@ class Queries::WorkPackages::Columns::CustomFieldColumn < Queries::WorkPackages:
def initialize(custom_field)
super
set_name! custom_field
set_sortable! custom_field
set_groupable! custom_field
set_summable! custom_field
@cf = custom_field
end
def set_name!(custom_field)
self.name = "cf_#{custom_field.id}".to_sym
end
def set_sortable!(custom_field)
self.sortable = custom_field.order_statements || false
end
def set_groupable!(custom_field)
self.groupable = custom_field.group_by_statements if groupable_custom_field?(custom_field)
self.groupable ||= false
end
def set_summable!(custom_field)
self.summable = %w(float int).include?(custom_field.field_format)
set_name!
set_sortable!
set_groupable!
set_summable!
end
def groupable_custom_field?(custom_field)
@ -77,23 +60,6 @@ class Queries::WorkPackages::Columns::CustomFieldColumn < Queries::WorkPackages:
work_package.formatted_custom_value_for(@cf.id)
end
def sum_of(work_packages)
if work_packages.respond_to?(:joins)
cast = @cf.field_format == 'int' ? 'BIGINT' : 'FLOAT'
CustomValue
.where(customized: work_packages, custom_field: @cf)
.where.not(value: nil)
.where.not(value: '')
.pluck("SUM(value::#{cast})")
.first
else
# TODO: eliminate calls of this method with an Array and drop the :compact call below
ActiveSupport::Deprecation.warn('Passing an array of work packages is deprecated. Pass an AR-relation instead.')
work_packages.map { |wp| wp.typed_custom_value_for(@cf) }.compact.reduce(:+)
end
end
def self.instances(context = nil)
if context
context.all_work_package_custom_fields
@ -103,4 +69,49 @@ class Queries::WorkPackages::Columns::CustomFieldColumn < Queries::WorkPackages:
.reject { |cf| cf.field_format == 'text' }
.map { |cf| new(cf) }
end
private
def set_name!
self.name = "cf_#{custom_field.id}".to_sym
end
def set_sortable!
self.sortable = custom_field.order_statements || false
end
def set_groupable!
self.groupable = custom_field.group_by_statements if groupable_custom_field?(custom_field)
self.groupable ||= false
end
def set_summable!
self.summable = if %w(float int).include?(custom_field.field_format)
select = summable_select_statement
->(query, grouped) {
Queries::WorkPackages::Columns::WorkPackageColumn
.scoped_column_sum(summable_scope(query), select, grouped && query.group_by_statement)
}
else
false
end
end
def summable_scope(query)
WorkPackage
.where(id: query.results.work_packages)
.left_joins(:custom_values)
.where(custom_values: { custom_field: custom_field })
.where.not(custom_values: { value: nil })
.where.not(custom_values: { value: '' })
end
def summable_select_statement
if custom_field.field_format == 'int'
"COALESCE(SUM(value::BIGINT)::BIGINT, 0) #{name}"
else
"COALESCE(ROUND(SUM(value::NUMERIC), 2)::FLOAT, 0.0) #{name}"
end
end
end

@ -41,13 +41,17 @@ class Queries::WorkPackages::Columns::WorkPackageColumn < Queries::Columns::Base
WorkPackage.human_attribute_name(name)
end
def sum_of(work_packages)
if work_packages.is_a?(Array)
# TODO: Sums::grouped_sums might call through here without an AR::Relation
# Ensure that this also calls using a Relation and drop this (slow!) implementation
work_packages.map { |wp| value(wp) }.compact.reduce(:+)
def self.scoped_column_sum(scope, select, group_by)
scope = scope
.except(:order, :select)
if group_by
scope
.group(group_by)
.select("#{group_by} id", select)
else
work_packages.sum(name)
scope
.select(select)
end
end
end

@ -266,6 +266,10 @@ class Query < ApplicationRecord
.merge(column_sortability)
end
def summed_up_columns
available_columns.select(&:summable?)
end
def columns
column_list = if has_default_columns?
column_list = Setting.work_package_list_default_columns.dup.map(&:to_sym)
@ -350,11 +354,7 @@ class Query < ApplicationRecord
end
def display_sums?
display_sums && any_summable_columns?
end
def any_summable_columns?
Setting.work_package_list_summable_columns.any?
display_sums
end
def group_by_column

@ -29,8 +29,8 @@
#++
class ::Query::Results
include ::Query::GroupBy
include ::Query::Sums
include ::Query::Results::GroupBy
include ::Query::Results::Sums
include Redmine::I18n
attr_accessor :query

@ -28,7 +28,7 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
module ::Query::GroupBy
module ::Query::Results::GroupBy
# Returns the work package count by group or nil if query is not grouped
def work_package_count_by_group
@work_package_count_by_group ||= begin
@ -177,7 +177,7 @@ module ::Query::GroupBy
# Retrieve the defined order for the group by
# IF it occurs in the sort criteria
def order_for_group_by(column)
sort_entry = query.sort_criteria.detect { |column, _dir| column == query.group_by }
sort_entry = query.sort_criteria.detect { |c, _dir| c == query.group_by }
order = sort_entry&.last || column.default_order
"#{order} #{column.null_handling(order == 'asc')}"

@ -0,0 +1,128 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
module ::Query::Results::Sums
include ActionView::Helpers::NumberHelper
def all_total_sums
group_sums = sums_select
query.summed_up_columns.inject({}) do |result, column|
value = group_sums.first
result[column] = value[column.name.to_s] unless value.nil?
result
end
end
def all_group_sums
return nil unless query.grouped?
sums_by_id = sums_select(true).inject({}) do |result, group_sum|
result[group_sum['id']] = {}
query.summed_up_columns.each do |column|
result[group_sum['id']][column] = group_sum[column.name.to_s]
end
result
end
transform_group_keys(sums_by_id)
end
private
def sums_select(grouped = false)
select = if grouped
["work_packages.id"]
else
[]
end
select += query.summed_up_columns.map(&:summable_select)
sql = <<~SQL
SELECT #{select.join(', ')}
FROM (#{sums_work_package_scope(grouped).to_sql}) work_packages
#{sums_callable_joins(grouped)}
SQL
connection = ActiveRecord::Base.connection
connection.uncached do
connection.select_all(sql)
end
end
def sums_work_package_scope(grouped)
scope = WorkPackage
.where(id: work_packages)
.except(:order, :select)
.select(sums_work_package_scope_selects(grouped))
if grouped
scope.group(query.group_by_statement)
else
scope
end
end
def sums_callable_joins(grouped)
callable_summed_up_columns
.map do |c|
join_condition = if grouped
"#{c.name}.id = work_packages.id OR #{c.name}.id IS NULL AND work_packages.id IS NULL"
else
"TRUE"
end
"LEFT OUTER JOIN (#{c.summable.(query, grouped).to_sql}) #{c.name} ON #{join_condition}"
end
.join(' ')
end
def sums_work_package_scope_selects(grouped)
select = if grouped
["#{query.group_by_statement} id"]
else
[]
end
select + query.summed_up_columns.map(&:summable_work_packages_select).compact.map { |c| "SUM(#{c}) #{c}" }
end
def callable_summed_up_columns
query.summed_up_columns.select { |column| column.summable.respond_to?(:call) }
end
def non_callable_summed_up_columns
query.summed_up_columns.map { |column| column.summable.respond_to?(:call) }
end
end

@ -1,147 +0,0 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
module ::Query::Sums
include ActionView::Helpers::NumberHelper
def next_in_same_group?(issue = cached_issue)
caching_issue issue do |issue|
!last_issue? &&
query.group_by_column.value(issue) == query.group_by_column.value(work_packages[issue_index + 1])
end
end
def last_issue?(issue = cached_issue)
caching_issue issue do |_issue|
issue_index == work_packages.size - 1
end
end
def issue_index(issue = cached_issue)
caching_issue issue do |issue|
work_packages.find_index(issue)
end
end
def grouped_sum_of_issue(column, issue = cached_issue)
grouped_sum_of column, group_for_issue(issue)
end
def grouped_sum_of(column, group)
sum_of column, group
end
def grouped_sums(column)
work_packages
.map { |wp| query.group_by_column.value(wp) }
.uniq
.inject({}) do |group_sums, current_group|
work_packages_in_current_group = work_packages.select { |wp| query.group_by_column.value(wp) == current_group }
# TODO: sum_of only works fast when passing an AR::Relation
group_sums.merge current_group => sum_of(column, work_packages_in_current_group)
end
end
def total_sum_of(column)
sum_of(column, work_packages)
end
def sum_of(column, collection)
return nil unless should_be_summed_up?(column)
sum = column.sum_of(collection)
crunch(sum)
end
def caching_issue(issue)
@cached_issue = issue unless @cached_issue == issue
block_given? ? yield(issue) : issue
end
def cached_issue
@cached_issue
end
def mapping_for(column)
if column.respond_to? :real_value
method(:number_to_currency)
else
# respond_to? :call, but do nothing
@nilproc ||= Proc.new { |val| val }
end
end
def crunch(num)
return num if num.nil? || !num.respond_to?(:integer?) || num.integer?
Float(format('%.2f', num.to_f))
end
def group_for_issue(issue = @current_issue)
caching_issue issue do |issue|
work_packages.select do |is|
query.group_by_column.value(issue) == query.group_by_column.value(is)
end
end
end
def should_be_summed_up?(column)
column.summable? && Setting.work_package_list_summable_columns.include?(column.name.to_s)
end
def column_total_sums
query.columns.map { |column| total_sum_of(column) }
end
def all_total_sums
query.available_columns.select { |column|
should_be_summed_up?(column)
}.inject({}) { |result, column|
sum = total_sum_of(column)
result[column] = sum unless sum.nil?
result
}
end
def all_sums_for_group(group)
return nil unless query.grouped?
group_work_packages = work_packages.select { |wp| query.group_by_column.value(wp) == group }
query.available_columns.inject({}) do |result, column|
sum = sum_of(column, group_work_packages)
result[column] = sum unless sum.nil?
result
end
end
def column_group_sums
query.group_by_column && query.columns.map { |column| grouped_sums(column) }
end
end

@ -50,13 +50,11 @@ class WorkPackageCustomField < CustomField
}
def self.summable
ids = Setting.work_package_list_summable_columns.map do |column_name|
if match = /cf_(\d+)/.match(column_name)
match[1]
end
end.compact
where(field_format: %w[int float])
end
where(id: ids)
def summable?
%w[int float].include?(field_format)
end
def type_name

@ -99,13 +99,10 @@ module API
return unless query.grouped?
results = query.results
sums = generate_group_sums
results.work_package_count_by_group.map do |group, count|
sums = if query.display_sums?
format_query_sums results.all_sums_for_group(group)
end
::API::Decorators::AggregationGroup.new(group, count, query: results.query, sums: sums, current_user: current_user)
::API::Decorators::AggregationGroup.new(group, count, query: query, sums: sums[group], current_user: current_user)
end
end
@ -115,6 +112,14 @@ module API
format_query_sums query.results.all_total_sums
end
def generate_group_sums
return {} unless query.display_sums?
query.results.all_group_sums.transform_values do |v|
format_query_sums(v)
end
end
def format_query_sums(sums)
OpenStruct.new(format_column_keys(sums).merge(available_custom_fields: WorkPackageCustomField.summable.to_a))
end

@ -72,12 +72,10 @@ See docs/COPYRIGHT.rdoc for more details.
<fieldset class="form--fieldset"><legend class="form--fieldset-legend"><%= t(:setting_column_options) %></legend>
<%
column_choices = Query.new.available_columns.map { |column|
choice = { caption: column.caption, value: column.name.to_s }
choice[:except] = :work_package_list_summable_columns unless column.summable?
choice
{ caption: column.caption, value: column.name.to_s }
}
%>
<%= settings_matrix([:work_package_list_default_columns, :work_package_list_summable_columns],
<%= settings_matrix([:work_package_list_default_columns],
column_choices, label_choices: :setting_work_package_properties) %>
</fieldset>
<%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %>

@ -2215,7 +2215,6 @@ en:
setting_work_package_done_ratio_status: "Use the work package status"
setting_work_package_done_ratio_disabled: "Disable (hide the progress)"
setting_work_package_list_default_columns: "Display by default"
setting_work_package_list_summable_columns: "Summable"
setting_work_package_properties: "Work package properties"
setting_work_package_startdate_is_adddate: "Use current date as start date for new work packages"
setting_work_packages_export_limit: "Work packages export limit"

@ -270,10 +270,6 @@ work_package_list_default_highlighting_mode:
work_package_list_default_highlighted_attributes:
serialized: true
default: []
work_package_list_summable_columns:
serialized: true
default:
- estimated_hours
display_subprojects_work_packages:
default: 1
work_package_done_ratio:

@ -98,14 +98,6 @@ Given(/^the custom field "(.*?)" is disabled for the project "(.*?)"$/) do |fiel
project.work_package_custom_fields.delete custom_field
end
Given /^the custom field "(.+)" is( not)? summable$/ do |field_name, negative|
custom_field = WorkPackageCustomField.find_by(name: field_name)
Setting.work_package_list_summable_columns = negative ?
Setting.work_package_list_summable_columns - ["cf_#{custom_field.id}"] :
Setting.work_package_list_summable_columns << "cf_#{custom_field.id}"
end
Given /^the custom field "(.*?)" is activated for type "(.*?)"$/ do |field_name, type_name|
custom_field = WorkPackageCustomField.find_by(name: field_name)
type = ::Type.find_by(name: type_name)

@ -41,11 +41,7 @@ module API
name_source: ->(*) { custom_field.name },
required: false,
writable: false,
show_if: ->(*) {
Setting.work_package_list_summable_columns.any? do |column_name|
/cf_(\d+)/.match(column_name)
end
}
show_if: ->(*) { custom_field.summable? }
end
def inject_property_value(custom_field)
@ -53,10 +49,7 @@ module API
getter: property_value_getter_for(custom_field),
setter: property_value_setter_for(custom_field),
render_nil: true,
if: ->(*) {
setting = ::Setting.work_package_list_summable_columns
setting.include?("cf_#{custom_field.id}")
}
if: ->(*) { custom_field.summable? }
end
end
end

@ -54,10 +54,32 @@ module API
schema :estimated_time,
type: 'Duration',
required: false,
writable: false,
show_if: ->(*) {
::Setting.work_package_list_summable_columns.include?('estimated_hours')
}
writable: false
schema :story_points,
type: 'Integer',
required: false
schema :remaining_time,
type: 'Duration',
name_source: :remaining_hours,
required: false,
writable: false
schema :overall_costs,
type: 'String',
required: false,
writable: false
schema :labor_costs,
type: 'String',
required: false,
writable: false
schema :material_costs,
type: 'String',
required: false,
writable: false
end
end
end

@ -5,6 +5,7 @@ module API
module WorkPackages
class WorkPackageSumsRepresenter < ::API::Decorators::Single
extend ::API::V3::Utilities::CustomFieldInjector::RepresenterClass
include ActionView::Helpers::NumberHelper
custom_field_injector(injector_class: ::API::V3::Utilities::CustomFieldSumInjector)
@ -22,9 +23,35 @@ module API
getter: ->(*) {
datetime_formatter.format_duration_from_hours(represented.estimated_hours,
allow_nil: true)
},
if: ->(*) {
::Setting.work_package_list_summable_columns.include?('estimated_hours')
}
property :story_points,
render_nil: true
property :remaining_time,
render_nil: true,
exec_context: :decorator,
getter: ->(*) {
datetime_formatter.format_duration_from_hours(represented.remaining_hours,
allow_nil: true)
}
property :overall_costs,
exec_context: :decorator,
getter: ->(*) {
number_to_currency(represented.overall_costs)
}
property :labor_costs,
exec_context: :decorator,
getter: ->(*) {
number_to_currency(represented.labor_costs)
}
property :material_costs,
exec_context: :decorator,
getter: ->(*) {
number_to_currency(represented.material_costs)
}
end
end

@ -29,8 +29,6 @@
require 'open_project/plugins'
require_relative './patches/api/work_package_representer'
require_relative './patches/api/work_package_schema_representer'
require_relative './patches/api/work_package_sums_representer'
require_relative './patches/api/work_package_sums_schema_representer'
module OpenProject::Backlogs
class Engine < ::Rails::Engine
@ -149,12 +147,6 @@ module OpenProject::Backlogs
extend_api_response(:v3, :work_packages, :schema, :work_package_schema,
&::OpenProject::Backlogs::Patches::API::WorkPackageSchemaRepresenter.extension)
extend_api_response(:v3, :work_packages, :schema, :work_package_sums_schema,
&::OpenProject::Backlogs::Patches::API::WorkPackageSumsSchemaRepresenter.extension)
extend_api_response(:v3, :work_packages, :work_package_sums,
&::OpenProject::Backlogs::Patches::API::WorkPackageSumsRepresenter.extension)
add_api_attribute on: :work_package, ar_name: :story_points
add_api_attribute on: :work_package, ar_name: :remaining_hours, writeable: ->(*) { model.leaf? }

@ -1,58 +0,0 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
module OpenProject::Backlogs
module Patches
module API
module WorkPackageSumsRepresenter
module_function
def extension
->(*) do
property :story_points,
render_nil: true,
if: ->(*) {
::Setting.work_package_list_summable_columns.include?('story_points')
}
property :remaining_time,
render_nil: true,
exec_context: :decorator,
getter: ->(*) {
datetime_formatter.format_duration_from_hours(represented.remaining_hours,
allow_nil: true)
},
if: ->(*) {
::Setting.work_package_list_summable_columns.include?('remaining_hours')
}
end
end
end
end
end
end

@ -1,57 +0,0 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
module OpenProject::Backlogs
module Patches
module API
module WorkPackageSumsSchemaRepresenter
module_function
def extension
->(*) do
schema :story_points,
type: 'Integer',
required: false,
show_if: ->(*) {
::Setting.work_package_list_summable_columns.include?('story_points')
}
schema :remaining_time,
type: 'Duration',
name_source: :remaining_hours,
required: false,
writable: false,
show_if: ->(*) {
::Setting.work_package_list_summable_columns.include?('remaining_hours')
}
end
end
end
end
end
end

@ -1,88 +0,0 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe ::API::V3::WorkPackages::Schema::WorkPackageSumsSchemaRepresenter do
let(:current_user) do
FactoryBot.build_stubbed(:user)
end
let(:schema) { ::API::V3::WorkPackages::Schema::WorkPackageSumsSchema.new }
let(:representer) { described_class.create(schema, current_user: current_user) }
subject { representer.to_json }
describe 'storyPoints' do
let(:setting) { ['story_points'] }
before do
allow(Setting)
.to receive(:work_package_list_summable_columns)
.and_return(setting)
end
it_behaves_like 'has basic schema properties' do
let(:path) { 'storyPoints' }
let(:type) { 'Integer' }
let(:name) { I18n.t('activerecord.attributes.work_package.story_points') }
let(:required) { false }
let(:writable) { false }
end
context 'not marked as summable' do
let(:setting) { [] }
it 'does not show story points' do
is_expected.to_not have_json_path('storyPoints')
end
end
end
describe 'remainingTime' do
let(:setting) { ['remaining_time'] }
shared_examples_for 'has schema for remainingTime' do
it_behaves_like 'has basic schema properties' do
let(:path) { 'remainingTime' }
let(:type) { 'Duration' }
let(:name) { I18n.t('activerecord.attributes.work_package.remaining_hours') }
let(:required) { false }
let(:writable) { true }
end
end
context 'not marked as summable' do
let(:setting) { [] }
it 'does not show remaining time' do
is_expected.to_not have_json_path('remaining time')
end
end
end
end

@ -1,80 +0,0 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe ::API::V3::WorkPackages::WorkPackageSumsRepresenter do
let(:sums) { double 'sums', story_points: 5, remaining_hours: 10 }
let(:schema) { double 'schema', available_custom_fields: [] }
let(:user) { FactoryBot.build_stubbed(:user) }
let(:representer) {
described_class.create_class(schema, user).new(sums)
}
let(:summable_columns) { [] }
before do
allow(Setting)
.to receive(:work_package_list_summable_columns)
.and_return(summable_columns)
end
subject { representer.to_json }
context 'remainingTime' do
context 'with it being configured to be summable' do
let(:summable_columns) { ['remaining_hours'] }
it 'is represented' do
expected = 'PT10H'
expect(subject).to be_json_eql(expected.to_json).at_path('remainingTime')
end
end
context 'without it being configured to be summable' do
it 'is not represented when the summable setting does not list it' do
expect(subject).to_not have_json_path('remainingTime')
end
end
end
context 'storyPoints' do
context 'with it being configured to be summable' do
let(:summable_columns) { ['story_points'] }
it 'is represented' do
expect(subject).to be_json_eql(sums.story_points.to_json).at_path('storyPoints')
end
end
context 'without it being configured to be summable' do
it 'is not represented when the summable setting does not list it' do
expect(subject).to_not have_json_path('storyPoints')
end
end
end
end

@ -246,61 +246,6 @@ module Costs
writable: false
end
extend_api_response(:v3, :work_packages, :schema, :work_package_sums_schema) do
schema :overall_costs,
type: 'String',
required: false,
writable: false,
show_if: ->(*) {
::Setting.work_package_list_summable_columns.include?('overall_costs')
}
schema :labor_costs,
type: 'String',
required: false,
writable: false,
show_if: ->(*) {
::Setting.work_package_list_summable_columns.include?('labor_costs')
}
schema :material_costs,
type: 'String',
required: false,
writable: false,
show_if: ->(*) {
::Setting.work_package_list_summable_columns.include?('material_costs')
}
end
extend_api_response(:v3, :work_packages, :work_package_sums) do
include ActionView::Helpers::NumberHelper
property :overall_costs,
exec_context: :decorator,
getter: ->(*) {
number_to_currency(represented.overall_costs)
},
if: ->(*) {
::Setting.work_package_list_summable_columns.include?('overall_costs')
}
property :labor_costs,
exec_context: :decorator,
getter: ->(*) {
number_to_currency(represented.labor_costs)
},
if: ->(*) {
::Setting.work_package_list_summable_columns.include?('labor_costs')
}
property :material_costs,
exec_context: :decorator,
getter: ->(*) {
number_to_currency(represented.material_costs)
},
if: ->(*) {
::Setting.work_package_list_summable_columns.include?('material_costs')
}
end
initializer 'costs.register_latest_project_activity' do
Project.register_latest_project_activity on: 'TimeEntry',
attribute: :updated_on

@ -33,9 +33,6 @@ module Costs
def initialize(name, options = {})
super
@sum_function = options[:summable]
self.summable = @sum_function.respond_to?(:call)
end
def value(work_package)
@ -54,40 +51,40 @@ module Costs
super_value work_package
end
def sum_of(work_packages)
@sum_function.call(work_packages)
end
class_attribute :currency_columns
self.currency_columns = {
budget: {},
material_costs: {
summable: ->(work_packages) {
WorkPackage::MaterialCosts
.new(user: User.current)
.costs_of(work_packages: work_packages)
summable: ->(query, grouped) {
scope = WorkPackage::MaterialCosts
.new(user: User.current)
.add_to_work_package_collection(WorkPackage.where(id: query.results.work_packages))
.except(:order, :select)
Queries::WorkPackages::Columns::WorkPackageColumn
.scoped_column_sum(scope,
"COALESCE(ROUND(SUM(cost_entries_sum), 2)::FLOAT, 0.0) material_costs",
grouped && query.group_by_statement)
}
},
labor_costs: {
summable: ->(work_packages) {
WorkPackage::LaborCosts
.new(user: User.current)
.costs_of(work_packages: work_packages)
summable: ->(query, grouped) {
scope = WorkPackage::LaborCosts
.new(user: User.current)
.add_to_work_package_collection(WorkPackage.where(id: query.results.work_packages))
.except(:order, :select)
Queries::WorkPackages::Columns::WorkPackageColumn
.scoped_column_sum(scope,
"COALESCE(ROUND(SUM(time_entries_sum), 2)::FLOAT, 0.0) labor_costs",
grouped && query.group_by_statement)
}
},
overall_costs: {
summable: ->(work_packages) {
labor_costs = WorkPackage::LaborCosts
.new(user: User.current)
.costs_of(work_packages: work_packages)
material_costs = WorkPackage::MaterialCosts
.new(user: User.current)
.costs_of(work_packages: work_packages)
labor_costs + material_costs
}
summable: true,
summable_select: "labor_costs + material_costs AS overall_costs",
summable_work_packages_select: false
}
}

@ -1,101 +0,0 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper.rb')
describe 'Work Package table cost sums', type: :feature, js: true do
let(:project) { work_package.project }
let(:user) { FactoryBot.create :user,
member_in_project: project,
member_through_role: role }
let(:role) { FactoryBot.create :role, permissions: [:view_own_hourly_rate,
:view_work_packages,
:view_work_packages,
:view_own_time_entries,
:view_own_cost_entries,
:view_cost_rates,
:log_costs] }
let(:work_package) {FactoryBot.create :work_package }
let(:hourly_rate) { FactoryBot.create :default_hourly_rate, user: user,
rate: 1.00 }
let!(:time_entry) { FactoryBot.create :time_entry, user: user,
work_package: work_package,
project: project,
hours: 1.50 }
let(:cost_type) {
type = FactoryBot.create :cost_type, name: 'Translations'
FactoryBot.create :cost_rate, cost_type: type,
rate: 1.00
type
}
let!(:cost_entry) { FactoryBot.create :cost_entry, work_package: work_package,
project: project,
units: 2.50,
cost_type: cost_type,
user: user }
let(:wp_table) { ::Pages::WorkPackagesTable.new(project) }
let!(:query) do
query = FactoryBot.build(:query, user: user, project: project)
query.column_names = %w(subject overall_costs material_costs overall_costs)
query.save!
query
end
before do
login_as(user)
allow(Setting).to receive(:work_package_list_summable_columns).and_return(summable)
wp_table.visit_query(query)
wp_table.expect_work_package_listed(work_package)
# Trigger action from action menu dropdown
wp_table.click_setting_item 'Display sums'
expect(page).to have_selector('tr.sum.group.all')
end
context 'when summing enabled' do
let(:summable) { %w(overall_costs labor_costs material_costs) }
it 'shows the sums' do
within('tr.sum.group.all') do
expect(page).to have_selector('.inline-edit--display-field', text: '2.50 EUR', count: 3)
expect(page).to have_selector('.inline-edit--display-field', text: '-', count: 1)
end
end
end
context 'when summing disabled' do
let(:summable) { [] }
it 'does not show the sums' do
within('tr.sum.group.all') do
expect(page).to have_selector('.inline-edit--display-field', text: '-', count: 4)
end
end
end
end

@ -1,98 +0,0 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe ::API::V3::WorkPackages::WorkPackageSumsRepresenter do
let(:sums) { double 'sums', material_costs: 5, labor_costs: 10, overall_costs: 15 }
let(:schema) { double 'schema', available_custom_fields: [] }
let(:user) { FactoryBot.build_stubbed(:user) }
let(:representer) do
described_class.create_class(schema, user).new(sums)
end
let(:summable_columns) { [] }
before do
allow(Setting)
.to receive(:work_package_list_summable_columns)
.and_return(summable_columns)
end
subject { representer.to_json }
context 'materialCosts' do
context 'with it being configured to be summable' do
let(:summable_columns) { ['material_costs'] }
it 'is represented' do
expected = "5.00 EUR"
expect(subject).to be_json_eql(expected.to_json).at_path('materialCosts')
end
end
context 'without it being configured to be summable' do
it 'is not represented when the summable setting does not list it' do
expect(subject).to_not have_json_path('materialCosts')
end
end
end
context 'laborCosts' do
context 'with it being configured to be summable' do
let(:summable_columns) { ['labor_costs'] }
it 'is represented' do
expected = "10.00 EUR"
expect(subject).to be_json_eql(expected.to_json).at_path('laborCosts')
end
end
context 'without it being configured to be summable' do
it 'is not represented when the summable setting does not list it' do
expect(subject).to_not have_json_path('laborCosts')
end
end
end
context 'overallCosts' do
context 'with it being configured to be summable' do
let(:summable_columns) { ['overall_costs'] }
it 'is represented' do
expected = "15.00 EUR"
expect(subject).to be_json_eql(expected.to_json).at_path('overallCosts')
end
end
context 'without it being configured to be summable' do
it 'is not represented when the summable setting does not list it' do
expect(subject).to_not have_json_path('overallCosts')
end
end
end
end

@ -0,0 +1,145 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe Costs::QueryCurrencyColumn, type: :model do
let(:project) do
FactoryBot.build_stubbed(:project).tap do |p|
allow(p)
.to receive(:costs_enabled?)
.and_return(costs_enabled)
end
end
let(:instance) { described_class.instances(project).detect { |c| c.name == column_name } }
let(:costs_enabled) { true }
let(:column_name) { :material_costs }
describe '.instances' do
subject { described_class.instances(project).map(&:name) }
context 'with costs enabled' do
it 'returns the four costs columns' do
is_expected
.to match_array %i[budget material_costs labor_costs overall_costs]
end
end
context 'with costs disabled' do
let(:costs_enabled) { false }
it 'returns no columns' do
is_expected
.to be_empty
end
end
context 'with no context' do
it 'returns the four costs columns' do
is_expected
.to match_array %i[budget material_costs labor_costs overall_costs]
end
end
end
context 'material_costs' do
describe '#summable?' do
it 'is true' do
expect(instance)
.to be_summable
end
end
describe '#summable' do
it 'is callable' do
expect(instance.summable)
.to respond_to(:call)
end
# Not testing the results here, this is done by an integration test
it 'returns an AR scope that has an id and a material_costs column' do
query = double('query')
result = double('result')
allow(query)
.to receive(:results)
.and_return result
allow(result)
.to receive(:work_packages)
.and_return(WorkPackage.all)
allow(query)
.to receive(:group_by_statement)
.and_return('author_id')
expect(ActiveRecord::Base.connection.select_all(instance.summable.(query, true).to_sql).column_types.keys)
.to match_array %w(id material_costs)
end
end
end
context 'labor_costs' do
let(:column_name) { :labor_costs }
describe '#summable?' do
it 'is true' do
expect(instance)
.to be_summable
end
end
describe '#summable' do
it 'is callable' do
expect(instance.summable)
.to respond_to(:call)
end
# Not testing the results here, this is done by an integration test
it 'returns an AR scope that has an id and a labor_costs column' do
query = double('query')
result = double('result')
allow(query)
.to receive(:results)
.and_return result
allow(result)
.to receive(:work_packages)
.and_return(WorkPackage.all)
allow(query)
.to receive(:group_by_statement)
.and_return('author_id')
expect(ActiveRecord::Base.connection.select_all(instance.summable.(query, true).to_sql).column_types.keys)
.to match_array %w(id labor_costs)
end
end
end
end

@ -29,8 +29,17 @@
require 'spec_helper'
RSpec.feature 'Work package index sums', js: true do
using_shared_fixtures :admin
let(:user) do
FactoryBot.create :user,
member_in_project: project,
member_with_permissions: %i[view_own_hourly_rate
view_work_packages
edit_work_packages
view_time_entries
view_cost_entries
view_cost_rates
log_costs]
end
let(:project) do
FactoryBot.create(:project, name: 'project1', identifier: 'project1')
end
@ -59,6 +68,33 @@ RSpec.feature 'Work package index sums', js: true do
wp.save!
end
end
let!(:hourly_rate) do
FactoryBot.create :default_hourly_rate,
user: user,
rate: 10.00
end
let!(:time_entry) do
FactoryBot.create :time_entry,
user: user,
work_package: work_package_1,
project: project,
hours: 1.50
end
let(:cost_type) do
type = FactoryBot.create :cost_type, name: 'Translations'
FactoryBot.create :cost_rate,
cost_type: type,
rate: 3.00
type
end
let!(:cost_entry) do
FactoryBot.create :cost_entry,
work_package: work_package_1,
project: project,
units: 2.50,
cost_type: cost_type,
user: user
end
let(:wp_table) { Pages::WorkPackagesTable.new(project) }
let(:columns) { ::Components::WorkPackages::Columns.new }
@ -66,17 +102,13 @@ RSpec.feature 'Work package index sums', js: true do
let(:group_by) { ::Components::WorkPackages::GroupBy.new }
before do
login_as(admin)
allow(Setting)
.to receive(:work_package_list_summable_columns)
.and_return(%W(estimated_hours cf_#{int_cf.id} cf_#{float_cf.id}))
login_as(user)
visit project_work_packages_path(project)
expect(current_path).to eq('/projects/project1/work_packages')
end
scenario 'calculates summs correctly' do
scenario 'calculates sums correctly' do
wp_table.expect_work_package_listed work_package_1, work_package_2
# Add estimated time column
@ -85,6 +117,12 @@ RSpec.feature 'Work package index sums', js: true do
columns.add int_cf.name
# Add float cf column
columns.add float_cf.name
# Add overall costs column
columns.add 'Overall costs'
# Add unit costs column
columns.add 'Unit costs'
# Add labor costs column
columns.add 'Labor costs'
# Trigger action from action menu dropdown
modal.set_display_sums enable: true
@ -98,6 +136,12 @@ RSpec.feature 'Work package index sums', js: true do
expect(page).to have_selector('.wp-table--sum-container', text: '25')
expect(page).to have_selector('.wp-table--sum-container', text: '12')
expect(page).to have_selector('.wp-table--sum-container', text: '13.2')
# Unit costs
expect(page).to have_selector('.wp-table--sum-container', text: '7.50')
# Overall costs
expect(page).to have_selector('.wp-table--sum-container', text: '22.50')
# Labor costs
expect(page).to have_selector('.wp-table--sum-container', text: '15.00')
# Update the sum
edit_field = wp_table.edit_field(work_package_1, :estimatedTime)
@ -107,17 +151,29 @@ RSpec.feature 'Work package index sums', js: true do
expect(page).to have_selector('.wp-table--sum-container', text: '35')
expect(page).to have_selector('.wp-table--sum-container', text: '12')
expect(page).to have_selector('.wp-table--sum-container', text: '13.2')
# Unit costs
expect(page).to have_selector('.wp-table--sum-container', text: '7.50')
# Overall costs
expect(page).to have_selector('.wp-table--sum-container', text: '22.50')
# Labor costs
expect(page).to have_selector('.wp-table--sum-container', text: '15.00')
# Enable groups
group_by.enable_via_menu 'Status'
# Expect to have three sums rows no
# Expect to have three sums rows now
expect(page).to have_selector('.wp-table--sums-row', count: 3)
# First status row
expect(page).to have_selector('.wp-table--sum-container', text: '20 h')
expect(page).to have_selector(".wp-table--sum-container.customField#{int_cf.id}", text: '5')
expect(page).to have_selector(".wp-table--sum-container.customField#{float_cf.id}", text: '5.5')
# Unit costs
expect(page).to have_selector('.wp-table--sum-container.materialCosts', text: '7.50')
# Overall costs
expect(page).to have_selector('.wp-table--sum-container.overallCosts', text: '22.50')
# Labor costs
expect(page).to have_selector('.wp-table--sum-container.laborCosts', text: '15.00')
# Second status row
expect(page).to have_selector('.wp-table--sum-container', text: '15 h')
@ -128,6 +184,12 @@ RSpec.feature 'Work package index sums', js: true do
expect(page).to have_selector('tfoot .wp-table--sum-container', text: '35')
expect(page).to have_selector("tfoot .wp-table--sum-container.customField#{int_cf.id}", text: '12')
expect(page).to have_selector("tfoot .wp-table--sum-container.customField#{float_cf.id}", text: '13.2')
# Unit costs
expect(page).to have_selector('tfoot .wp-table--sum-container.materialCosts', text: '7.50')
# Overall costs
expect(page).to have_selector('tfoot .wp-table--sum-container.overallCosts', text: '22.50')
# Labor costs
expect(page).to have_selector('tfoot .wp-table--sum-container.laborCosts', text: '15.00')
# Collapsing groups will also hide the sums row
page.find('.expander.icon-minus2', match: :first).click

@ -31,20 +31,14 @@ require 'spec_helper'
describe ::API::V3::WorkPackages::Schema::WorkPackageSumsSchemaRepresenter do
include ::API::V3::Utilities::PathHelper
let(:available_custom_fields) { [] }
let(:custom_field) { FactoryBot.build_stubbed(:integer_issue_custom_field) }
let(:available_custom_fields) { [custom_field] }
let(:schema) { double('wp_schema', available_custom_fields: available_custom_fields) }
let(:current_user) { double('user', admin?: false) }
let(:representer) do
described_class.create(schema, current_user: current_user)
end
let(:summable_columns) { [] }
before do
allow(Setting)
.to receive(:work_package_list_summable_columns)
.and_return(summable_columns)
end
subject { representer.to_json }
@ -59,49 +53,72 @@ describe ::API::V3::WorkPackages::Schema::WorkPackageSumsSchemaRepresenter do
end
context 'estimated_time' do
context 'with it being configured to be summable' do
let(:summable_columns) { ['estimated_hours'] }
it_behaves_like 'has basic schema properties' do
let(:path) { 'estimatedTime' }
let(:type) { 'Duration' }
let(:name) { I18n.t('attributes.estimated_hours') }
let(:required) { false }
let(:writable) { false }
end
end
it 'is represented' do
expected = { 'type': 'Duration',
'name': 'Estimated time',
'required': false,
'hasDefault': false,
'writable': false,
'options': {} }
expect(subject).to be_json_eql(expected.to_json).at_path('estimatedTime')
end
describe 'storyPoints' do
it_behaves_like 'has basic schema properties' do
let(:path) { 'storyPoints' }
let(:type) { 'Integer' }
let(:name) { I18n.t('activerecord.attributes.work_package.story_points') }
let(:required) { false }
let(:writable) { false }
end
end
context 'without it being configured to be summable' do
it 'is not represented when the summable setting does not list it' do
expect(subject).to_not have_json_path('estimatedTime')
end
describe 'remainingTime' do
it_behaves_like 'has basic schema properties' do
let(:path) { 'remainingTime' }
let(:type) { 'Duration' }
let(:name) { I18n.t('activerecord.attributes.work_package.remaining_hours') }
let(:required) { false }
let(:writable) { false }
end
end
context 'custom field x' do
let(:custom_field) { FactoryBot.build_stubbed(:integer_issue_custom_field) }
let(:available_custom_fields) { [custom_field] }
describe 'overallCosts' do
it_behaves_like 'has basic schema properties' do
let(:path) { 'overallCosts' }
let(:type) { 'String' }
let(:name) { I18n.t('activerecord.attributes.work_package.overall_costs') }
let(:required) { false }
let(:writable) { false }
end
end
context 'with it being configured to be summable' do
let(:summable_columns) { ["cf_#{custom_field.id}"] }
describe 'laborCosts' do
it_behaves_like 'has basic schema properties' do
let(:path) { 'laborCosts' }
let(:type) { 'String' }
let(:name) { I18n.t('activerecord.attributes.work_package.labor_costs') }
let(:required) { false }
let(:writable) { false }
end
end
it 'is represented' do
expected = { 'type': 'Integer',
'name': custom_field.name,
'required': false,
'hasDefault': false,
'writable': false,
'options': {} }
expect(subject).to be_json_eql(expected.to_json).at_path("customField#{custom_field.id}")
end
describe 'materialCosts' do
it_behaves_like 'has basic schema properties' do
let(:path) { 'materialCosts' }
let(:type) { 'String' }
let(:name) { I18n.t('activerecord.attributes.work_package.material_costs') }
let(:required) { false }
let(:writable) { false }
end
end
context 'without it being configured to be summable' do
it 'is not represented when the summable setting does not list it' do
expect(subject).to_not have_json_path("customField#{custom_field.id}")
end
context 'custom field x' do
it_behaves_like 'has basic schema properties' do
let(:path) { "customField#{custom_field.id}" }
let(:type) { 'Integer' }
let(:name) { custom_field.name }
let(:required) { false }
let(:writable) { false }
end
end
end

@ -387,7 +387,9 @@ describe ::API::V3::WorkPackages::WorkPackageCollectionRepresenter do
let(:total_sums) { OpenStruct.new(estimated_hours: 1) }
it 'renders the groups object as json' do
expected = { 'estimatedTime': 'PT1H' }
expected = { 'estimatedTime': 'PT1H',
'remainingTime': nil,
'storyPoints': nil }
is_expected.to be_json_eql(expected.to_json).at_path('totalSums')
end

@ -29,57 +29,70 @@
require 'spec_helper'
describe ::API::V3::WorkPackages::WorkPackageSumsRepresenter do
let(:available_custom_fields) { [] }
let(:sums) { double 'sums', estimated_hours: 5 }
let(:schema) { double 'schema', available_custom_fields: available_custom_fields }
let(:custom_field) { FactoryBot.build_stubbed(:int_wp_custom_field, id: 1) }
let(:sums) do
double('sums',
story_points: 5,
remaining_hours: 10,
estimated_hours: 5,
material_costs: 5,
labor_costs: 10,
overall_costs: 15,
custom_field_1: 5,
available_custom_fields: [custom_field])
end
let(:schema) { double 'schema', available_custom_fields: [custom_field] }
let(:current_user) { FactoryBot.build_stubbed(:user) }
let(:representer) do
described_class.create_class(schema, current_user).new(sums)
end
let(:summable_columns) { [] }
before do
allow(Setting)
.to receive(:work_package_list_summable_columns)
.and_return(summable_columns)
end
subject { representer.to_json }
context 'estimated_time' do
context 'with it being configured to be summable' do
let(:summable_columns) { ['estimated_hours'] }
it 'is represented' do
expected = 'PT5H'
expect(subject).to be_json_eql(expected.to_json).at_path('estimatedTime')
end
end
it 'is represented' do
expected = 'PT5H'
expect(subject).to be_json_eql(expected.to_json).at_path('estimatedTime')
end
context 'remainingTime' do
it 'is represented' do
expected = 'PT10H'
expect(subject).to be_json_eql(expected.to_json).at_path('remainingTime')
end
end
context 'without it being configured to be summable' do
it 'is not represented when the summable setting does not list it' do
expect(subject).to_not have_json_path('estimatedTime')
end
context 'storyPoints' do
it 'is represented' do
expect(subject).to be_json_eql(sums.story_points.to_json).at_path('storyPoints')
end
end
context 'custom field x' do
let(:custom_field) { FactoryBot.build_stubbed(:int_wp_custom_field, id: 1) }
let(:available_custom_fields) { [custom_field] }
let(:sums) { double 'sums', available_custom_fields: available_custom_fields, custom_field_1: 5 }
context 'materialCosts' do
it 'is represented' do
expected = "5.00 EUR"
expect(subject).to be_json_eql(expected.to_json).at_path('materialCosts')
end
end
context 'with it being configured to be summable' do
let(:summable_columns) { ["cf_#{custom_field.id}"] }
context 'laborCosts' do
it 'is represented' do
expected = "10.00 EUR"
expect(subject).to be_json_eql(expected.to_json).at_path('laborCosts')
end
end
it 'is represented' do
expect(subject).to be_json_eql(sums.custom_field_1.to_json).at_path('customField1')
end
context 'overallCosts' do
it 'is represented' do
expected = "15.00 EUR"
expect(subject).to be_json_eql(expected.to_json).at_path('overallCosts')
end
end
context 'without it being configured to be summable' do
it 'is not represented when the summable setting does not list it' do
expect(subject).to_not have_json_path("customField#{custom_field.id}")
end
context 'custom field x' do
it 'is represented' do
expect(subject).to be_json_eql(sums.custom_field_1.to_json).at_path('customField1')
end
end
end

@ -34,6 +34,7 @@ describe OpenProject::OmniAuth::Authorization do
let(:user) { FactoryBot.create :user, mail: 'foo@bar.de' }
let(:state) { Struct.new(:number, :user_email, :uid).new 0, nil, nil }
let(:collector) { [] }
let!(:existing_callbacks) { OpenProject::OmniAuth::Authorization.after_login_callbacks.dup }
before do
OpenProject::OmniAuth::Authorization.after_login_callbacks.clear
@ -53,7 +54,13 @@ describe OpenProject::OmniAuth::Authorization do
end
after do
# Reset existing callbacks to avoid sideeffects
OpenProject::OmniAuth::Authorization.after_login_callbacks.clear
callbacks = OpenProject::OmniAuth::Authorization.after_login_callbacks
existing_callbacks.each do |callback_block|
callbacks << callback_block
end
end
it 'triggers every callback setting uid to "bar", number to 42 and user_email to foo@bar.de' do

@ -36,59 +36,4 @@ describe Queries::WorkPackages::Columns::WorkPackageColumn, type: :model do
it "allows to be constructed without attribute highlightable" do
expect(described_class.new('foo').highlightable?).to eq(false)
end
describe "sum of" do
describe :estimated_hours do
context "with work packages in a hierarchy" do
let(:work_packages) do
hierarchy = [
["Single", 1, 0],
{
["Parent", 1, 3] => [
["Child 1 of Parent", 1, 0],
["Child 2 of Parent", 1, 0],
["Hidden Child 3 of Parent", 1, 0]
]
},
{
["Hidden Parent", 5, 4] => [
["Child of Hidden Parent", 1, 0],
["Hidden Child", 3, 0]
]
},
{
["Parent 2", 1, 3] => [
["Child 1 of Parent 2", 1, 0],
{
["Nested Parent", 0, 2] => [
["Child 1 of Nested Parent", 1, 0],
["Child 2 of Nested Parent", 1, 0]
]
}
]
}
]
build_work_package_hierarchy hierarchy, :subject, :estimated_hours, :derived_estimated_hours
end
let(:result_set) { WorkPackage.where("NOT subject LIKE 'Hidden%'") }
let(:column) { Queries::WorkPackages::Columns::WorkPackageColumn.new :estimated_hours }
before do
work_packages # create work packages
expect(WorkPackage.count).to eq work_packages.size
expect(result_set.count).to eq(work_packages.size - 3) # all work packages except the hidden parent and children
end
it "yields the correct sum, not counting any children (of parents in the result set) twice" do
# Single + Parent + Child 1 of Parent + Child 2 of Parent + Child of Hidden Parent + Parent 2 + Child 1 of Parent 2
# + Nested Parent + Child 1 of Nested Parent + Child 2 of Nested Parent
expect(column.sum_of(result_set)).to eq 9
expect(column.sum_of(WorkPackage.all)).to eq 18 # the above + Hidden Child 3 of Parent + Hidden Parent + Hidden Child
end
end
end
end
end

@ -0,0 +1,277 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe ::Query::Results, 'sums', type: :model do
let(:project) do
FactoryBot.create(:project).tap do |p|
p.work_package_custom_fields << int_cf
p.work_package_custom_fields << float_cf
end
end
let(:other_project) do
FactoryBot.create(:project).tap do |p|
p.work_package_custom_fields << int_cf
p.work_package_custom_fields << float_cf
end
end
let!(:work_package1) do
FactoryBot.create(:work_package,
type: type,
project: project,
estimated_hours: 5,
done_ratio: 10,
"custom_field_#{int_cf.id}" => 10,
"custom_field_#{float_cf.id}" => 3.414,
remaining_hours: 3,
story_points: 7)
end
let!(:work_package2) do
FactoryBot.create(:work_package,
type: type,
project: project,
assigned_to: current_user,
done_ratio: 50,
estimated_hours: 5,
"custom_field_#{int_cf.id}" => 10,
"custom_field_#{float_cf.id}" => 3.414,
remaining_hours: 3,
story_points: 7)
end
let!(:work_package3) do
FactoryBot.create(:work_package,
type: type,
project: project,
assigned_to: current_user,
responsible: current_user,
done_ratio: 50,
estimated_hours: 5,
"custom_field_#{int_cf.id}" => 10,
"custom_field_#{float_cf.id}" => 3.414,
remaining_hours: 3,
story_points: 7)
end
let!(:invisible_work_package1) do
FactoryBot.create(:work_package,
type: type,
project: other_project,
estimated_hours: 5,
"custom_field_#{int_cf.id}" => 10,
"custom_field_#{float_cf.id}" => 3.414,
remaining_hours: 3,
story_points: 7)
end
let!(:cost_entry1) do
FactoryBot.create(:cost_entry,
project: project,
work_package: work_package1,
user: current_user,
overridden_costs: 200)
end
let!(:cost_entry2) do
FactoryBot.create(:cost_entry,
project: project,
work_package: work_package2,
user: current_user,
overridden_costs: 200)
end
let!(:time_entry1) do
FactoryBot.create(:time_entry,
project: project,
work_package: work_package1,
user: current_user,
overridden_costs: 300)
end
let!(:time_entry2) do
FactoryBot.create(:time_entry,
project: project,
work_package: work_package2,
user: current_user,
overridden_costs: 300)
end
let(:int_cf) do
FactoryBot.create(:int_wp_custom_field)
end
let(:float_cf) do
FactoryBot.create(:float_wp_custom_field)
end
let(:type) do
FactoryBot.create(:type).tap do |t|
t.custom_fields << int_cf
t.custom_fields << float_cf
end
end
let(:current_user) do
FactoryBot.create(:user,
member_in_project: project,
member_with_permissions: permissions)
end
let(:permissions) do
%i[view_work_packages view_cost_entries view_time_entries view_cost_rates view_hourly_rates]
end
let(:group_by) { nil }
let(:query) do
FactoryBot.build :query,
project: project,
group_by: group_by
end
let(:query_results) do
::Query::Results.new query
end
before do
login_as(current_user)
end
let(:estimated_hours_column) { query.available_columns.detect { |c| c.name.to_s == 'estimated_hours' } }
let(:int_cf_column) { query.available_columns.detect { |c| c.name.to_s == "cf_#{int_cf.id}" } }
let(:float_cf_column) { query.available_columns.detect { |c| c.name.to_s == "cf_#{float_cf.id}" } }
let(:material_costs_column) { query.available_columns.detect { |c| c.name.to_s == "material_costs" } }
let(:labor_costs_column) { query.available_columns.detect { |c| c.name.to_s == "labor_costs" } }
let(:overall_costs_column) { query.available_columns.detect { |c| c.name.to_s == "overall_costs" } }
let(:remaining_hours_column) { query.available_columns.detect { |c| c.name.to_s == "remaining_hours" } }
let(:story_points_column) { query.available_columns.detect { |c| c.name.to_s == "story_points" } }
describe '#all_total_sums' do
it 'is a hash of all summable columns' do
expect(query_results.all_total_sums)
.to eql(estimated_hours_column => 15.0,
int_cf_column => 30,
float_cf_column => 10.24,
material_costs_column => 400.0,
labor_costs_column => 600.0,
overall_costs_column => 1000.0,
remaining_hours_column => 9.0,
story_points_column => 21)
end
context 'when filtering' do
before do
query.add_filter('assigned_to_id', '=', [current_user.id.to_s])
end
it 'is a hash of all summable columns and includes only the work packages matching the filter' do
expect(query_results.all_total_sums)
.to eql(estimated_hours_column => 10.0,
int_cf_column => 20,
float_cf_column => 6.83,
material_costs_column => 200.0,
labor_costs_column => 300.0,
overall_costs_column => 500.0,
remaining_hours_column => 6.0,
story_points_column => 14)
end
end
end
describe '#all_sums_for_group' do
context 'grouped by assigned_to' do
let(:group_by) { :assigned_to }
it 'is a hash of sums grouped by user values (and nil) and grouped columns' do
expect(query_results.all_group_sums)
.to eql(current_user => { estimated_hours_column => 10.0,
int_cf_column => 20,
float_cf_column => 6.83,
material_costs_column => 200.0,
labor_costs_column => 300.0,
overall_costs_column => 500.0,
remaining_hours_column => 6.0,
story_points_column => 14 },
nil => { estimated_hours_column => 5.0,
int_cf_column => 10,
float_cf_column => 3.41,
material_costs_column => 200.0,
labor_costs_column => 300.0,
overall_costs_column => 500.0,
remaining_hours_column => 3.0,
story_points_column => 7 })
end
context 'when filtering' do
before do
query.add_filter('responsible_id', '=', [current_user.id.to_s])
end
it 'is a hash of sums grouped by user values and grouped columns' do
expect(query_results.all_group_sums)
.to eql(current_user => { estimated_hours_column => 5.0,
int_cf_column => 10,
float_cf_column => 3.41,
material_costs_column => 0.0,
labor_costs_column => 0.0,
overall_costs_column => 0.0,
story_points_column => 7,
remaining_hours_column => 3.0 })
end
end
end
context 'grouped by done_ratio' do
let(:group_by) { :done_ratio }
it 'is a hash of sums grouped by done_ratio values and grouped columns' do
expect(query_results.all_group_sums)
.to eql(50 => { estimated_hours_column => 10.0,
int_cf_column => 20,
float_cf_column => 6.83,
material_costs_column => 200.0,
labor_costs_column => 300.0,
overall_costs_column => 500.0,
remaining_hours_column => 6.0,
story_points_column => 14 },
10 => { estimated_hours_column => 5.0,
int_cf_column => 10,
float_cf_column => 3.41,
material_costs_column => 200.0,
labor_costs_column => 300.0,
overall_costs_column => 500.0,
remaining_hours_column => 3.0,
story_points_column => 7 })
end
context 'when filtering' do
before do
query.add_filter('responsible_id', '=', [current_user.id.to_s])
end
it 'is a hash of sums grouped by done_ratio values and grouped columns' do
expect(query_results.all_group_sums)
.to eql(50 => { estimated_hours_column => 5.0,
int_cf_column => 10,
float_cf_column => 3.41,
material_costs_column => 0.0,
labor_costs_column => 0.0,
overall_costs_column => 0.0,
story_points_column => 7,
remaining_hours_column => 3.0 })
end
end
end
end
end

@ -30,41 +30,20 @@ require 'spec_helper'
describe WorkPackageCustomField, type: :model do
describe '.summable' do
let (:custom_field) do
FactoryBot.create(:work_package_custom_field,
name: 'Database',
field_format: 'list',
possible_values: %w(MySQL PostgreSQL Oracle),
is_required: true)
let!(:list_custom_field) do
FactoryBot.create(:list_wp_custom_field)
end
before do
custom_field.save!
let!(:int_custom_field) do
FactoryBot.create(:int_wp_custom_field)
end
let!(:float_custom_field) do
FactoryBot.create(:float_wp_custom_field)
end
context 'with a summable field' do
before do
allow(Setting)
.to receive(:work_package_list_summable_columns)
.and_return(["cf_#{custom_field.id}"])
end
it 'contains the custom_field' do
expect(described_class.summable)
.to match_array [custom_field]
end
end
context 'without a summable field' do
before do
allow(Setting)
.to receive(:work_package_list_summable_columns)
.and_return(['blubs'])
end
it 'does not contain the custom_field' do
expect(described_class.summable)
.to be_empty
.to match_array [int_custom_field, float_custom_field]
end
end
end

@ -219,7 +219,12 @@ describe API::V3::WorkPackages::WorkPackagesByProjectAPI, type: :request do
value: priority1.name,
count: 2,
sums: {
estimatedTime: 'PT4H'
estimatedTime: 'PT4H',
laborCosts: "0.00 EUR",
materialCosts: "0.00 EUR",
overallCosts: "0.00 EUR",
remainingTime: nil,
storyPoints: nil
}
}
end
@ -237,7 +242,12 @@ describe API::V3::WorkPackages::WorkPackagesByProjectAPI, type: :request do
value: priority2.name,
count: 1,
sums: {
estimatedTime: 'PT2H'
estimatedTime: 'PT2H',
laborCosts: "0.00 EUR",
materialCosts: "0.00 EUR",
overallCosts: "0.00 EUR",
remainingTime: nil,
storyPoints: nil
}
}
end
@ -265,7 +275,12 @@ describe API::V3::WorkPackages::WorkPackagesByProjectAPI, type: :request do
it 'contains the sum element' do
expected = {
estimatedTime: 'PT3H'
estimatedTime: 'PT3H',
laborCosts: "0.00 EUR",
materialCosts: "0.00 EUR",
overallCosts: "0.00 EUR",
remainingTime: nil,
storyPoints: nil
}
expect(subject.body).to be_json_eql(expected.to_json).at_path('totalSums')

@ -185,12 +185,6 @@ describe API::V3::WorkPackages::Schema::WorkPackageSchemasAPI, type: :request do
let(:schema_path) { api_v3_paths.work_package_sums_schema }
subject { last_response }
before do
allow(Setting)
.to receive(:work_package_list_summable_columns)
.and_return(['estimated_hours'])
end
context 'logged in' do
before do
allow(User).to receive(:current).and_return(current_user)

@ -57,14 +57,9 @@ describe ::API::V3::WorkPackageCollectionFromQueryService,
.and_return(1 => 5, 2 => 10)
allow(results)
.to receive(:all_sums_for_group)
.with(1)
.and_return(OpenStruct.new(name: :status_id) => 50)
allow(results)
.to receive(:all_sums_for_group)
.with(2)
.and_return(OpenStruct.new(name: :status_id) => 100)
.to receive(:all_group_sums)
.and_return(1 => { OpenStruct.new(name: :status_id) => 50 },
2 => { OpenStruct.new(name: :status_id) => 100 })
allow(results)
.to receive(:query)

Loading…
Cancel
Save