Skip to content

Commit 2c7e76e

Browse files
authored
Merge pull request opf#15054 from opf/impl/batch_copy_project_job
Introduces the use of `GoodJob::Batch` for CopyProjectJob in order to support async managed remote folder copy.
2 parents aa996b6 + 8d67467 commit 2c7e76e

33 files changed

+1280
-902
lines changed

Diff for: app/services/projects/copy_service.rb

+15-3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# frozen_string_literal: true
2+
13
#-- copyright
24
# OpenProject is an open source project management software.
35
# Copyright (C) 2012-2024 the OpenProject GmbH
@@ -41,12 +43,22 @@ def self.copy_dependencies
4143
::Projects::Copy::QueriesDependentService,
4244
::Projects::Copy::BoardsDependentService,
4345
::Projects::Copy::OverviewDependentService,
44-
::Projects::Copy::StoragesDependentService,
45-
::Projects::Copy::StorageProjectFoldersDependentService,
46-
::Projects::Copy::FileLinksDependentService
46+
::Projects::Copy::StoragesDependentService
4747
]
4848
end
4949

50+
# Project Folders and File Links aren't dependent services anymore,
51+
# so we need to amend the services for the form Representer
52+
def self.copyable_dependencies
53+
super + [{ identifier: "storage_project_folders",
54+
name_source: -> { I18n.t(:label_project_storage_project_folder) },
55+
count_source: ->(source, _) { source.storages.count } },
56+
57+
{ identifier: "file_links",
58+
name_source: -> { I18n.t("projects.copy.work_package_file_links") },
59+
count_source: ->(source, _) { source.work_packages.joins(:file_links).count("file_links.id") } }]
60+
end
61+
5062
protected
5163

5264
##

Diff for: app/services/projects/enqueue_copy_service.rb

+9-5
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# frozen_string_literal: true
2+
13
#-- copyright
24
# OpenProject is an open source project management software.
35
# Copyright (C) 2012-2024 the OpenProject GmbH
@@ -60,11 +62,13 @@ def test_copy(params)
6062
##
6163
# Schedule the project copy job
6264
def schedule_copy_job(params)
63-
CopyProjectJob.perform_later(user_id: user.id,
64-
source_project_id: source.id,
65-
target_project_params: params[:target_project_params],
66-
associations_to_copy: params[:only].to_a,
67-
send_mails: ActiveRecord::Type::Boolean.new.cast(params[:send_notifications]))
65+
job = nil
66+
GoodJob::Batch.enqueue(on_finish: SendCopyProjectStatusEmailJob, user:, source_project: source) do
67+
job = CopyProjectJob.perform_later(target_project_params: params[:target_project_params],
68+
associations_to_copy: params[:only].to_a,
69+
send_mails: ActiveRecord::Type::Boolean.new.cast(params[:send_notifications]))
70+
end
71+
job
6872
end
6973
end
7074
end

Diff for: app/workers/copy_project_job.rb

+63-75
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# frozen_string_literal: true
2+
13
#-- copyright
24
# OpenProject is an open source project management software.
35
# Copyright (C) 2012-2024 the OpenProject GmbH
@@ -27,71 +29,51 @@
2729
#++
2830

2931
class CopyProjectJob < ApplicationJob
30-
queue_with_priority :above_normal
3132
include OpenProject::LocaleHelper
33+
include GoodJob::ActiveJobExtensions::Batches
3234

33-
attr_reader :user_id,
34-
:source_project_id,
35-
:target_project_params,
36-
:target_project_name,
37-
:target_project,
38-
:errors,
39-
:associations_to_copy,
40-
:send_mails
41-
42-
def perform(user_id:,
43-
source_project_id:,
44-
target_project_params:,
45-
associations_to_copy:,
46-
send_mails: false)
47-
# Needs refactoring after moving to activejob
48-
49-
@user_id = user_id
50-
@source_project_id = source_project_id
51-
@target_project_params = target_project_params.with_indifferent_access
52-
@associations_to_copy = associations_to_copy
53-
@send_mails = send_mails
35+
queue_with_priority :above_normal
5436

