diff --git a/app/services/projects/creation_wizard/reupload_artifact_on_status_changes_service.rb b/app/services/projects/creation_wizard/reupload_artifact_on_status_changes_service.rb new file mode 100644 index 00000000000..288bbc43f2e --- /dev/null +++ b/app/services/projects/creation_wizard/reupload_artifact_on_status_changes_service.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Projects::CreationWizard + class ReuploadArtifactOnStatusChangesService + include Contracted + include ProjectHelper + include Rails.application.routes.url_helpers + prepend Projects::Concerns::UpdateDemoData + + attr_reader :current_user, :work_package + + delegate :project, to: :work_package + + def initialize(current_user:, work_package:) + @current_user = current_user + @work_package = work_package + end + + def call!(changes:) + return unless OpenProject::FeatureDecisions.project_initiation_active? + return if changes["status_id"].blank? + return unless update_is_artifact_work_package? + + User.execute_as_admin(current_user) do + update_artifact + end + end + + def update_is_artifact_work_package? + project.project_creation_wizard_artifact_work_package_id.to_s == work_package.id.to_s + end + + private + + def update_artifact + call = store_artifact + if call.success? + Rails.logger.debug { "Updated artifact for creation wizard in ##{work_package.id}" } + else + Rails.logger.error("Failed to process artifact change for work package ##{work_package.id}: ##{call.message}") + end + end + + def store_artifact + if store_attachment_locally? + return add_attachment_locally + end + + if project_storage.nil? + return ServiceResult.failure(message: I18n.t("projects.wizard.create_artifact_storage_error")) + end + + upload_artifact_to_storage + end + + def store_attachment_locally? + project.project_creation_wizard_artifact_export_type == "attachment" + end + + def upload_artifact_to_storage + export = create_pdf_export! + + Storages::UploadFileService + .call( + container: work_package, + project_storage:, + file_path: project.project_creation_wizard_artifact_name, + file_data: StringIO.new(export.content), + filename: export.title + ) + end + + def project_storage + return @project_storage if defined?(@project_storage) + + @project_storage = project + .project_storages + .find_by(id: project.project_creation_wizard_artifact_export_storage) + end + + def add_attachment_locally + export = create_pdf_export! + file = OpenProject::Files.create_uploaded_file( + name: export.title, + content_type: export.mime_type, + content: export.content, + binary: true + ) + + attachment = work_package.attachments.create( + author: current_user, + file: + ) + + if attachment.persisted? + ServiceResult.success(result: attachment) + else + ServiceResult.failure(result: attachment, errors: attachment.errors) + end + end + + def create_pdf_export! + Project::PDFExport::ProjectInitiation.new(project).export! + end + end +end diff --git a/app/workers/work_packages/workflow_job.rb b/app/workers/work_packages/workflow_job.rb new file mode 100644 index 00000000000..08365232be5 --- /dev/null +++ b/app/workers/work_packages/workflow_job.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module WorkPackages + class WorkflowJob < ApplicationJob + def perform(journal, changes) + work_package = journal.journable + process_artifact_changes(work_package, changes) + end + + private + + def process_artifact_changes(work_package, changes) + Projects::CreationWizard::ReuploadArtifactOnStatusChangesService + .new(current_user: User.current, work_package:) + .call!(changes:) + end + end +end diff --git a/config/initializers/subscribe_listeners.rb b/config/initializers/subscribe_listeners.rb index abbc096c3d2..5d087000ca6 100644 --- a/config/initializers/subscribe_listeners.rb +++ b/config/initializers/subscribe_listeners.rb @@ -30,13 +30,19 @@ Rails.application.config.after_initialize do OpenProject::Notifications.subscribe(OpenProject::Events::JOURNAL_CREATED) do |payload| + journal = payload[:journal] + send_notifications = payload[:send_notification] + + # A job is scheduled immediately that handles additional workflows on work packages + WorkPackages::WorkflowJob.perform_later(journal, payload[:changes]) if journal.journable_type == "WorkPackage" + # A job is scheduled immediately that creates notifications (in-app if # supported) right away and schedules jobs to be run for mail and digest # mails. Notifications::WorkflowJob .perform_later(:create_notifications, - payload[:journal], - payload[:send_notification]) + journal, + send_notifications) # A job is scheduled for the end of the journal aggregation time. If the # journal still exists with a matching updated_at value (it might be updated diff --git a/lib_static/plugins/acts_as_journalized/lib/acts/journalized/save_hooks.rb b/lib_static/plugins/acts_as_journalized/lib/acts/journalized/save_hooks.rb index 014f7a7aeb5..f307230e5b4 100644 --- a/lib_static/plugins/acts_as_journalized/lib/acts/journalized/save_hooks.rb +++ b/lib_static/plugins/acts_as_journalized/lib/acts/journalized/save_hooks.rb @@ -67,6 +67,7 @@ module Acts::Journalized if create_call.success? && create_call.result OpenProject::Notifications.send(OpenProject::Events::JOURNAL_CREATED, + changes: previous_changes, journal: create_call.result, send_notification: Journal::NotificationConfiguration.active?) end diff --git a/spec/services/projects/creation_wizard/reupload_artifact_on_status_changes_service_spec.rb b/spec/services/projects/creation_wizard/reupload_artifact_on_status_changes_service_spec.rb new file mode 100644 index 00000000000..db9fe6e3c1f --- /dev/null +++ b/spec/services/projects/creation_wizard/reupload_artifact_on_status_changes_service_spec.rb @@ -0,0 +1,289 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Projects::CreationWizard::ReuploadArtifactOnStatusChangesService, + with_flag: { project_initiation: true } do + shared_let(:status_new) { create(:status, name: "New") } + shared_let(:status_approved) { create(:status, name: "Approved") } + shared_let(:type) { create(:type, name: "Project initiation") } + shared_let(:user_custom_field) { create(:user_project_custom_field, name: "Project Manager") } + shared_let(:assignee_user) { create(:user, firstname: "assignee_user") } + shared_let(:current_user) { create(:user, lastname: "current_user") } + shared_let(:role) do + create(:project_role, permissions: %i[ + add_work_packages + view_project_attributes + work_package_assigned + edit_work_packages + ]) + end + shared_let(:default_priority) { create(:default_priority) } + shared_let(:project) do + create( + :project, + name: "Important Project", + types: [type], + project_custom_fields: [user_custom_field], + # project initiation request settings + project_creation_wizard_artifact_name: "project_mandate", + project_creation_wizard_enabled: true, + project_creation_wizard_work_package_type_id: type.id, + project_creation_wizard_status_when_submitted_id: status_new.id, + project_creation_wizard_assignee_custom_field_id: user_custom_field.id, + project_creation_wizard_work_package_comment: "PIR submitted for **Project Name**.", + user_custom_field.attribute_name => assignee_user.id + ).tap do |p| + p.members << create(:member, principal: assignee_user, project: p, roles: [role]) + p.members << create(:member, principal: current_user, project: p, roles: [role]) + end + end + + shared_let(:work_package) do + create( + :work_package, + project:, + type:, + status: status_new, + subject: "Artifact Work Package", + assigned_to: assignee_user + ) + end + + let(:instance) do + described_class.new(current_user:, work_package:) + end + + let(:changes) { { "status_id" => [status_new.id, status_approved.id] } } + + before do + login_as current_user + project.update(project_creation_wizard_artifact_work_package_id: work_package.id) + end + + describe "#call!" do + context "when status_id is not in changes" do + let(:changes) { {} } + + it "returns early without processing" do + project.update(project_creation_wizard_artifact_export_type: "attachment") + allow(User).to receive(:execute_as_admin) + + instance.call!(changes:) + + expect(User).not_to have_received(:execute_as_admin) + end + end + + context "when status_id is blank in changes" do + let(:changes) { { "status_id" => nil } } + + it "returns early without processing" do + project.update(project_creation_wizard_artifact_export_type: "attachment") + allow(User).to receive(:execute_as_admin) + + instance.call!(changes:) + + expect(User).not_to have_received(:execute_as_admin) + end + end + + context "when work package is not the artifact work package" do + before do + project.update(project_creation_wizard_artifact_work_package_id: work_package.id + 999) + end + + it "returns early without processing" do + project.update(project_creation_wizard_artifact_export_type: "attachment") + allow(User).to receive(:execute_as_admin) + + instance.call!(changes:) + + expect(User).not_to have_received(:execute_as_admin) + end + end + + context "when artifact storage is internal (attachment)" do + before do + project.update(project_creation_wizard_artifact_export_type: "attachment") + end + + it "adds the PDF as an attachment to the existing work package" do + initial_attachment_count = work_package.attachments.count + + instance.call!(changes:) + + work_package.reload + expect(work_package.attachments.count).to eq(initial_attachment_count + 1) + + attachment = work_package.attachments.last + date = Date.current.iso8601 + expect(attachment.content_type).to eq "application/pdf" + expect(attachment.filename).to match(/.*_Project_mandate_#{status_new.name}_#{date}_\d+-\d+.pdf/) + expect(attachment.author).to eq(current_user) + end + + context "when work package already has an attachment" do + before do + work_package.attachments.create( + author: current_user, + file: OpenProject::Files.create_uploaded_file( + name: "existing_file.pdf", + content_type: "application/pdf", + content: "old content", + binary: true + ) + ) + end + + it "adds a new attachment without removing the existing one" do + initial_count = work_package.attachments.count + + instance.call!(changes:) + + work_package.reload + expect(work_package.attachments.count).to eq(initial_count + 1) + end + end + end + + context "when artifact storage is project storage (file link)" do + let(:storage) { create(:nextcloud_storage_with_local_connection) } + let(:project_storage) { create(:project_storage, project:, storage:, project_folder_id: "/project_folder") } + let(:service_result) { ServiceResult.success(result: nil) } + + before do + project.update( + project_creation_wizard_artifact_export_type: "file_link", + project_creation_wizard_artifact_export_storage: project_storage.id + ) + + allow(Storages::UploadFileService) + .to receive(:call) + .and_return(service_result) + end + + it "uploads the artifact to the project storage" do + instance.call!(changes:) + + work_package.reload + expect(work_package.attachments.count).to eq(0) + + date = Date.current.iso8601 + expect(Storages::UploadFileService) + .to have_received(:call) + .with(container: work_package, + project_storage:, + file_path: "project_mandate", + filename: /.*_Project_mandate_#{status_new.name}_#{date}_\d+-\d+.pdf/, + file_data: instance_of(StringIO)) + end + + context "when storage upload fails" do + let(:service_result) do + ServiceResult.failure(result: nil).tap do |result| + result.errors.add(:base, "Storage upload failed!") + end + end + + it "logs the failure" do + allow(Rails.logger).to receive(:error) + + instance.call!(changes:) + + expect(Storages::UploadFileService).to have_received(:call) + expect(Rails.logger).to have_received(:error).with(/Failed to process artifact change/) + end + end + + context "when project storage is not configured" do + before do + project.update(project_creation_wizard_artifact_export_storage: nil) + end + + it "logs the failure" do + allow(Rails.logger).to receive(:error) + + instance.call!(changes:) + + expect(Rails.logger).to have_received(:error).with(/Failed to process artifact change/) + end + + it "does not call the storage upload service" do + allow(Rails.logger).to receive(:error) + + instance.call!(changes:) + + expect(Storages::UploadFileService).not_to have_received(:call) + end + end + + context "when project storage does not exist (invalid id)" do + before do + project.update(project_creation_wizard_artifact_export_storage: 99999) + end + + it "logs the failure" do + allow(Rails.logger).to receive(:error) + + instance.call!(changes:) + + expect(Rails.logger).to have_received(:error).with(/Failed to process artifact change/) + end + end + end + + context "when current user executes as admin" do + it "executes the update with admin privileges" do + allow(User).to receive(:execute_as_admin).with(current_user).and_call_original + project.update(project_creation_wizard_artifact_export_type: "attachment") + + instance.call!(changes:) + + expect(User).to have_received(:execute_as_admin).with(current_user) + end + end + + context "when PDF export creation fails" do + let(:pdf_export) { instance_double(Project::PDFExport::ProjectInitiation) } + + before do + project.update(project_creation_wizard_artifact_export_type: "attachment") + allow(Project::PDFExport::ProjectInitiation).to receive(:new).and_return(pdf_export) + allow(pdf_export).to receive(:export!).and_raise(StandardError, "PDF generation failed") + end + + it "raises an error" do + expect { instance.call!(changes:) }.to raise_error(StandardError, "PDF generation failed") + end + end + end +end