Refactor copy project into service

pull/8498/head
Oliver Günther 4 years ago
parent dd56bc08f3
commit 605f6b8355
No known key found for this signature in database
GPG Key ID: 88872239EB414F99
  1. 6
      app/contracts/projects/copy_contract.rb
  2. 5
      app/controllers/copy_projects_controller.rb
  3. 1
      app/models/project.rb
  4. 386
      app/models/projects/copy.rb
  5. 2
      app/services/base_services/base_contracted.rb
  6. 89
      app/services/base_services/copy.rb
  7. 24
      app/services/copy/concerns/copy_attachments.rb
  8. 96
      app/services/copy/dependency.rb
  9. 43
      app/services/projects/copy/categories_dependent_service.rb
  10. 67
      app/services/projects/copy/forums_dependent_service.rb
  11. 55
      app/services/projects/copy/members_dependent_service.rb
  12. 71
      app/services/projects/copy/queries_dependent_service.rb
  13. 43
      app/services/projects/copy/versions_dependent_service.rb
  14. 102
      app/services/projects/copy/wiki_dependent_service.rb
  15. 157
      app/services/projects/copy/work_packages_dependent_service.rb
  16. 87
      app/services/projects/copy_service.rb
  17. 5
      app/services/service_result.rb
  18. 71
      app/workers/copy_project_job.rb
  19. 163
      lib/copy_model.rb
  20. 2
      spec/workers/copy_project_job_spec.rb

@ -28,6 +28,12 @@
module Projects
class CopyContract < BaseContract
protected
def validate_model?
false
end
private
def validate_user_allowed_to_manage

@ -47,7 +47,10 @@ class CopyProjectsController < ApplicationController
end
def copy_project
@copy_project = Project.copy_attributes(@project)
@copy_project = Projects::CopyService
.new(user: current_user, source: @project)
.call(target_project_params: {}, attributes_only: true)
.result
if @copy_project
project_copy(@copy_project, EmptyContract)

@ -32,7 +32,6 @@ class Project < ApplicationRecord
extend Pagination::Model
extend FriendlyId
include Projects::Copy
include Projects::Storage
include Projects::Activity
include ::Scopes::Scoped