37+
# Again error handling pushing the branch costs up
38+
def perform(target_project_params:, associations_to_copy:, send_mails: false)
5539
User.current = user
56-
@target_project_name = target_project_params[:name]
40+
target_project_params = target_project_params.with_indifferent_access
5741

58-
@target_project, @errors = with_locale_for(user) do
59-
create_project_copy
42+
target_project, errors = with_locale_for(user) do
43+
create_project_copy(target_project_params, associations_to_copy, send_mails)
6044
end
6145

46+
update_batch(target_project:, errors:, target_project_name: target_project_params[:name])
47+
6248
if target_project
63-
successful_status_update
64-
ProjectMailer.copy_project_succeeded(user, source_project, target_project, errors).deliver_later
49+
successful_status_update(target_project, errors)
6550
else
66-
failure_status_update
67-
ProjectMailer.copy_project_failed(user, source_project, target_project_name, errors).deliver_later
51+
failure_status_update(errors)
6852
end
6953
rescue StandardError => e
7054
logger.error { "Failed to finish copy project job: #{e} #{e.message}" }
7155
errors = [I18n.t("copy_project.failed_internal")]
72-
failure_status_update
73-
ProjectMailer.copy_project_failed(user, source_project, target_project_name, errors).deliver_later
56+
update_batch(errors:)
57+
failure_status_update(errors)
7458
end
7559

76-
def store_status?
77-
true
78-
end
60+
def store_status? = true
7961

80-
def updates_own_status?
81-
true
82-
end
62+
def updates_own_status? = true
8363

8464
protected
8565

86-
def title
87-
I18n.t(:label_copy_project)
88-
end
66+
def title = I18n.t(:label_copy_project)
8967

9068
private
9169

92-
def successful_status_update
93-
payload = redirect_payload(url_helpers.project_url(target_project))
94-
.merge(hal_links(target_project))
70+
def update_batch(hash)
71+
batch.properties.merge!(hash)
72+
batch.save
73+
end
74+
75+
def successful_status_update(target_project, errors)
76+
payload = redirect_payload(url_helpers.project_url(target_project)).merge(hal_links(target_project))
9577

9678
if errors.any?
9779
payload[:errors] = errors
@@ -102,7 +84,7 @@ def successful_status_update
10284
payload:
10385
end
10486

105-
def failure_status_update
87+
def failure_status_update(errors)
10688
message = I18n.t("copy_project.failed", source_project_name: source_project.name)
10789

10890
if errors
@@ -123,60 +105,66 @@ def hal_links(project)
123105
}
124106
end
125107

126-
def user
127-
@user ||= User.find user_id
128-
end
108+
def user = batch.properties[:user]
129109

130-
def source_project
131-
@source_project ||= Project.find source_project_id
132-
end
110+
def source_project = batch.properties[:source_project]
133111

134-
def create_project_copy
112+
# rubocop:disable Metrics/AbcSize
113+
# Most of the cost is from handling errors, we need to check what can be moved around / removed
114+
def create_project_copy(target_project_params, associations_to_copy, send_mails)
135115
errors = []
136116

137117
ProjectMailer.with_deliveries(send_mails) do
138-
service_call = copy_project
139-
target_project = service_call.result
140-
errors = service_call.errors.full_messages
118+
service_result = copy_project(target_project_params, associations_to_copy, send_mails)
119+
target_project = service_result.result
120+
errors = service_result.errors.full_messages
141121

