Project

General

Profile

Feature #33422 » 0003-background-job-for-project-deletion.patch

Jens Krämer, 2022-03-31 12:49

View differences:

app/controllers/projects_controller.rb
300 300

  
301 301
    @project_to_destroy = @project
302 302
    if api_request? || params[:confirm] == @project_to_destroy.identifier
303
      @project_to_destroy.destroy
303
      DestroyProjectJob.schedule(@project_to_destroy)
304
      flash[:notice] = l(:notice_successful_delete)
304 305
      respond_to do |format|
305 306
        format.html do
306 307
          redirect_to(
app/helpers/admin_helper.rb
22 22
    options_for_select([[l(:label_all), ''],
23 23
                        [l(:project_status_active), '1'],
24 24
                        [l(:project_status_closed), '5'],
25
                        [l(:project_status_archived), '9']], selected.to_s)
25
                        [l(:project_status_archived), '9'],
26
                        [l(:project_status_scheduled_for_deletion), '10']], selected.to_s)
26 27
  end
27 28

  
28 29
  def plugin_data_for_updates(plugins)
app/jobs/application_job.rb
1
# frozen_string_literal: true
2

  
3
class ApplicationJob < ActiveJob::Base
4
end
app/jobs/destroy_project_job.rb
1
# frozen_string_literal: true
2

  
3
class DestroyProjectJob < ApplicationJob
4
  include Redmine::I18n
5

  
6
  def self.schedule(project, user: User.current)
7
    # make the project (and any children) disappear immediately
8
    project.self_and_descendants.update_all status: Project::STATUS_SCHEDULED_FOR_DELETION
9
    perform_later project.id, user.id, user.remote_ip
10
  end
11

  
12
  def perform(project_id, user_id, remote_ip)
13
    user_current_was = User.current
14

  
15
    unless @user = User.active.find_by_id(user_id)
16
      info "User check failed: User #{user_id} triggering project destroy does not exist anymore or isn't active."
17
      return
18
    end
19
    @user.remote_ip = remote_ip
20
    User.current = @user
21
    set_language_if_valid @user.language || Setting.default_language
22

  
23
    unless @project = Project.find_by_id(project_id)
24
      info "Project check failed: Project has already been deleted."
25
      return
26
    end
27

  
28
    unless @project.deletable?
29
      info "Project check failed: User #{user_id} lacks permissions."
30
      return
31
    end
32

  
33
    message = if @project.descendants.any?
34
                :mail_destroy_project_with_subprojects_successful
35
              else
36
                :mail_destroy_project_successful
37
              end
38
    delete_project ? success(message) : failure
39
  ensure
40
    User.current = user_current_was
41
    info "End destroy project"
42
  end
43

  
44
  private
45

  
46
  def delete_project
47
    info "Starting with project deletion"
48
    return !!@project.destroy
49
  rescue
50
    info "Error while deleting project: #{$!}"
51
    false
52
  end
53

  
54
  def success(message)
55
    Mailer.deliver_security_notification(
56
      @user, @user,
57
      message: message,
58
      value: @project.name,
59
      url: {controller: 'admin', action: 'projects'},
60
      title: :label_project_plural
61
    )
62
  end
63

  
64
  def failure
65
    Mailer.deliver_security_notification(
66
      @user, @user,
67
      message: :mail_destroy_project_failed,
68
      value: @project.name,
69
      url: {controller: 'admin', action: 'projects'},
70
      title: :label_project_plural
71
    )
72
  end
73

  
74
  def info(msg)
75
    Rails.logger.info("[DestroyProjectJob] --- #{msg}")
76
  end
77
end
app/models/project.rb
25 25
  STATUS_ACTIVE     = 1
26 26
  STATUS_CLOSED     = 5
27 27
  STATUS_ARCHIVED   = 9
28
  STATUS_SCHEDULED_FOR_DELETION = 10
28 29

  
29 30
  # Maximum length for project identifiers
30 31
  IDENTIFIER_MAX_LENGTH = 100
......
182 183
    perm = Redmine::AccessControl.permission(permission)
