From a52d1fea6c242e092ce016fa0b4ac07a9b2848e3 Mon Sep 17 00:00:00 2001 From: Jens Kraemer Date: Thu, 24 Feb 2022 18:18:28 +0800 Subject: [PATCH 4/4] projects bulk delete --- app/controllers/projects_controller.rb | 25 ++++++-- app/jobs/destroy_projects_job.rb | 31 ++++++++++ app/views/context_menus/projects.html.erb | 6 ++ app/views/projects/bulk_destroy.html.erb | 27 +++++++++ config/locales/en.yml | 7 +++ config/routes.rb | 1 + test/functional/projects_controller_test.rb | 25 ++++++++ test/unit/jobs/destroy_projects_job_test.rb | 63 +++++++++++++++++++++ 8 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 app/jobs/destroy_projects_job.rb create mode 100644 app/views/projects/bulk_destroy.html.erb create mode 100644 test/unit/jobs/destroy_projects_job_test.rb diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index d8b2e8b9a9..695ca1dec2 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -23,16 +23,16 @@ class ProjectsController < ApplicationController menu_item :projects, :only => [:index, :new, :copy, :create] before_action :find_project, - :except => [:index, :autocomplete, :list, :new, :create, :copy] + :except => [:index, :autocomplete, :list, :new, :create, :copy, :bulk_destroy] before_action :authorize, :except => [:index, :autocomplete, :list, :new, :create, :copy, :archive, :unarchive, - :destroy] + :destroy, :bulk_destroy] before_action :authorize_global, :only => [:new, :create] - before_action :require_admin, :only => [:copy, :archive, :unarchive] + before_action :require_admin, :only => [:copy, :archive, :unarchive, :bulk_destroy] accept_atom_auth :index accept_api_auth :index, :show, :create, :update, :destroy, :archive, :unarchive, :close, :reopen - require_sudo_mode :destroy + require_sudo_mode :destroy, :bulk_destroy helper :custom_fields helper :issues @@ -315,6 +315,23 @@ class ProjectsController < ApplicationController @project = nil end + # Delete selected projects + def bulk_destroy + @projects = Project.where(id: params[:ids]). + where.not(status: Project::STATUS_SCHEDULED_FOR_DELETION).to_a + + if @projects.empty? + render_404 + return + end + + if params[:confirm] == I18n.t(:general_text_Yes) + DestroyProjectsJob.schedule @projects + flash[:notice] = l(:notice_successful_delete) + redirect_to admin_projects_path + end + end + private # Returns the ProjectEntry scope for index diff --git a/app/jobs/destroy_projects_job.rb b/app/jobs/destroy_projects_job.rb new file mode 100644 index 0000000000..dba1213dcd --- /dev/null +++ b/app/jobs/destroy_projects_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class DestroyProjectsJob < ApplicationJob + include Redmine::I18n + + def self.schedule(projects_to_delete, user: User.current) + # make the projects disappear immediately + projects_to_delete.each do |project| + project.self_and_descendants.update_all status: Project::STATUS_SCHEDULED_FOR_DELETION + end + perform_later(projects_to_delete.map(&:id), user.id, user.remote_ip) + end + + def perform(project_ids, user_id, remote_ip) + user = User.active.find_by_id(user_id) + unless user&.admin? + info "[DestroyProjectsJob] --- User check failed: User #{user_id} triggering projects destroy does not exist anymore or isn't admin/active." + return + end + + project_ids.each do |project_id| + DestroyProjectJob.perform_now(project_id, user_id, remote_ip) + end + end + + private + + def info(*msg) + Rails.logger.info(*msg) + end +end diff --git a/app/views/context_menus/projects.html.erb b/app/views/context_menus/projects.html.erb index 900d316c89..f0f1740507 100644 --- a/app/views/context_menus/projects.html.erb +++ b/app/views/context_menus/projects.html.erb @@ -12,5 +12,11 @@ <%= context_menu_link l(:button_delete), project_path(@project, back_url: @back), method: :delete, data: {confirm: l(:text_project_destroy_confirmation)}, class: 'icon icon-del' %> + <% else %> +
  • + <%= context_menu_link l(:button_delete), + {controller: 'projects', action: 'bulk_destroy', ids: @projects.map(&:id), back_url: @back}, + method: :delete, data: {confirm: l(:text_projects_bulk_destroy_confirmation)}, class: 'icon icon-del' %> +
  • <% end %> diff --git a/app/views/projects/bulk_destroy.html.erb b/app/views/projects/bulk_destroy.html.erb new file mode 100644 index 0000000000..43c6dcf51e --- /dev/null +++ b/app/views/projects/bulk_destroy.html.erb @@ -0,0 +1,27 @@ +<%= title l(:label_confirmation) %> + +<%= form_tag(bulk_destroy_projects_path(ids: @projects.map(&:id)), method: :delete) do %> +
    + +

    <%= simple_format l :text_projects_bulk_destroy_head %>

    + +<% @projects.each do |project| %> +

    Project: <%= project.to_s %> + <% if project.descendants.any? %> +
    + <%= l :text_subprojects_bulk_destroy, project.descendants.map(&:to_s).join(', ') %> + <% end %> +

    +<% end %> + +

    <%= l :text_projects_bulk_destroy_confirm, yes: l(:general_text_Yes) %>

    +

    <%= text_field_tag 'confirm' %>

    + +
    + +

    + <%= submit_tag l(:button_delete), class: 'btn-alert btn-small' %> + <%= link_to l(:button_cancel), admin_projects_path %> +

    +<% end %> + diff --git a/config/locales/en.yml b/config/locales/en.yml index 687f006992..12bd42693c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1212,7 +1212,14 @@ en: text_select_mail_notifications: Select actions for which email notifications should be sent. text_regexp_info: eg. ^[A-Z0-9]+$ text_project_destroy_confirmation: Are you sure you want to delete this project and related data? + text_projects_bulk_destroy_confirmation: Are you sure you want to delete the selected projects and related data? + text_projects_bulk_destroy_head: | + You are about to permanently delete the following projects, including possible subprojects and any related data. + Please review the information below and confirm that this is indeed what you want to do. + This action cannot be undone. + text_projects_bulk_destroy_confirm: To confirm, please enter "%{yes}" in the box below. text_subprojects_destroy_warning: "Its subproject(s): %{value} will be also deleted." + text_subprojects_bulk_destroy: "including its subproject(s): %{value}" text_workflow_edit: Select a role and a tracker to edit the workflow text_are_you_sure: Are you sure? text_journal_changed: "%{label} changed from %{old} to %{new}" diff --git a/config/routes.rb b/config/routes.rb index 9d6c541321..1e4b20d94f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -128,6 +128,7 @@ Rails.application.routes.draw do resources :projects do collection do get 'autocomplete' + delete 'bulk_destroy' end member do diff --git a/test/functional/projects_controller_test.rb b/test/functional/projects_controller_test.rb index 411496f0d9..07f590365d 100644 --- a/test/functional/projects_controller_test.rb +++ b/test/functional/projects_controller_test.rb @@ -1227,6 +1227,31 @@ class ProjectsControllerTest < Redmine::ControllerTest assert Project.find(1) end + def test_bulk_destroy_should_require_admin + @request.session[:user_id] = 2 # non-admin + delete :bulk_destroy, params: { ids: [1, 2], confirm: 'Yes' } + assert_response 403 + end + + def test_bulk_destroy_should_require_confirmation + @request.session[:user_id] = 1 # admin + assert_difference 'Project.count', 0 do + delete :bulk_destroy, params: { ids: [1, 2] } + end + assert Project.find(1) + assert Project.find(2) + assert_response 200 + end + + def test_bulk_destroy_should_delete_projects + @request.session[:user_id] = 1 # admin + assert_difference 'Project.count', -2 do + delete :bulk_destroy, params: { ids: [2, 6], confirm: 'Yes' } + end + assert_equal 0, Project.where(id: [2, 6]).count + assert_redirected_to '/admin/projects' + end + def test_archive @request.session[:user_id] = 1 # admin post(:archive, :params => {:id => 1}) diff --git a/test/unit/jobs/destroy_projects_job_test.rb b/test/unit/jobs/destroy_projects_job_test.rb new file mode 100644 index 0000000000..980f983e4f --- /dev/null +++ b/test/unit/jobs/destroy_projects_job_test.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006-2022 Jean-Philippe Lang +# +# 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. + +require File.expand_path('../../../test_helper', __FILE__) + +class DestroyProjectsJobTest < ActiveJob::TestCase + fixtures :users, :projects, :email_addresses + + setup do + @projects = Project.where(id: [1, 2]).to_a + @user = User.find_by_admin true + end + + test "schedule should mark projects and children for deletion" do + DestroyProjectsJob.schedule @projects, user: @user + @projects.each do |project| + project.reload + assert_equal Project::STATUS_SCHEDULED_FOR_DELETION, project.status + project.descendants.each do |child| + assert_equal Project::STATUS_SCHEDULED_FOR_DELETION, child.status + end + end + end + + test "schedule should enqueue job" do + assert_enqueued_with( + job: DestroyProjectsJob, + args: [[1, 2], @user.id, '127.0.0.1'] + ) do + @user.remote_ip = '127.0.0.1' + DestroyProjectsJob.schedule @projects, user: @user + end + end + + test "should destroy projects and send emails" do + assert_difference 'Project.count', -6 do + DestroyProjectsJob.perform_now @projects.map(&:id), @user.id, '127.0.0.1' + end + assert_enqueued_with( + job: ActionMailer::MailDeliveryJob, + args: ->(job_args){ + job_args[1] == 'security_notification' && + job_args[3].to_s.include?("mail_destroy_project_with_subprojects_successful") + } + ) + end +end -- 2.20.1