142122
# We assume the copying worked "successfully" if the project was saved
143-
unless target_project&.persisted?
144-
target_project = nil
123+
if target_project&.persisted?
124+
return target_project, errors
125+
else
145126
logger.error("Copying project fails with validation errors: #{errors.join("\n")}")
127+
return nil, errors
146128
end
147-
148-
return target_project, errors
149129
end
150130
rescue ActiveRecord::RecordNotFound => e
151131
logger.error("Entity missing: #{e.message} #{e.backtrace.join("\n")}")
132+
raise e
152133
rescue StandardError => e
153134
logger.error("Encountered an error when trying to copy project " \
154-
"'#{source_project_id}' : #{e.message} #{e.backtrace.join("\n")}")
135+
"'#{source_project.id}' : #{e.message} #{e.backtrace.join("\n")}")
136+
raise e
155137
ensure
156-
unless errors.empty?
138+
if errors.any?
157139
logger.error("Encountered an errors while trying to copy related objects for " \
158-
"project '#{source_project_id}': #{errors.inspect}")
140+
"project '#{source_project.id}': #{errors.inspect}")
159141
end
160142
end
143+
# rubocop:enable Metrics/AbcSize
161144

162-
def copy_project
163-
::Projects::CopyService
164-
.new(source: source_project, user:)
165-
.call(copy_project_params)
166-
end
145+
def copy_project(target_project_params, associations_to_copy, send_notifications)
146+
copy_service = ::Projects::CopyService.new(source: source_project, user:)
147+
result = copy_service.call({ target_project_params:, send_notifications:, only: Array(associations_to_copy) })
167148

168-
def copy_project_params
169-
params = { target_project_params:, send_notifications: send_mails }
170-
params[:only] = associations_to_copy if associations_to_copy.present?
149+
enqueue_copy_project_folder_jobs(copy_service.state.copied_project_storages,
150+
copy_service.state.work_package_id_lookup,
151+
associations_to_copy)
171152

172-
params
153+
result
173154
end
174155

175-
def logger
176-
Rails.logger
177-
end
156+
def enqueue_copy_project_folder_jobs(copied_storages, work_packages_map, only)
157+
return unless only.intersect?(%w[file_links storage_project_folders])
178158

179-
def url_helpers
180-
@url_helpers ||= OpenProject::StaticRouting::StaticUrlHelpers.new
159+
Array(copied_storages).each do |storage_pair|
160+
batch.enqueue do
161+
Storages::CopyProjectFoldersJob
162+
.perform_later(source: storage_pair[:source], target: storage_pair[:target], work_packages_map:)
163+
end
164+
end
181165
end
166+
167+
def logger = OpenProject.logger
168+
169+
def url_helpers = OpenProject::StaticRouting::StaticUrlHelpers.new
182170
end

Diff for: app/workers/notifications/schedule_date_alerts_notifications_job.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
# along with this program; if not, write to the Free Software
2424
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
2525
#
26-
# See docs/COPYRIGHT.rdoc for more details.
26+
# See COPYRIGHT and LICENSE files for more details.
2727
#++
2828

2929
module Notifications

Diff for: app/workers/notifications/schedule_reminder_mails_job.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
# along with this program; if not, write to the Free Software
2424
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
2525
#
26-
# See docs/COPYRIGHT.rdoc for more details.
26+
# See COPYRIGHT and LICENSE files for more details.
2727
#++
2828

2929
module Notifications