@ -1,386 +0,0 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
module Projects::Copy
def self.included(base)
base.send :include, CopyModel
base.send :include, self::CopyMethods
# things that are explicitly excluded when copying a project
base.not_to_copy ['id', 'created_at', 'updated_at', 'name', 'identifier', 'active', 'lft', 'rgt']
# specify the order of associations to copy
base.copy_precedence ['members', 'versions', 'categories', 'work_packages', 'wiki', 'custom_values', 'queries']
end
module CopyMethods
def copy_attributes(project)
super
with_model(project) do |project_instance|
# Clear enabled modules
self.enabled_modules = []
self.enabled_module_names = project_instance.enabled_module_names - %w[repository]
self.types = project_instance.types
self.work_package_custom_fields = project_instance.work_package_custom_fields
self.custom_field_values = project_instance.custom_value_attributes
end
self
rescue ActiveRecord::RecordNotFound
nil
end
def copy_associations(from_model, options = {})
super(from_model, options) if save
end
private
# Copies custom values from +project+
def copy_custom_values(project, _selected_copies = [])
self.custom_values = project.custom_values.map(&:dup)
end
# Copies wiki from +project+
def copy_wiki(project, selected_copies = [])
# Check that the source project has a wiki first
unless project.wiki.nil?
self.wiki = build_wiki(project.wiki.attributes.dup.except('id', 'project_id'))
wiki.wiki_menu_items.delete_all
copy_wiki_pages(project, selected_copies)
copy_wiki_menu_items(project, selected_copies)
end
end
# Copies wiki pages from +project+, requires a wiki to be already set
def copy_wiki_pages(project, selected_copies = [])
wiki_pages_map = {}
project.wiki.pages.each do |page|
# Skip pages without content
next if page.content.nil?
new_wiki_content = WikiContent.new(page.content.attributes.dup.except('id', 'page_id', 'updated_at'))
new_wiki_page = WikiPage.new(page.attributes.dup.except('id', 'wiki_id', 'created_on', 'parent_id'))
new_wiki_page.content = new_wiki_content
wiki.pages << new_wiki_page
wiki_pages_map[page] = new_wiki_page
end
wiki.save
# Reproduce page hierarchy
project.wiki.pages.each do |page|
if page.parent_id && wiki_pages_map[page]
wiki_pages_map[page].parent = wiki_pages_map[page.parent]
wiki_pages_map[page].save
end
end
# Copy attachments
if selected_copies.include? :wiki_page_attachments
wiki_pages_map.each do |old_page, new_page|
copy_attachments(old_page, new_page)
end
end
end
# Copies wiki_menu_items from +project+, requires a wiki to be already set
def copy_wiki_menu_items(project, _selected_copies = [])
wiki_menu_items_map = {}
project.wiki.wiki_menu_items.each do |item|
new_item = MenuItems::WikiMenuItem.new
new_item.attributes = item.attributes.dup.except('id', 'wiki_id', 'parent_id')
new_item.wiki = wiki
(wiki_menu_items_map[item.id] = new_item.reload) if new_item.save
end
project.wiki.wiki_menu_items.each do |item|
if item.parent_id && (copy = wiki_menu_items_map[item.id])
copy.parent = wiki_menu_items_map[item.parent_id]
copy.save
end
end
end
# Copies versions from +project+
def copy_versions(project, _selected_copies = [])
project.versions.each do |version|
new_version = Version.new
new_version.attributes = version.attributes.dup.except('id', 'project_id', 'created_on', 'updated_at')
versions << new_version
end
end
# Copies issue categories from +project+
def copy_categories(project, _selected_copies = [])
project.categories.each do |category|
new_category = Category.new
new_category.send(:assign_attributes, category.attributes.dup.except('id', 'project_id'))
categories << new_category
end
end
# Copies work_packages from +project+
def copy_work_packages(project, selected_copies = [])
# Stores the source work_package id as a key and the copied work_packages as the
# value. Used to map the two together for work_package relations.
work_packages_map = {}
# Get work_packages sorted by their depth in the hierarchy tree
# so that parents get copied before their children.
to_copy = project
.work_packages
.includes(:custom_values, :version, :assigned_to, :responsible)
.order_by_ancestors('asc')
.order('id ASC')
user_cf_ids = WorkPackageCustomField.where(field_format: 'user').pluck(:id)
to_copy.each do |wp|
parent_id = work_packages_map[wp.parent_id]&.id || wp.parent_id
new_wp = copy_work_package(wp, parent_id, user_cf_ids)
work_packages_map[wp.id] = new_wp if new_wp
end
# reload all work_packages in our map, they might be modified by movement in their tree
work_packages_map.each_value(&:reload)
# Relations and attachments after in case work_packages related each other
to_copy.each do |wp|
new_wp = work_packages_map[wp.id]
unless new_wp
# work_package was not copied
next
end
# Attachments
if selected_copies.include? :work_package_attachments
copy_attachments(wp, new_wp)
end
# Relations
wp.relations_to.non_hierarchy.direct.each do |source_relation|
new_relation = Relation.new
new_relation.attributes = source_relation.attributes.dup.except('id', 'from_id', 'to_id', 'relation_type')
new_relation.to = work_packages_map[source_relation.to_id]
if new_relation.to.nil? && Setting.cross_project_work_package_relations?
new_relation.to = source_relation.to
end
new_relation.from = new_wp
new_relation.save
end
wp.relations_from.non_hierarchy.direct.each do |source_relation|
new_relation = Relation.new
new_relation.attributes = source_relation.attributes.dup.except('id', 'from_id', 'to_id', 'relation_type')
new_relation.from = work_packages_map[source_relation.from_id]
if new_relation.from.nil? && Setting.cross_project_work_package_relations?
new_relation.from = source_relation.from
end
new_relation.to = new_wp
new_relation.save
end
end
end
# Copies members from +project+
def copy_members(project, _selected_copies = [])
# Copy users first, then groups to handle members with inherited and given roles
members_to_copy = []
members_to_copy += project.memberships.select { |m| m.principal.is_a?(User) }
members_to_copy += project.memberships.reject { |m| m.principal.is_a?(User) }
members_to_copy.each do |member|
new_member = Member.new
new_member.send(:assign_attributes, member.attributes.dup.except('id', 'project_id', 'created_on'))
# only copy non inherited roles
# inherited roles will be added when copying the group membership
role_ids = member.member_roles.reject(&:inherited?).map(&:role_id)
next if role_ids.empty?
new_member.role_ids = role_ids
new_member.project = self
memberships << new_member
end
# Update the omitted attributes for the copied memberships
memberships.each do |new_member|
member = project.memberships.find_by(user_id: new_member.user_id)
Redmine::Hook.call_hook(:copy_project_add_member, new_member: new_member, member: member)
new_member.save
end
end
# Copies queries from +project+
# Only includes the queries visible in the wp table view.
def copy_queries(project, _selected_copies = [])
project.queries.non_hidden.includes(:query_menu_item).each do |query|
new_query = duplicate_query(query)
duplicate_query_menu_item(query, new_query)
end
# Update the context in the new project, otherwise, the filters will be invalid
queries.map(&:set_context)
end
# Copies forums from +project+
def copy_forums(project, _selected_copies = [])
project.forums.each do |forum|
new_forum = Forum.new
new_forum.attributes = forum.attributes.dup.except('id',
'project_id',
'topics_count',
'messages_count',
'last_message_id')
copy_topics(forum, new_forum)
new_forum.project = self
forums << new_forum
end
end
def copy_topics(board, new_forum)
topics = board.topics.where('parent_id is NULL')
topics.each do |topic|
new_topic = Message.new
new_topic.attributes = topic.attributes.dup.except('id',
'forum_id',
'author_id',
'replies_count',
'last_reply_id',
'created_on',
'updated_on')
new_topic.forum = new_forum
new_topic.author_id = topic.author_id
new_forum.topics << new_topic
end
end
def copy_attachments(from_container, to_container)
from_container.attachments.each do |old_attachment|
copied = old_attachment.dup
old_attachment.file.copy_to(copied)
to_container.attachments << copied
if copied.new_record?
log_error <<~MSG
Project#copy_attachments: Attachments ##{old_attachment.id} could not be copied: #{copied.errors.full_messages}
MSG
end
rescue StandardError => e
log_error("Failed to copy attachments from #{from_container} to #{to_container}: #{e}")
end
end
def duplicate_query(query)
new_query = ::Query.new name: '_'
new_query.attributes = query.attributes.dup.except('id', 'project_id', 'sort_criteria')
new_query.sort_criteria = query.sort_criteria if query.sort_criteria
new_query.set_context
new_query.project = self
queries << new_query
new_query
end
def duplicate_query_menu_item(source, sink)
if source.query_menu_item && sink.persisted?
::MenuItems::QueryMenuItem.create(
navigatable_id: sink.id,
name: SecureRandom.uuid,
title: source.query_menu_item.title
)
end
end
def copy_work_package(source_work_package, parent_id, user_cf_ids)
overrides = copy_work_package_attribute_overrides(source_work_package, parent_id, user_cf_ids)
service_call = WorkPackages::CopyService
.new(user: User.current,
work_package: source_work_package,
contract_class: WorkPackages::CopyProjectContract)
.call(overrides)
if service_call.success?
service_call.result
elsif logger&.info
log_work_package_copy_error(source_work_package, service_call.errors)
end
end
def copy_work_package_attribute_overrides(source_work_package, parent_id, user_cf_ids)
custom_value_attributes = source_work_package.custom_value_attributes.map do |id, value|
if user_cf_ids.include?(id) && !users.detect { |u| u.id.to_s == value }
[id, nil]
else
[id, value]
end
end.to_h
{
project: self,
parent_id: parent_id,
version: work_package_version(source_work_package),
assigned_to: work_package_assigned_to(source_work_package),
responsible: work_package_responsible(source_work_package),
custom_field_values: custom_value_attributes,
# We fetch the value from the global registry to persist it in the job which
# will trigger a delayed job for potentially sending the journal notifications.
send_notifications: ActionMailer::Base.perform_deliveries
}
end
def work_package_version(source_work_package)
source_work_package.version && versions.detect { |v| v.name == source_work_package.version.name }
end
def work_package_assigned_to(source_work_package)
source_work_package.assigned_to && possible_assignees.detect { |u| u.id == source_work_package.assigned_to_id }
end
def work_package_responsible(source_work_package)
source_work_package.responsible && possible_responsibles.detect { |u| u.id == source_work_package.responsible_id }
end
def log_work_package_copy_error(source_work_package, errors)
compiled_errors << errors
message = <<-MSG
Project#copy_work_packages: work package ##{source_work_package.id} could not be copied: #{errors.full_messages}
MSG
log_error(message, :info)
end
def log_error(message, level = :error)
Rails.logger.send(level, message)
end
end
end

