mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
Merge pull request #21214 from opf/feat/workflow-work-package
Recreate artifact on status change
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+289
@@ -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
|
||||
Reference in New Issue
Block a user