183 184
    base_statement =
184 185
      if perm && perm.read?
185
        "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}"
186
        "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND #{Project.table_name}.status <> #{Project::STATUS_SCHEDULED_FOR_DELETION}"
186 187
      else
187 188
        "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}"
188 189
      end
......
399 400
    self.status == STATUS_ARCHIVED
400 401
  end
401 402

  
403
  def scheduled_for_deletion?
404
    self.status == STATUS_SCHEDULED_FOR_DELETION
405
  end
406

  
402 407
  # Archives the project and its descendants
403 408
  def archive
404 409
    # Check that there is no issue of a non descendant project that is assigned
app/models/project_query.rb
111 111
    values = super
112 112
    if self.admin_projects
113 113
      values << [l(:project_status_archived), Project::STATUS_ARCHIVED.to_s]
114
      values << [l(:project_status_scheduled_for_deletion), Project::STATUS_SCHEDULED_FOR_DELETION.to_s]
114 115
    end
115 116
    values
116 117
  end
app/views/context_menus/projects.html.erb
1 1
<ul>
2
  <% if @project %>
2
  <% if @project && !@project.scheduled_for_deletion? %>
3 3
    <% if @project.archived? %>
4 4
      <li><%= context_menu_link l(:button_unarchive), unarchive_project_path(@project), method: :post, class: 'icon icon-unlock' %></li>
5 5
    <% else %>
app/views/projects/_list.html.erb
40 40
    </tr>
41 41
  <% end %>
42 42
  <tr id="project-<%= entry.id %>" class="<%= cycle('odd', 'even') %> hascontextmenu <%= entry.css_classes %> <%= level > 0 ? "idnt idnt-#{level}" : nil %>">
43
    <% if @admin_list %>
43
    <% if @admin_list && !entry.scheduled_for_deletion? %>
44 44
      <td class="checkbox hide-when-print"><%= check_box_tag("ids[]", entry.id, false, :id => nil) %></td>
45 45
    <% end %>
46 46
    <% @query.inline_columns.each do |column| %>
47 47
    <%= content_tag('td', column_content(column, entry), :class => column.css_classes) %>
48 48
    <% end %>
49
    <% if @admin_list %>
49
    <% if @admin_list && !entry.scheduled_for_deletion? %>
50 50
      <td class="buttons"><%= link_to_context_menu %></td>
51 51
    <% end %>
52 52
  </tr>
config/locales/en.yml
270 270
  mail_body_security_notification_notify_disabled: "Email address %{value} no longer receives notifications."
271 271
  mail_body_settings_updated: "The following settings were changed:"
272 272
  mail_body_password_updated: "Your password has been changed."
273
  mail_destroy_project_failed: Project %{value} could not be deleted.
274
  mail_destroy_project_successful: Project %{value} was deleted successfully.
275
  mail_destroy_project_with_subprojects_successful: Project %{value} and its subprojects were deleted successfully.
276

  
273 277

  
274 278
  field_name: Name
275 279
  field_description: Description
......
1197 1201
  project_status_active: active
1198 1202
  project_status_closed: closed
1199 1203
  project_status_archived: archived
1204
  project_status_scheduled_for_deletion: scheduled for deletion
1200 1205

  
1201 1206
  version_status_open: open
1202 1207
  version_status_locked: locked
test/functional/projects_controller_test.rb
33 33
  def setup
34 34
    @request.session[:user_id] = nil
35 35
    Setting.default_language = 'en'
36
    ActiveJob::Base.queue_adapter = :inline
36 37
  end
37 38

  
38 39
  def test_index_by_anonymous_should_not_show_private_projects
......
1118 1119
                            'eCookbook Subproject 2'].join(', ')
1119 1120
  end
1120 1121

  
1122
  def test_destroy_should_mark_project_and_subprojects_for_deletion
1123
    queue_adapter_was = ActiveJob::Base.queue_adapter
1124
    ActiveJob::Base.queue_adapter = :test
1125
    set_tmp_attachments_directory
1126
    @request.session[:user_id] = 1 # admin
1127

  
1128
    assert_no_difference 'Project.count' do