@ -47,7 +47,7 @@ module BaseServices
end
end
private
protected
def perform(params)
service_call = before_perform(params)

@ -0,0 +1,89 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
module BaseServices
class Copy < ::BaseServices::BaseContracted
attr_reader :source
# BaseContracted needs a `model` attribute
alias_attribute :model, :source
def initialize(user:, source:, contract_class: nil, contract_options: { copied_from: source })
@source = source
super(user: user, contract_class: contract_class, contract_options: contract_options)
end
def call(params)
User.execute_as(user) do
perform(params)
end
end
def after_validate(params, _call)
# Initialize the target resource to copy into
call = initialize_copy(model, params)
# Return only the unsaved copy
return call if params[:attributes_only]
# Allow to keep a state object between services
state = {}
copy_dependencies.each do |service_cls|
call.merge! call_dependent_service(service_cls, target: call.result, params: params, state: state)
end
call
end
protected
##
# dependent services to copy associations
def copy_dependencies
raise NotImplementedError
end
##
# Calls a dependent service with the source and copy instance
def call_dependent_service(service_cls, target:, params:, state:)
service_cls
.new(source: model, target: target, user: user)
.call(params: params, state: state)
end
def initialize_copy(source, params)
raise NotImplementedError
end
def default_contract_class
"#{namespace}::CopyContract".constantize
end
end
end