Diff for: app/workers/send_copy_project_status_email_job.rb

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# frozen_string_literal: true
2+
3+
#-- copyright
4+
# OpenProject is an open source project management software.
5+
# Copyright (C) 2012-2024 the OpenProject GmbH
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License version 3.
9+
#
10+
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
11+
# Copyright (C) 2006-2013 Jean-Philippe Lang
12+
# Copyright (C) 2010-2013 the ChiliProject Team
13+
#
14+
# This program is free software; you can redistribute it and/or
15+
# modify it under the terms of the GNU General Public License
16+
# as published by the Free Software Foundation; either version 2
17+
# of the License, or (at your option) any later version.
18+
#
19+
# This program is distributed in the hope that it will be useful,
20+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
21+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22+
# GNU General Public License for more details.
23+
#
24+
# You should have received a copy of the GNU General Public License
25+
# along with this program; if not, write to the Free Software
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
27+
#
28+
# See COPYRIGHT and LICENSE files for more details.
29+
#++
30+
31+
class SendCopyProjectStatusEmailJob < ApplicationJob
32+
# Job is to be used as a callback to the CopyProjectJob batch
33+
34+
def perform(batch, _args)
35+
if copy_job_succeeded?(batch) && batch.properties[:target_project]
36+
send_success_email(batch)
37+
else
38+
send_failure_email(batch)
39+
end
40+
end
41+
42+
private
43+
44+
def copy_job_succeeded?(batch)
45+
job = batch.active_jobs.find { |batch_job| batch_job.instance_of?(CopyProjectJob) }
46+
47+
job.job_status.success?
48+
end
49+
50+
def send_failure_email(batch)
51+
ProjectMailer.copy_project_failed(
52+
batch.properties[:user],
53+
batch.properties[:source_project],
54+
batch.properties[:target_project_name],
55+
batch.properties[:errors]
56+
).deliver_later
57+
end
58+
59+
def send_success_email(batch)
60+
ProjectMailer.copy_project_succeeded(
61+
batch.properties[:user],
62+
batch.properties[:source_project],
63+
batch.properties[:target_project],
64+
batch.properties[:errors]
65+
).deliver_later
66+
end
67+
end

Diff for: modules/storages/app/common/storages/errors.rb

+2
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ class BaseError < StandardError; end
3434

3535
class ResolverStandardError < BaseError; end
3636

37+
class PollingRequired < BaseError; end
38+
3739
class MissingContract < ResolverStandardError; end
3840

3941
class OperationNotSupported < ResolverStandardError; end

Diff for: modules/storages/app/common/storages/peripherals/nextcloud.rb renamed to modules/storages/app/common/storages/peripherals/nextcloud_registry.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030

3131
module Storages
3232
module Peripherals
33-
Nextcloud = Dry::Container::Namespace.new("nextcloud") do
33+
NextcloudRegistry = Dry::Container::Namespace.new("nextcloud") do
3434
namespace("queries") do
3535
register(:auth_check, StorageInteraction::Nextcloud::AuthCheckQuery)
3636
register(:download_link, StorageInteraction::Nextcloud::DownloadLinkQuery)

Diff for: modules/storages/app/common/storages/peripherals/one_drive.rb renamed to modules/storages/app/common/storages/peripherals/one_drive_registry.rb

+2-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030

3131
module Storages
3232
module Peripherals
33-
OneDrive = Dry::Container::Namespace.new("one_drive") do
33+
OneDriveRegistry = Dry::Container::Namespace.new("one_drive") do
3434
namespace("queries") do
3535
register(:auth_check, StorageInteraction::OneDrive::AuthCheckQuery)
3636
register(:download_link, StorageInteraction::OneDrive::DownloadLinkQuery)
@@ -44,6 +44,7 @@ module Peripherals
4444
end
4545

4646
namespace("commands") do
47+
register(:copy_template_folder, StorageInteraction::OneDrive::CopyTemplateFolderCommand)
4748
register(:create_folder, StorageInteraction::OneDrive::CreateFolderCommand)
4849
register(:delete_folder, StorageInteraction::OneDrive::DeleteFolderCommand)
4950
register(:rename_file, StorageInteraction::OneDrive::RenameFileCommand)

Diff for: modules/storages/app/common/storages/peripherals/registry.rb

+2-2
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def call(container, key)
4444
config.resolver = Resolver.new
4545
end
4646

47-
Registry.import Nextcloud
48-
Registry.import OneDrive
47+
Registry.import NextcloudRegistry
48+
Registry.import OneDriveRegistry
4949
end
5050
end

0 commit comments

Comments
 (0)