1129
      delete(:destroy, :params => {:id => 1, :confirm => 'ecookbook'})
1130
      assert_redirected_to '/admin/projects'
1131
    end
1132
    assert p = Project.find_by_id(1)
1133
    assert_equal Project::STATUS_SCHEDULED_FOR_DELETION, p.status
1134
    p.descendants.each do |child|
1135
      assert_equal Project::STATUS_SCHEDULED_FOR_DELETION, child.status
1136
    end
1137
  ensure
1138
    ActiveJob::Base.queue_adapter = queue_adapter_was
1139
  end
1140

  
1121 1141
  def test_destroy_with_confirmation_should_destroy_the_project_and_subprojects
1122 1142
    set_tmp_attachments_directory
1123 1143
    @request.session[:user_id] = 1 # admin
test/integration/api_test/projects_test.rb
20 20
require File.expand_path('../../../test_helper', __FILE__)
21 21

  
22 22
class Redmine::ApiTest::ProjectsTest < Redmine::ApiTest::Base
23
  include ActiveJob::TestHelper
23 24
  fixtures :projects, :versions, :users, :roles, :members, :member_roles, :issues, :journals, :journal_details,
24 25
           :trackers, :projects_trackers, :issue_statuses, :enabled_modules, :enumerations, :boards, :messages,
25 26
           :attachments, :custom_fields, :custom_values, :custom_fields_projects, :time_entries, :issue_categories,
......
361 362
    assert_select 'errors error', :text => "Name cannot be blank"
362 363
  end
363 364

  
364
  test "DELETE /projects/:id.xml should delete the project" do
365
    assert_difference('Project.count', -1) do
365
  test "DELETE /projects/:id.xml should schedule deletion of the project" do
366
    assert_no_difference('Project.count') do
366 367
      delete '/projects/2.xml', :headers => credentials('admin')
367 368
    end
369
    assert_enqueued_with(job: DestroyProjectJob,
370
                         args: ->(job_args){ job_args[0] == 2})
368 371
    assert_response :no_content
369 372
    assert_equal '', @response.body
370
    assert_nil Project.find_by_id(2)
373
    assert p = Project.find_by_id(2)
374
    assert_equal Project::STATUS_SCHEDULED_FOR_DELETION, p.status
371 375
  end
372 376

  
373 377
  test "PUT /projects/:id/archive.xml should archive project" do
test/unit/jobs/destroy_project_job_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-2022  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require File.expand_path('../../../test_helper', __FILE__)
21

  
22
class DestroyProjectJobTest < ActiveJob::TestCase
23
  fixtures :users, :projects, :email_addresses
24

  
25
  setup do
26
    @project = Project.find 1
27
    @user = User.find_by_admin true
28
  end
29

  
30
  test "schedule should mark project and children for deletion" do
31
    assert @project.descendants.any?
32
    DestroyProjectJob.schedule @project, user: @user
33
    @project.reload
34
    assert_equal Project::STATUS_SCHEDULED_FOR_DELETION, @project.status
35
    @project.descendants.each do |child|
36
      assert_equal Project::STATUS_SCHEDULED_FOR_DELETION, child.status
37
    end
38
  end
39

  
40
  test "schedule should enqueue job" do
41
    DestroyProjectJob.schedule @project, user: @user
42
    assert_enqueued_with(
43
      job: DestroyProjectJob,
44
      args: ->(job_args){
45
        job_args[0] == @project.id &&
46
        job_args[1] == @user.id
47
      }
48
    )
49
  end
50

  
51
  test "should destroy project and send email" do
52
    assert_difference 'Project.count', -5 do
53
      DestroyProjectJob.perform_now @project.id, @user.id, '127.0.0.1'
54
    end
55
    assert_enqueued_with(
56
      job: ActionMailer::MailDeliveryJob,
57
      args: ->(job_args){
58
        job_args[1] == 'security_notification' &&
59
        job_args[3].to_s.include?("mail_destroy_project_with_subprojects_successful")
60
      }
61
    )
62
  end
63
end
(3-3/6)