@ -0,0 +1,24 @@
module Copy
module Concerns
module CopyAttachments
##
# Tries to copy the given attachment between containers
def copy_attachments(from_container_id, to_container_id, container_type)
Attachment.where(container_id: from_container_id).find_each do |old_attachment|
copied = old_attachment.dup
old_attachment.file.copy_to(copied)
copied.container_type = container_type
copied.container_id = to_container_id
unless copied.save
Rails.logger.error { "Attachments ##{old_attachment.id} could not be copied: #{copied.errors.full_messages} " }
end
rescue StandardError => e
Rails.logger.error { "Failed to copy attachments from #{from_container} to #{to_container}: #{e}" }
end
end
end
end
end

@ -0,0 +1,96 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
##
# Dependent service to be executed under the BaseServices::Copy service
module Copy
class Dependency
attr_reader :source,
:target,
:user,
:result
##
# Identifier of this dependency to include/exclude
def self.identifier
name.demodulize.gsub('DependentService', '').underscore
end
def initialize(source:, target:, user:)
@source = source
@target = target
@user = user
@result = ServiceResult.new(result: target, success: true)
end
def call(params:, state:)
return result if skip?(params)
begin
perform(params: params, state: state)
rescue StandardError => e
Rails.logger.error { "Failed to copy dependency #{self.class.name}: #{e.message}" }
result.success = false
result.errors.add(self.class.identifier, :could_not_be_copied)
end
result
end
protected
##
# Merge some other model's errors with the result errors
def add_error!(model, errors)
result.errors.add(:base, "#{model.class.model_name.human} '#{model}': #{errors.full_messages.join(". ")}")
end
##
# Whether this entire dependency should be skipped
def skip?(params)
skip_dependency?(params, self.class.identifier)
end
##
# Whether to skip the given key.
# Useful when copying nested dependencies
def skip_dependency?(params, name)
return false unless params[:only].present?
!params[:only].any? { |key| key.to_s == name.to_s }
end
def perform(params:, state:)
raise NotImplementedError
end
end
end

@ -0,0 +1,43 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
module Projects::Copy
class CategoriesDependentService < ::Copy::Dependency
protected
def perform(params:, state:)
source.categories.find_each do |category|
new_category = Category.new
new_category.send(:assign_attributes, category.attributes.dup.except('id', 'project_id'))
target.categories << new_category
end
end
end
end

@ -0,0 +1,67 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
module Projects::Copy
class ForumsDependentService < ::Copy::Dependency
protected
def perform(params:, state:)
source.forums.find_each do |forum|
new_forum = Forum.new
new_forum.attributes = forum.attributes.dup.except('id',
'project_id',
'topics_count',
'messages_count',
'last_message_id')
copy_topics(forum, new_forum)
new_forum.project = target
target.forums << new_forum
end
end
def copy_topics(board, new_forum)
topics = board.topics.where('parent_id is NULL')
topics.each do |topic|
new_topic = Message.new
new_topic.attributes = topic.attributes.dup.except('id',
'forum_id',
'author_id',
'replies_count',
'last_reply_id',
'created_on',
'updated_on')
new_topic.forum = new_forum
new_topic.author_id = topic.author_id
new_forum.topics << new_topic
end
end
end
end

