From 605f6b83558700139e104b462b6dd8779181946f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 6 Jul 2020 11:40:33 +0200 Subject: [PATCH] Refactor copy project into service --- app/contracts/projects/copy_contract.rb | 6 + app/controllers/copy_projects_controller.rb | 5 +- app/models/project.rb | 1 - app/models/projects/copy.rb | 386 ------------------ app/services/base_services/base_contracted.rb | 2 +- app/services/base_services/copy.rb | 89 ++++ .../copy/concerns/copy_attachments.rb | 24 ++ app/services/copy/dependency.rb | 96 +++++ .../copy/categories_dependent_service.rb | 43 ++ .../projects/copy/forums_dependent_service.rb | 67 +++ .../copy/members_dependent_service.rb | 55 +++ .../copy/queries_dependent_service.rb | 71 ++++ .../copy/versions_dependent_service.rb | 43 ++ .../projects/copy/wiki_dependent_service.rb | 102 +++++ .../copy/work_packages_dependent_service.rb | 157 +++++++ app/services/projects/copy_service.rb | 87 ++++ app/services/service_result.rb | 5 + app/workers/copy_project_job.rb | 71 +--- lib/copy_model.rb | 163 -------- spec/workers/copy_project_job_spec.rb | 2 +- 20 files changed, 865 insertions(+), 610 deletions(-) delete mode 100644 app/models/projects/copy.rb create mode 100644 app/services/base_services/copy.rb create mode 100644 app/services/copy/concerns/copy_attachments.rb create mode 100644 app/services/copy/dependency.rb create mode 100644 app/services/projects/copy/categories_dependent_service.rb create mode 100644 app/services/projects/copy/forums_dependent_service.rb create mode 100644 app/services/projects/copy/members_dependent_service.rb create mode 100644 app/services/projects/copy/queries_dependent_service.rb create mode 100644 app/services/projects/copy/versions_dependent_service.rb create mode 100644 app/services/projects/copy/wiki_dependent_service.rb create mode 100644 app/services/projects/copy/work_packages_dependent_service.rb create mode 100644 app/services/projects/copy_service.rb delete mode 100644 lib/copy_model.rb diff --git a/app/contracts/projects/copy_contract.rb b/app/contracts/projects/copy_contract.rb index b3ee26ac4b..a008e78c1b 100644 --- a/app/contracts/projects/copy_contract.rb +++ b/app/contracts/projects/copy_contract.rb @@ -28,6 +28,12 @@ module Projects class CopyContract < BaseContract + protected + + def validate_model? + false + end + private def validate_user_allowed_to_manage diff --git a/app/controllers/copy_projects_controller.rb b/app/controllers/copy_projects_controller.rb index 6eed17d6b8..cbcfd927b5 100644 --- a/app/controllers/copy_projects_controller.rb +++ b/app/controllers/copy_projects_controller.rb @@ -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) diff --git a/app/models/project.rb b/app/models/project.rb index c762f05136..510b5908d4 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -32,7 +32,6 @@ class Project < ApplicationRecord extend Pagination::Model extend FriendlyId - include Projects::Copy include Projects::Storage include Projects::Activity include ::Scopes::Scoped diff --git a/app/models/projects/copy.rb b/app/models/projects/copy.rb deleted file mode 100644 index d4be11cd9e..0000000000 --- a/app/models/projects/copy.rb +++ /dev/null @@ -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 diff --git a/app/services/base_services/base_contracted.rb b/app/services/base_services/base_contracted.rb index f11558c08a..fc9836ac80 100644 --- a/app/services/base_services/base_contracted.rb +++ b/app/services/base_services/base_contracted.rb @@ -47,7 +47,7 @@ module BaseServices end end - private + protected def perform(params) service_call = before_perform(params) diff --git a/app/services/base_services/copy.rb b/app/services/base_services/copy.rb new file mode 100644 index 0000000000..7ec9613434 --- /dev/null +++ b/app/services/base_services/copy.rb @@ -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 diff --git a/app/services/copy/concerns/copy_attachments.rb b/app/services/copy/concerns/copy_attachments.rb new file mode 100644 index 0000000000..e549b0fd4b --- /dev/null +++ b/app/services/copy/concerns/copy_attachments.rb @@ -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 \ No newline at end of file diff --git a/app/services/copy/dependency.rb b/app/services/copy/dependency.rb new file mode 100644 index 0000000000..121613df2f --- /dev/null +++ b/app/services/copy/dependency.rb @@ -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 diff --git a/app/services/projects/copy/categories_dependent_service.rb b/app/services/projects/copy/categories_dependent_service.rb new file mode 100644 index 0000000000..1888ba78ba --- /dev/null +++ b/app/services/projects/copy/categories_dependent_service.rb @@ -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 diff --git a/app/services/projects/copy/forums_dependent_service.rb b/app/services/projects/copy/forums_dependent_service.rb new file mode 100644 index 0000000000..aebe2e108a --- /dev/null +++ b/app/services/projects/copy/forums_dependent_service.rb @@ -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 diff --git a/app/services/projects/copy/members_dependent_service.rb b/app/services/projects/copy/members_dependent_service.rb new file mode 100644 index 0000000000..be7f0dc9e2 --- /dev/null +++ b/app/services/projects/copy/members_dependent_service.rb @@ -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 diff --git a/app/services/projects/copy/queries_dependent_service.rb b/app/services/projects/copy/queries_dependent_service.rb new file mode 100644 index 0000000000..cce7438699 --- /dev/null +++ b/app/services/projects/copy/queries_dependent_service.rb @@ -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 diff --git a/app/services/projects/copy/versions_dependent_service.rb b/app/services/projects/copy/versions_dependent_service.rb new file mode 100644 index 0000000000..9ff8667652 --- /dev/null +++ b/app/services/projects/copy/versions_dependent_service.rb @@ -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 diff --git a/app/services/projects/copy/wiki_dependent_service.rb b/app/services/projects/copy/wiki_dependent_service.rb new file mode 100644 index 0000000000..ca1c222cf6 --- /dev/null +++ b/app/services/projects/copy/wiki_dependent_service.rb @@ -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 diff --git a/app/services/projects/copy/work_packages_dependent_service.rb b/app/services/projects/copy/work_packages_dependent_service.rb new file mode 100644 index 0000000000..8dfae420a4 --- /dev/null +++ b/app/services/projects/copy/work_packages_dependent_service.rb @@ -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 diff --git a/app/services/projects/copy_service.rb b/app/services/projects/copy_service.rb new file mode 100644 index 0000000000..2105a96992 --- /dev/null +++ b/app/services/projects/copy_service.rb @@ -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 diff --git a/app/services/service_result.rb b/app/services/service_result.rb index b2b5887441..91b73f82d6 100644 --- a/app/services/service_result.rb +++ b/app/services/service_result.rb @@ -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 diff --git a/app/workers/copy_project_job.rb b/app/workers/copy_project_job.rb index f32340bbb7..48dad33526 100644 --- a/app/workers/copy_project_job.rb +++ b/app/workers/copy_project_job.rb @@ -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 diff --git a/lib/copy_model.rb b/lib/copy_model.rb deleted file mode 100644 index 942dff020f..0000000000 --- a/lib/copy_model.rb +++ /dev/null @@ -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 diff --git a/spec/workers/copy_project_job_spec.rb b/spec/workers/copy_project_job_spec.rb index 0319708904..937429b374 100644 --- a/spec/workers/copy_project_job_spec.rb +++ b/spec/workers/copy_project_job_spec.rb @@ -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