Merge pull request #21214 from opf/feat/workflow-work-package

Recreate artifact on status change
This commit is contained in:
Oliver Günther
2025-12-08 14:08:55 +01:00
committed by GitHub
parent 6b197347cb
commit 3aa5275567
5 changed files with 478 additions and 2 deletions
@@ -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
+46
View File
@@ -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
+8 -2
View File
@@ -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
@@ -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
@@ -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