@ -0,0 +1,55 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
module Projects::Copy
class MembersDependentService < ::Copy::Dependency
protected
def perform(params:, state:)
# Copy users first, then groups to handle members with inherited and given roles
members_to_copy = []
members_to_copy += source.memberships.select { |m| m.principal.is_a?(User) }
members_to_copy += source.memberships.reject { |m| m.principal.is_a?(User) }
members_to_copy.each do |member|
new_member = Member.new
new_member.send(:assign_attributes, member.attributes.dup.except('id', 'project_id', 'created_on'))
# only copy non inherited roles
# inherited roles will be added when copying the group membership
role_ids = member.member_roles.reject(&:inherited?).map(&:role_id)
next if role_ids.empty?
new_member.role_ids = role_ids
new_member.project = target
target.memberships << new_member
new_member.save
end
end
end
end

@ -0,0 +1,71 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
module Projects::Copy
class QueriesDependentService < ::Copy::Dependency
protected
def perform(params:, state:)
copy_queries(state[:work_packages_map])
end
# Copies queries from +project+
# Only includes the queries visible in the wp table view.
def copy_queries(work_packages_map)
source.queries.non_hidden.includes(:query_menu_item).each do |query|
new_query = duplicate_query(query)
duplicate_query_menu_item(query, new_query)
end
end
def duplicate_query(query)
new_query = ::Query.new name: '_'
new_query.attributes = query.attributes.dup.except('id', 'project_id', 'sort_criteria')
new_query.sort_criteria = query.sort_criteria if query.sort_criteria
new_query.set_context
new_query.project = target
target.queries << new_query
new_query.set_context
new_query
end
def duplicate_query_menu_item(query, new_query)
if query.query_menu_item && new_query.persisted?
::MenuItems::QueryMenuItem.create(
navigatable_id: new_query.id,
name: SecureRandom.uuid,
title: query.query_menu_item.title
)
end
end
end
end

@ -0,0 +1,43 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
module Projects::Copy
class VersionsDependentService < ::Copy::Dependency
protected
def perform(params:, state:)
source.versions.each do |version|
new_version = Version.new
new_version.attributes = version.attributes.dup.except('id', 'project_id', 'created_on', 'updated_at')
target.versions << new_version
end
end
end
end

@ -0,0 +1,102 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
module Projects::Copy
class WikiDependentService < ::Copy::Dependency
include ::Copy::Concerns::CopyAttachments
protected
def perform(params:, state:)
# Check that the source project has a wiki first
return if source.wiki.nil?
target.wiki = target.build_wiki(source.wiki.attributes.dup.except('id', 'project_id'))
target.wiki.wiki_menu_items.delete_all
copy_wiki_pages
copy_wiki_menu_items
end
# Copies wiki pages from +project+, requires a wiki to be already set
def copy_wiki_pages
wiki_pages_map = {}
source.wiki.pages.find_each do |page|
# Skip pages without content
next if page.content.nil?
new_wiki_content = WikiContent.new(page.content.attributes.dup.except('id', 'page_id', 'updated_at'))
new_wiki_page = WikiPage.new(page.attributes.dup.except('id', 'wiki_id', 'created_on', 'parent_id'))
new_wiki_page.content = new_wiki_content
target.wiki.pages << new_wiki_page
wiki_pages_map[page] = new_wiki_page
end
# Save the wiki
target.wiki.save
# Reproduce page hierarchy
source.project.wiki.pages.each do |page|
if page.parent_id && wiki_pages_map[page]
wiki_pages_map[page].parent = wiki_pages_map[page.parent]
wiki_pages_map[page].save
end
end
# Copy attachments
unless skip_dependency?(params, :wiki_page_attachments)
wiki_pages_map.each do |old_page, new_page|
copy_attachments(old_page.id, new_page.id, new_page.class.name)
end
end
end
# Copies wiki_menu_items from +project+, requires a wiki to be already set
def copy_wiki_menu_items
wiki_menu_items_map = {}
source.wiki.wiki_menu_items.each do |item|
new_item = MenuItems::WikiMenuItem.new
new_item.attributes = item.attributes.dup.except('id', 'wiki_id', 'parent_id')
new_item.wiki = target.wiki
(wiki_menu_items_map[item.id] = new_item.reload) if new_item.save
end
source.wiki.wiki_menu_items.each do |item|
if item.parent_id && (copy = wiki_menu_items_map[item.id])
copy.parent = wiki_menu_items_map[item.parent_id]
copy.save
end
end
end
end
end

