diff --git a/app/models/journal/caused_by_total_percent_complete_mode_changed_to_work_weighted_average.rb b/app/models/journal/caused_by_total_percent_complete_mode_changed_to_work_weighted_average.rb new file mode 100644 index 000000000000..b1d2ce41a29c --- /dev/null +++ b/app/models/journal/caused_by_total_percent_complete_mode_changed_to_work_weighted_average.rb @@ -0,0 +1,33 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Journal::CausedByTotalPercentCompleteModeChangedToWorkWeightedAverage < CauseOfChange::Base + def initialize + super("total_percent_complete_mode_changed_to_work_weighted_average") + end +end diff --git a/app/workers/work_packages/progress/apply_total_percent_complete_mode_change_job.rb b/app/workers/work_packages/progress/apply_total_percent_complete_mode_change_job.rb new file mode 100644 index 000000000000..518d1004959a --- /dev/null +++ b/app/workers/work_packages/progress/apply_total_percent_complete_mode_change_job.rb @@ -0,0 +1,143 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class WorkPackages::Progress::ApplyTotalPercentCompleteModeChangeJob < WorkPackages::Progress::Job + VALID_CAUSE_TYPES = %w[ + total_percent_complete_mode_changed_to_work_weighted_average + total_percent_complete_mode_changed_to_simple_average + ].freeze + + attr_reader :cause_type, :old_mode, :new_mode + + # Updates the total % complete of all work packages after the total + # percent complete mode has been changed. + # + # It creates a journal entry with the System user describing the changes. + # + # + # Updates the total % complete of all work packages after the total + # percent complete mode has been changed. + # + # It creates a journal entry with the System user describing the changes. + # + # @param [String] old_mode The previous total percent complete mode + # @param [String] new_mode The new total percent complete mode + # @return [void] + def perform(cause_type:, old_mode:, new_mode:) + @cause_type = cause_type + @old_mode = old_mode + @new_mode = new_mode + + with_temporary_total_percent_complete_table do + update_total_percent_complete + copy_total_percent_complete_values_to_work_packages_and_update_journals(journal_cause) + end + end + + private + + def update_total_percent_complete + case new_mode + when "work_weighted_average" + update_work_weighted_average + when "simple_average" + update_simple_average + else + raise ArgumentError, "Invalid total percent complete mode: #{new_mode}" + end + end + + def update_work_weighted_average + execute(<<~SQL.squish) + UPDATE temp_wp_progress_values + SET total_p_complete = CASE + WHEN total_work IS NULL OR total_remaining_work IS NULL THEN NULL + WHEN total_work = 0 THEN NULL + ELSE ROUND( + ((total_work - total_remaining_work)::float / total_work) * 100 + ) + END + WHERE id IN ( + SELECT ancestor_id + FROM work_package_hierarchies + GROUP BY ancestor_id + HAVING MAX(generations) > 0 + ) + SQL + end + + def update_simple_average + execute(<<~SQL.squish) + UPDATE temp_wp_progress_values + SET derived_done_ratio = CASE + WHEN avg_ratios.avg_done_ratio IS NOT NULL THEN ROUND(avg_ratios.avg_done_ratio) + ELSE done_ratio + END + FROM ( + SELECT wp_tree.ancestor_id AS id, + AVG(CASE + WHEN wp_tree.generations = 1 THEN COALESCE(wp_progress.done_ratio, 0) + ELSE NULL + END) AS avg_done_ratio + FROM work_package_hierarchies wp_tree + LEFT JOIN temp_wp_progress_values wp_progress ON wp_tree.descendant_id = wp_progress.id + LEFT JOIN statuses ON wp_progress.status_id = statuses.id + WHERE statuses.excluded_from_totals = FALSE + GROUP BY wp_tree.ancestor_id + ) avg_ratios + WHERE temp_wp_progress_values.id = avg_ratios.id + AND temp_wp_progress_values.id IN ( + SELECT ancestor_id AS id + FROM work_package_hierarchies + GROUP BY id + HAVING MAX(generations) > 0 + ) + SQL + end + + def journal_cause + assert_valid_cause_type! + + @journal_cause ||= + case cause_type + when "total_percent_complete_mode_changed_to_work_weighted_average" + Journal::CausedByTotalPercentCompleteModeChangedToWorkWeightedAverage.new + when "total_percent_complete_mode_changed_to_simple_average" + Journal::CausedByTotalPercentCompleteModeChangedToSimpleAverage.new + else + raise "Unable to handle cause type #{cause_type.inspect}" + end + end + + def assert_valid_cause_type! + unless VALID_CAUSE_TYPES.include?(cause_type) + raise ArgumentError, "Invalid cause type #{cause_type.inspect}. " \ + "Valid values are #{VALID_CAUSE_TYPES.inspect}" + end + end +end diff --git a/app/workers/work_packages/progress/sql_commands.rb b/app/workers/work_packages/progress/sql_commands.rb index b00763d1098f..971d6556261d 100644 --- a/app/workers/work_packages/progress/sql_commands.rb +++ b/app/workers/work_packages/progress/sql_commands.rb @@ -60,6 +60,41 @@ def drop_temporary_progress_table SQL end + def with_temporary_total_percent_complete_table + WorkPackage.transaction do + case new_mode + when "work_weighted_average" + create_temporary_total_percent_complete_table_for_work_weighted_average_mode + when "simple_average" + create_temporary_total_percent_complete_table_for_simple_average_mode + else + raise ArgumentError, "Invalid total percent complete mode: #{new_mode}" + end + + yield + ensure + drop_temporary_total_percent_complete_table + end + end + + def create_temporary_total_percent_complete_table_for_work_weighted_average_mode + execute(<<~SQL.squish) + CREATE UNLOGGED TABLE temp_wp_progress_values + AS SELECT + id, + derived_estimated_hours as total_work, + derived_remaining_hours as total_remaining_work, + derived_done_ratio as total_p_complete + FROM work_packages + SQL + end + + def drop_temporary_total_percent_complete_table + execute(<<~SQL.squish) + DROP TABLE temp_wp_progress_values + SQL + end + def derive_remaining_work_from_work_and_p_complete execute(<<~SQL.squish) UPDATE temp_wp_progress_values @@ -170,6 +205,27 @@ def copy_progress_values_to_work_packages results.column_values(0) end + def copy_total_percent_complete_values_to_work_packages_and_update_journals(cause) + updated_work_package_ids = copy_total_percent_complete_values_to_work_packages + create_journals_for_updated_work_packages(updated_work_package_ids, cause:) + end + + def copy_total_percent_complete_values_to_work_packages + results = execute(<<~SQL.squish) + UPDATE work_packages + SET derived_done_ratio = temp_wp_progress_values.total_p_complete, + lock_version = lock_version + 1, + updated_at = NOW() + FROM temp_wp_progress_values + WHERE work_packages.id = temp_wp_progress_values.id + AND ( + work_packages.derived_done_ratio IS DISTINCT FROM temp_wp_progress_values.total_p_complete + ) + RETURNING work_packages.id + SQL + results.column_values(0) + end + def create_journals_for_updated_work_packages(updated_work_package_ids, cause:) WorkPackage.where(id: updated_work_package_ids).find_each do |work_package| Journals::CreateService diff --git a/spec/workers/work_packages/progress/apply_total_percent_complete_mode_change_job_spec.rb b/spec/workers/work_packages/progress/apply_total_percent_complete_mode_change_job_spec.rb new file mode 100644 index 000000000000..51e0cc38e06c --- /dev/null +++ b/spec/workers/work_packages/progress/apply_total_percent_complete_mode_change_job_spec.rb @@ -0,0 +1,235 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe WorkPackages::Progress::ApplyTotalPercentCompleteModeChangeJob do + shared_let(:author) { create(:user) } + shared_let(:priority) { create(:priority, name: "Normal") } + shared_let(:project) { create(:project, name: "Main project") } + + # statuses for work-based mode + shared_let(:status_new) { create(:status, name: "New") } + shared_let(:status_wip) { create(:status, name: "In progress") } + shared_let(:status_closed) { create(:status, name: "Closed") } + + # statuses for status-based mode + shared_let(:status_0p_todo) { create(:status, name: "To do (0%)", default_done_ratio: 0) } + shared_let(:status_40p_doing) { create(:status, name: "Doing (40%)", default_done_ratio: 40) } + shared_let(:status_100p_done) { create(:status, name: "Done (100%)", default_done_ratio: 100) } + + # statuses for both work-based and status-based modes + shared_let(:status_excluded) { create(:status, :excluded_from_totals, name: "Excluded") } + + before_all do + set_factory_default(:user, author) + set_factory_default(:priority, priority) + set_factory_default(:project, project) + set_factory_default(:project_with_types, project) + set_factory_default(:status, status_new) + end + + subject(:job) { described_class } + + def expect_performing_job_changes(from:, to:, + cause_type: "total_percent_complete_mode_changed_to_work_weighted_average", + old_mode: "simple_average", + new_mode: "work_weighted_average") + table = create_table(from) + + job.perform_now(cause_type:, old_mode:, new_mode:) + + table.work_packages.map(&:reload) + expect_work_packages(table.work_packages, to) + + table.work_packages + end + + context "when changing from simple average to work weighted average mode", + with_settings: { total_percent_complete_mode: "work_weighted_average" } do + context "on a single-level hierarchy" do + it "updates the total % complete of the work packages" do + expect_performing_job_changes( + old_mode: "simple_average", + new_mode: "work_weighted_average", + from: <<~TABLE, + hierarchy | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete + flat_wp_1 | 10h | | 6h | | 40% | + flat_wp_2 | 5h | | 3h | | 60% | + TABLE + to: <<~TABLE + subject | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete + flat_wp_1 | 10h | | 6h | | 40% | + flat_wp_2 | 5h | | 3h | | 60% | + TABLE + ) + end + end + + context "on a two-level hierarchy with parents having total values" do + it "updates the total % complete of parent work packages" do + expect_performing_job_changes( + old_mode: "simple_average", + new_mode: "work_weighted_average", + from: <<~TABLE, + hierarchy | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete + parent | 10h | 30h | 6h | 6h | 40% | 70% + child1 | 15h | | 0h | | 100% | + child2 | | | | | 40% | + child3 | 5h | | 0h | | 100% | + TABLE + to: <<~TABLE + subject | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete + parent | 10h | 30h | 6h | 6h | 40% | 80% + child1 | 15h | | 0h | | 100% | + child2 | | | | | 40% | + child3 | 5h | | 0h | | 100% | + TABLE + ) + end + end + + context "on a two-level hierarchy with only % complete values set" do + it "unsets the % complete value from parents" do + expect_performing_job_changes( + old_mode: "simple_average", + new_mode: "work_weighted_average", + from: <<~TABLE, + hierarchy | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete + parent | | | | | 40% | 70% + child1 | | | | | 100% | + child2 | | | | | 40% | + child3 | | | | | 100% | + TABLE + to: <<~TABLE + subject | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete + parent | | | | | 40% | + child1 | | | | | 100% | + child2 | | | | | 40% | + child3 | | | | | 100% | + TABLE + ) + end + end + + context "on a multi-level hierarchy with only % complete values set" do + it "unsets the % complete value from parents" do + expect_performing_job_changes( + old_mode: "simple_average", + new_mode: "work_weighted_average", + from: <<~TABLE, + hierarchy | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete + parent | | | | | 40% | 63% + child1 | | | | | 100% | + child2 | | | | | 40% | + child3 | | | | | 100% | 70% + grandchild1 | | | | | 40% | + grandchild2 | | | | | 100% | + TABLE + to: <<~TABLE + subject | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete + parent | | | | | 40% | + child1 | | | | | 100% | + child2 | | | | | 40% | + child3 | | | | | 100% | + grandchild1 | | | | | 40% | + grandchild2 | | | | | 100% | + TABLE + ) + end + end + + context "on a multi-level hierarchy with work and remaining work values set" do + it "updates the total % complete of parent work packages" do + expect_performing_job_changes( + old_mode: "simple_average", + new_mode: "work_weighted_average", + from: <<~TABLE, + hierarchy | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete + parent | 10h | 50h | 6h | 6h | 40% | 63% + child1 | 15h | | 0h | | 100% | + child2 | | | | | 40% | + child3 | 5h | 25h | 0h | 0h | 100% | 70% + grandchild1 | | | | | 40% | + grandchild2 | 20h | | 0h | | 100% | + TABLE + to: <<~TABLE + subject | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete + parent | 10h | 50h | 6h | 6h | 40% | 88% + child1 | 15h | | 0h | | 100% | + child2 | | | | | 40% | + child3 | 5h | 25h | 0h | 0h | 100% | 100% + grandchild1 | | | | | 40% | + grandchild2 | 20h | | 0h | | 100% | + TABLE + ) + end + end + + describe "journal entries" do + it "is still not done" do + pending "TODO: Add specs for the journal entries created" + raise StandardError, "Not implemented" + end + end + end + + context "with errors during job execution" do + let_work_packages(<<~TABLE) + subject | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete + wp | 10h | 10h | 6h | 6h | 40% | 40% + wp 0% | 10h | 10h | 10h | 10h | 0% | 0% + wp 40% | 10h | 10h | 6h | 6h | 40% | 40% + wp 100% | 10h | 10h | 0h | 0h | 100% | 100% + TABLE + + before do + job.perform_now(cause_type: "should make it blow up!", + old_mode: "simple_average", + new_mode: "work_weighted_average") + rescue StandardError + # Catch the error to continue the test + end + + it "does not update any work package" do + expect_work_packages(WorkPackage.all, <<~TABLE) + subject | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete + wp | 10h | 10h | 6h | 6h | 40% | 40% + wp 0% | 10h | 10h | 10h | 10h | 0% | 0% + wp 40% | 10h | 10h | 6h | 6h | 40% | 40% + wp 100% | 10h | 10h | 0h | 0h | 100% | 100% + TABLE + end + + it "cleans up temporary database artifacts used throughout the job" do + expect( + ActiveRecord::Base.connection.table_exists?("temp_wp_progress_values") + ).to be(false) + end + end +end