Merge branch 'release/17.0' into dev

This commit is contained in:
OpenProject Actions CI
2025-12-09 03:48:44 +00:00
7 changed files with 483 additions and 4 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
-1
View File
@@ -24,7 +24,6 @@ Below you will find a complete list of all changes and bug fixes.
- Bugfix: Error when creating a new work package after the previous one is opened in details view \[[#67980](https://community.openproject.org/wp/67980)\]
- Bugfix: OpenID Connect: Claims escaped twice \[[#69079](https://community.openproject.org/wp/69079)\]
- Bugfix: Disable editing of sendmail attributes through UI \[[#69577](https://community.openproject.org/wp/69577)\]
- Changed: Restrict editing of user email addresses to superadmins, rather than "Manage user" permissions \[[#69578](https://community.openproject.org/wp/69578)\]
<!-- END AUTOMATED SECTION -->
@@ -62,7 +62,11 @@ You can also [report a vulnerability directly in GitHub](https://github.com/opf/
Please include a description on how to reproduce the issue if possible. Our security team will get your email and will attempt to reproduce and fix the issue as soon as possible.
> **Please note:** OpenProject currently does not offer a bug bounty program. We will do our best to give you the appropriate credits for responsibly disclosing a security vulnerability to us. We will gladly reference your work, name, website on every publication we do related to the security update.
## Bug bounty program
OpenProject is currently subject of a bug bounty program, kindly sponsored by the European Commission. Please see https://yeswehack.com/programs/openproject for more details.
Please note that OpenProject does not offer its own bug bounty program. For any security vulnerability you responsibly disclose to it, whether it's through another bug bounty porgram or through our website, we will do our best to give you the appropriate credits for responsibly disclosing a security vulnerability to us. We will gladly reference your work, name, website on every publication we do related to the security update.
## OpenProject security features
@@ -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