@ -0,0 +1,157 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
module Projects::Copy
class WorkPackagesDependentService < ::Copy::Dependency
include ::Copy::Concerns::CopyAttachments
protected
def perform(params:, state:)
# Stores the source work_package id as a key and the copied work package ID as the
# value. Used to map the two together for work_package relations.
work_packages_map = {}
# Get work_packages sorted by their depth in the hierarchy tree
# so that parents get copied before their children.
to_copy = source
.work_packages
.includes(:custom_values, :version, :assigned_to, :responsible)
.order_by_ancestors('asc')
.order('id ASC')
user_cf_ids = WorkPackageCustomField.where(field_format: 'user').pluck(:id)
to_copy.each do |wp|
parent_id = work_packages_map[wp.parent_id] || wp.parent_id
new_wp = copy_work_package(wp, parent_id, user_cf_ids)
work_packages_map[wp.id] = new_wp.id if new_wp
end
# Relations and attachments after in case work_packages related each other
to_copy.each do |wp|
new_wp_id = work_packages_map[wp.id]
next unless new_wp_id
# Attachments
unless skip_dependency?(params, :work_package_attachments)
copy_attachments(wp.id, new_wp_id, 'WorkPackage')
end
copy_relations(wp, new_wp_id, work_packages_map)
end
state[:work_packages_map] = work_packages_map
end
def copy_work_package(source_work_package, parent_id, user_cf_ids)
overrides = copy_work_package_attribute_overrides(source_work_package, parent_id, user_cf_ids)
service_call = WorkPackages::CopyService
.new(user: user,
work_package: source_work_package,
contract_class: WorkPackages::CopyProjectContract)
.call(overrides)
if service_call.success?
service_call.result
else
add_error!(source_work_package, service_call.errors)
error = service_call.message
Rails.logger.warn do
"Project#copy_work_packages: work package ##{source_work_package.id} could not be copied: #{error}"
end
nil
end
end
def copy_relations(wp, new_wp_id, work_packages_map)
wp.relations_to.non_hierarchy.direct.each do |source_relation|
new_relation = Relation.new
new_relation.attributes = source_relation.attributes.dup.except('id', 'from_id', 'to_id', 'relation_type')
new_relation.to_id = work_packages_map[source_relation.to_id]
if new_relation.to_id.nil? && Setting.cross_project_work_package_relations?
new_relation.to_id = source_relation.to_id
end
new_relation.from_id = new_wp_id
new_relation.save
end
wp.relations_from.non_hierarchy.direct.each do |source_relation|
new_relation = Relation.new
new_relation.attributes = source_relation.attributes.dup.except('id', 'from_id', 'to_id', 'relation_type')
new_relation.from_id = work_packages_map[source_relation.from_id]
if new_relation.from_id.nil? && Setting.cross_project_work_package_relations?
new_relation.from_id = source_relation.from_id
end
new_relation.to_id = new_wp_id
new_relation.save
end
end
def copy_work_package_attribute_overrides(source_work_package, parent_id, user_cf_ids)
custom_value_attributes = source_work_package.custom_value_attributes.map do |id, value|
if user_cf_ids.include?(id) && !users.detect { |u| u.id.to_s == value }
[id, nil]
else
[id, value]
end
end.to_h
{
project: target,
parent_id: parent_id,
version: work_package_version(source_work_package),
assigned_to: work_package_assigned_to(source_work_package),
responsible: work_package_responsible(source_work_package),
custom_field_values: custom_value_attributes,
# We fetch the value from the global registry to persist it in the job which
# will trigger a delayed job for potentially sending the journal notifications.
send_notifications: ActionMailer::Base.perform_deliveries
}
end
def work_package_version(source_work_package)
source_work_package.version && versions.detect { |v| v.name == source_work_package.version.name }
end
def work_package_assigned_to(source_work_package)
source_work_package.assigned_to && possible_assignees.detect { |u| u.id == source_work_package.assigned_to_id }
end
def work_package_responsible(source_work_package)
source_work_package.responsible && possible_responsibles.detect { |u| u.id == source_work_package.responsible_id }
end
end
end

@ -0,0 +1,87 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
module Projects
class CopyService < ::BaseServices::Copy
protected
def copy_dependencies
[
::Projects::Copy::MembersDependentService,
::Projects::Copy::VersionsDependentService,
::Projects::Copy::CategoriesDependentService,
::Projects::Copy::WorkPackagesDependentService,
::Projects::Copy::WikiDependentService,
::Projects::Copy::QueriesDependentService
]
end
def initialize_copy(source, params)
target = Project.new
target.attributes = source.attributes.dup.except(*skipped_attributes)
# Clear enabled modules
target.enabled_modules = []
target.enabled_module_names = source.enabled_module_names - %w[repository]
target.types = source.types
target.work_package_custom_fields = source.work_package_custom_fields
# Copy enabled custom fields and their values
target.custom_field_values = source.custom_value_attributes
target.custom_values = source.custom_values.map(&:dup)
cleanup_target_project_attributes(target)
cleanup_target_project_params(params)
# Assign additional params from user
Projects::SetAttributesService
.new(user: user,
model: target,
contract_class: Projects::CopyContract,
contract_options: { copied_from: source })
.call(params[:target_project_params])
end
def cleanup_target_project_params(params)
if (parent_id = params[:target_project_params]["parent_id"]) && (parent = Project.find_by(id: parent_id))
params[:target_project_params].delete("parent_id") unless user.allowed_to?(:add_subprojects, parent)
end
end
def cleanup_target_project_attributes(target_project)
if target_project.parent
target_project.parent = nil unless user.allowed_to?(:add_subprojects, target_project.parent)
end
end
def skipped_attributes
%w[id created_at updated_at name identifier active lft rgt]
end
end
end

@ -61,6 +61,7 @@ class ServiceResult
def merge!(other)
merge_success!(other)
merge_errors!(other)
merge_dependent!(other)
end
@ -159,6 +160,10 @@ class ServiceResult
self.success &&= other.success
end
def merge_errors!(other)
self.errors.merge! other.errors
end
def merge_dependent!(other)
self.dependent_results += other.dependent_results
end

@ -112,21 +112,18 @@ class CopyProjectJob < ApplicationJob
end
def source_project
@project ||= Project.find source_project_id
@source_project ||= Project.find source_project_id
end
def create_project_copy
errors = []
ProjectMailer.with_deliveries(send_mails) do
service_call = copy_project_attributes
service_call = copy_project
target_project = service_call.result
errors = service_call.errors.full_messages
if service_call.success? && target_project.save
errors = copy_project_associations(target_project)
else
service_call.errors.merge!(target_project.errors, nil)
errors = service_call.errors.full_messages
unless service_call.success? && target_project.save
target_project = nil
logger.error("Copying project fails with validation errors: #{errors.join("\n")}")
end
@ -145,61 +142,21 @@ class CopyProjectJob < ApplicationJob
end
end
def logger
Rails.logger
end
def copy_project_attributes
target_project = Project.copy_attributes(source_project)
cleanup_target_project_attributes(target_project)
cleanup_target_project_params
Projects::SetAttributesService
.new(user: user,
model: target_project,
contract_class: Projects::CopyContract,
contract_options: { copied_from: source_project })
.call(target_project_params)
end
def cleanup_target_project_params
if (parent_id = target_project_params["parent_id"]) && (parent = Project.find_by(id: parent_id))
target_project_params.delete("parent_id") unless user.allowed_to?(:add_subprojects, parent)
end
end
def cleanup_target_project_attributes(target_project)
if target_project.parent
target_project.parent = nil unless user.allowed_to?(:add_subprojects, target_project.parent)
end
def copy_project
::Projects::CopyService
.new(source: source_project, user: user)
.call(copy_project_params)
end
def copy_project_associations(target_project)
target_project.copy_associations(source_project, only: associations_to_copy)
errors = []
# Project was created
# But some objects might not have been copied due to validation failures
error_objects = project_errors(target_project)
error_objects.each do |error_object|
error_prefix = error_prefix_for(error_object)
def copy_project_params
params = { target_project_params: target_project_params }
params[:only] = associations_to_copy if associations_to_copy.present?
error_object.full_messages.flatten.each do |error|
errors << error_prefix + error
end
end
errors
end
def project_errors(project)
(project.compiled_errors.flatten + [project.errors]).flatten
params
end
def error_prefix_for(error_object)
base = error_object.instance_variable_get(:@base)
base.is_a?(Project) ? '' : "#{base.class.model_name.human} '#{base}': "
def logger
Rails.logger
end
def url_helpers

@ -1,163 +0,0 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
# Provides some convenience for copying an ActiveRecord model with associations.
# The actual copying methods need to be provided, though.
# Including this Module will include Redmine::SafeAttributes as well.
module CopyModel
module InstanceMethods
# Copies all attributes from +from_model+
# except those specified in self.class#not_to_copy.
# Does NOT save self.
def copy_attributes(from_model)
with_model(from_model) do |model|
# clear unique attributes
self.attributes = model.attributes.dup.except(*Array(self.class.not_to_copy).map(&:to_s))
return self
end
end
# Copies the instance's associations based on the +from_model+.
# The associations CAN be copied when the instance responds to
# something called 'copy_association_name'.
#
# For example: If we have a method called #copy_work_packages,
# the WorkPackages from the work_packages association can be copied.
#
# Accepts an +options+ argument to specify what to copy
#
# Examples:
# model.copy_associations(1) # => copies everything
# model.copy_associations(1, only: 'members') # => copies members only
# model.copy_associations(1, only: ['members', 'versions']) # => copies members and versions
def copy_associations(from_model, options = {})
to_be_copied = self.class.reflect_on_all_associations.map(&:name)
to_be_copied = Array(options[:only]) unless options[:only].nil?
to_be_copied = to_be_copied.map(&:to_s).sort do |a, b|
(copy_precedence.map(&:to_s).index(a) || -1) <=> (copy_precedence.map(&:to_s).index(b) || -1)
end.map(&:to_sym)
with_model(from_model) do |model|
self.class.transaction do
to_be_copied.each do |name|
if respond_to?(:"copy_#{name}") || private_methods.include?(:"copy_#{name}")
reload
begin
send(:"copy_#{name}", model, to_be_copied)
# Array(nil) => [], works around nil values of has_one associations
(Array(send(name)).map do |instance|
compiled_errors << instance.errors unless instance.valid?
end)
rescue => e
Rails.logger.error "Failed to copy association #{name}: #{e}"
errors.add(name, :could_not_be_copied)
end
end
end
self
end
end
end
# copies everything (associations and attributes) based on
# +from_model+ and saves the instance.
def copy(from_model, options = {})
save if copy_attributes(from_model) && copy_associations(from_model, options)
self
end
# resolves +model+ and returns it,
# or yields it if a block was passed
def with_model(model)
model = model.is_a?(self.class) ? model : self.class.find(model)
if model
if block_given?
yield model
else
model
end
end
end
def copy_precedence
self.class.copy_precedence
end
def compiled_errors
@compiled_errors ||= []
end
def compiled_errors=(errors)
@compiled_errors = errors
end
end
module ClassMethods
# Overwrite or set CLASS::NOT_TO_COPY to specify
# which attributes are not safe to copy.
def not_to_copy(should_not_be_copied = nil)
@not_to_copy ||= (should_not_be_copied || begin self::NOT_TO_COPY
rescue NameError
[]
end)
@not_to_copy
end
def copy_precedence(precedence = nil)
@copy_precedence ||= (precedence || begin self::COPY_PRECEDENCE
rescue NameError
[]
end)
@copy_precedence
end
# Copies +from_model+ and returns the new instance. This will not save
# the copy
def copy_attributes(from_model)
new.copy_attributes(from_model)
end
# Creates a new instance and
# copies everything (associations and attributes) based on
# +from_model+.
def copy(from_model, options = {})
new.copy(from_model, options)
end
end
def self.included(base)
base.send :extend, self::ClassMethods
base.send :include, self::InstanceMethods
end
def self.extended(base)
base.send :extend, self::ClassMethods
base.send :include, self::InstanceMethods
end
end

@ -89,7 +89,7 @@ describe CopyProjectJob, type: :model do
end
let(:params) { {name: 'Copy', identifier: 'copy', type_ids: [type.id], work_package_custom_field_ids: [custom_field.id]} }
let(:expected_error_message) { "#{WorkPackage.model_name.human} '#{work_package.type.name} #: #{work_package.subject}': #{custom_field.name} #{I18n.t('errors.messages.blank')}." }
let(:expected_error_message) { "#{WorkPackage.model_name.human} '#{work_package.type.name} ##{work_package.id}: #{work_package.subject}': #{custom_field.name} #{I18n.t('errors.messages.blank')}." }
before do
source_project.work_package_custom_fields << custom_field

Loading…
Cancel
Save