Patch #31076 » 0002-implements-background-issue-PDF-and-CSV-exports.patch
app/controllers/issues_controller.rb | ||
---|---|---|
65 | 65 |
render_feed(@issues, :title => "#{@project || Setting.app_title}: #{l(:label_issue_plural)}") |
66 | 66 |
} |
67 | 67 |
format.csv { |
68 |
@issues = @query.issues(:limit => Setting.issues_export_limit.to_i)
|
|
69 |
send_data(query_to_csv(@issues, @query, params[:csv]), :type => 'text/csv; header=present', :filename => 'issues.csv')
|
|
68 |
handle_issues_export
|
|
69 |
return
|
|
70 | 70 |
} |
71 | 71 |
format.pdf { |
72 |
@issues = @query.issues(:limit => Setting.issues_export_limit.to_i)
|
|
73 |
send_file_headers! :type => 'application/pdf', :filename => 'issues.pdf'
|
|
72 |
handle_issues_export
|
|
73 |
return
|
|
74 | 74 |
} |
75 | 75 |
end |
76 | 76 |
else |
... | ... | |
603 | 603 |
redirect_back_or_default issue_path(@issue) |
604 | 604 |
end |
605 | 605 |
end |
606 | ||
607 |
# exports with less than this number of issues are done inline (not handed |
|
608 |
# over to the configured AJ backend at all) |
|
609 |
PERFORM_NOW_LIMIT = 1000 |
|
610 | ||
611 |
# Issues CSV / PDF export via ActiveJob |
|
612 |
# See Redmine::Exports::BackgroundExport for how this works. |
|
613 |
def handle_issues_export |
|
614 |
limit = Setting.issues_export_limit.to_i |
|
615 |
issue_count = @query.issue_count |
|
616 |
export = Redmine::Export::BackgroundExport.new( |
|
617 |
@query, |
|
618 |
worker_class: Redmine::Export::IssueExport, |
|
619 |
perform_now: issue_count <= PERFORM_NOW_LIMIT, |
|
620 |
params: { |
|
621 |
format: params[:format], |
|
622 |
options: { |
|
623 |
project_id: @project.try(:identifier), |
|
624 |
limit: limit, |
|
625 |
csv: { encoding: params[:encoding] }.merge(params[:csv]||{}) |
|
626 |
} |
|
627 |
} |
|
628 |
) |
|
629 | ||
630 |
if export.wait |
|
631 |
if attachment = export.result |
|
632 |
send_file attachment.diskfile, |
|
633 |
filename: Redmine::Export::IssueExport.filename(params[:format]), |
|
634 |
type: attachment.content_type, |
|
635 |
disposition: 'attachment' |
|
636 |
return |
|
637 |
else |
|
638 |
flash[:error] = l(:error_issue_export_failed) |
|
639 |
end |
|
640 |
else |
|
641 |
flash[:notice] = l(:notice_issue_export_scheduled) |
|
642 |
end |
|
643 | ||
644 |
params[:format] = nil |
|
645 |
parameters = params.permit!.to_h.except(:action, :controller, :csv, :project_id, :format, :encoding) |
|
646 | ||
647 |
if @project |
|
648 |
redirect_to project_issues_url(@project, parameters) |
|
649 |
else |
|
650 |
redirect_to issues_url(parameters) |
|
651 |
end |
|
652 |
end |
|
653 | ||
654 | ||
606 | 655 |
end |
app/jobs/export_job.rb | ||
---|---|---|
1 |
# frozen_string_literal: true |
|
2 | ||
3 |
# Background job for creating CSV/PDF exports. |
|
4 |
# |
|
5 |
class ExportJob < ActiveJob::Base |
|
6 |
include Redmine::I18n |
|
7 | ||
8 |
def perform(filename, query_hash, user_id, created_at, |
|
9 |
worker_class_name, params, notify_if_finished_after = nil) |
|
10 | ||
11 |
User.current = User.anonymous |
|
12 |
if user = User.active.find_by_id(user_id) |
|
13 |
User.current = user |
|
14 |
set_language_if_valid user.language || Setting.default_language |
|
15 |
end |
|
16 | ||
17 |
@notify_if_finished_after = notify_if_finished_after.to_i |
|
18 | ||
19 |
worker_class = worker_class_name.constantize |
|
20 |
export = worker_class.new(query_hash, params) |
|
21 | ||
22 | ||
23 |
if data = export.generate_data |
|
24 |
file = DataFile.new data, filename, export.content_type |
|
25 |
@attachment = Attachment.new(file: file, author: User.current) |
|
26 |
@attachment.skip_filesize_validation! |
|
27 |
unless @attachment.save |
|
28 |
@error = @attachment.errors.full_messages.join("\n").presence |
|
29 |
end |
|
30 |
else |
|
31 |
@error = I18n.t(:error_export_failed) |
|
32 |
end |
|
33 | ||
34 |
if notify? |
|
35 |
if @attachment && @error.blank? |
|
36 |
# the controller has given up waiting now, so there is no need for the |
|
37 |
# unique filename anymore. Let's change it to something nice. This |
|
38 |
# allows us to have URLs like attachments/download/100/issues.pdf |
|
39 |
# instead of attachments/download/100/<uuid>.pdf |
|
40 |
@attachment.update_column :filename, export.filename |
|
41 |
end |
|
42 |
Mailer.export_notification( |
|
43 |
User.current, Time.at(created_at), @attachment, @error |
|
44 |
).deliver |
|
45 |
end |
|
46 | ||
47 |
end |
|
48 | ||
49 | ||
50 |
private |
|
51 | ||
52 |
def notify? |
|
53 |
if @notify_if_finished_after > 0 |
|
54 |
# 1 second margin to avoid lost exports. This may lead to |
|
55 |
# a notification being sent out even if the file was delivered |
|
56 |
# directly but this is the lesser evil. |
|
57 |
Time.at(@notify_if_finished_after) - 1.second < Time.now |
|
58 |
else |
|
59 |
false |
|
60 |
end |
|
61 |
end |
|
62 | ||
63 |
# StringIO plus filename and content_type. |
|
64 |
# The result of #generate_data should be of this kind |
|
65 |
class DataFile < StringIO |
|
66 |
attr_reader :original_filename, :content_type |
|
67 |
def initialize(string, filename, content_type) |
|
68 |
super string |
|
69 |
@original_filename = filename |
|
70 |
@content_type = content_type |
|
71 |
end |
|
72 |
end |
|
73 | ||
74 | ||
75 |
end |
app/models/attachment.rb | ||
---|---|---|
70 | 70 |
end |
71 | 71 | |
72 | 72 |
def validate_max_file_size |
73 |
return if @skip_filesize_validation |
|
73 | 74 |
if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes |
74 | 75 |
errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes)) |
75 | 76 |
end |
76 | 77 |
end |
77 | 78 | |
79 |
def skip_filesize_validation! |
|
80 |
@skip_filesize_validation = true |
|
81 |
end |
|
82 | ||
78 | 83 |
def validate_file_extension |
79 | 84 |
if @temp_file |
80 | 85 |
extension = File.extname(filename) |
app/models/mailer.rb | ||
---|---|---|
602 | 602 |
end |
603 | 603 |
end |
604 | 604 | |
605 |
def export_notification(user, date, attachment, error) |
|
606 |
set_language_if_valid(user.language) |
|
607 |
@date = date |
|
608 |
@error = error |
|
609 |
if attachment |
|
610 |
@filename = attachment.filename |
|
611 |
@url = download_named_attachment_url(attachment, @filename) |
|
612 |
else |
|
613 |
@url = issues_url |
|
614 |
end |
|
615 |
redmine_headers 'Sender' => user.login, 'Url' => @url |
|
616 | ||
617 |
mail to: user, |
|
618 |
subject: l(error.blank? ? |
|
619 |
:mail_subject_export_finished : |
|
620 |
:mail_subject_export_failed) |
|
621 |
end |
|
622 | ||
605 | 623 |
# Activates/desactivates email deliveries during +block+ |
606 | 624 |
def self.with_deliveries(enabled = true, &block) |
607 | 625 |
was_enabled = ActionMailer::Base.perform_deliveries |
app/views/mailer/export_notification.html.erb | ||
---|---|---|
1 |
<% if @error.present? %> |
|
2 |
<p><%= l :mail_body_export_failed, date: format_time(@date) %></p> |
|
3 |
<p><%= @error %></p> |
|
4 |
<% else %> |
|
5 |
<p><%= l :mail_body_export_finished, date: format_time(@date) %></p> |
|
6 |
<p><%= link_to @filename, @url %></p> |
|
7 |
<% end %> |
|
8 |
app/views/mailer/export_notification.text.erb | ||
---|---|---|
1 |
<% if @error.present? %> |
|
2 |
<%= l :mail_body_export_failed, date: format_time(@date) %> |
|
3 |
<%= @error %> |
|
4 |
<% else %> |
|
5 |
<%= l :mail_body_export_finished, date: format_time(@date) %> |
|
6 | ||
7 |
<%= @url %> |
|
8 |
<% end %> |
config/locales/en.yml | ||
---|---|---|
187 | 187 |
notice_new_password_must_be_different: The new password must be different from the current password |
188 | 188 |
notice_import_finished: "%{count} items have been imported" |
189 | 189 |
notice_import_finished_with_errors: "%{count} out of %{total} items could not be imported" |
190 |
notice_issue_export_scheduled: "Exporting your issues takes longer than usual andwill be continued in the background. You will be notified once the process is completed." |
|
190 | 191 | |
191 | 192 |
error_can_t_load_default_data: "Default configuration could not be loaded: %{value}" |
192 | 193 |
error_scm_not_found: "The entry or revision was not found in the repository." |
... | ... | |
225 | 226 |
error_can_not_delete_auth_source: "This authentication mode is in use and cannot be deleted." |
226 | 227 |
error_spent_on_future_date: "Cannot log time on a future date" |
227 | 228 |
error_not_allowed_to_log_time_for_other_users: "You are not allowed to log time for other users" |
229 |
error_issue_export_failed: The issue export failed. |
|
228 | 230 | |
229 | 231 |
mail_subject_lost_password: "Your %{value} password" |
230 | 232 |
mail_body_lost_password: 'To change your password, click on the following link:' |
... | ... | |
250 | 252 |
mail_body_security_notification_notify_disabled: "Email address %{value} no longer receives notifications." |
251 | 253 |
mail_body_settings_updated: "The following settings were changed:" |
252 | 254 |
mail_body_password_updated: "Your password has been changed." |
255 |
mail_subject_export_failed: Export failed |
|
256 |
mail_body_export_failed: The export that was started by you on %{date} has failed. |
|
257 |
mail_subject_export_finished: Export finished |
|
258 |
mail_body_export_finished: "The export that was started by you on %{date} is available for download now:" |
|
253 | 259 | |
254 | 260 |
field_name: Name |
255 | 261 |
field_description: Description |
lib/redmine/export/background_export.rb | ||
---|---|---|
1 |
# frozen_string_literal: true |
|
2 | ||
3 |
require 'timeout' |
|
4 |
require 'securerandom' |
|
5 | ||
6 |
module Redmine |
|
7 |
module Export |
|
8 | ||
9 |
# This class models an export that may or may not take too long to wait for |
|
10 |
# in a web request. |
|
11 |
# |
|
12 |
# The initializer creates an export that will result in an attachment with |
|
13 |
# a random filename. Use the wait instance methods to wait at most the |
|
14 |
# specified number of seconds for completion of the job. If this returns |
|
15 |
# true, the generated file is available as an Attachment object through |
|
16 |
# #result. Otherwise, the export is taking longer and the user will be |
|
17 |
# notified by email. |
|
18 |
# |
|
19 |
# This makes most sense with a background job runner like DelayedJob |
|
20 |
# configured for ActiveJob. Without this, the job will be run immediately |
|
21 |
# and everything will be like before - #initialize (which launches the |
|
22 |
# background job) will block until the export is done, and wait will return |
|
23 |
# immediately true since the attachments exists when calling code gets |
|
24 |
# around to call it. |
|
25 |
# |
|
26 |
# Jobs can be forced to run inline via the perform_now: argument. This is |
|
27 |
# used in IssuesController to always run small exports immediately instead |
|
28 |
# of putting them in a queue where they potentially have to wait |
|
29 |
# unnecessarily long for large exports to complete. |
|
30 |
class BackgroundExport |
|
31 | ||
32 |
def initialize(query, wait_for: 30.seconds, |
|
33 |
user: User.current, |
|
34 |
perform_now: false, |
|
35 |
worker_class: Redmine::Export::IssueExport, |
|
36 |
params: {}) |
|
37 | ||
38 |
@wait_for = wait_for |
|
39 |
@filename = SecureRandom.uuid |
|
40 |
@user = user |
|
41 |
ExportJob.send( |
|
42 |
perform_now ? :perform_now : :perform_later, |
|
43 |
@filename, |
|
44 |
serialize_query(query), user.id, Time.now.to_i, |
|
45 |
worker_class.name, params, |
|
46 |
wait_for.from_now.to_i |
|
47 |
) |
|
48 |
end |
|
49 | ||
50 |
def serialize_query(query) |
|
51 |
query.as_params.tap do |params| |
|
52 |
params[:c].map!(&:to_s) if params[:c] |
|
53 |
end |
|
54 |
end |
|
55 | ||
56 |
# returns true if the export finished in time |
|
57 |
def wait |
|
58 |
Timeout.timeout(@wait_for) { |
|
59 |
sleep 1 while not finished? |
|
60 |
true |
|
61 |
} |
|
62 |
rescue Timeout::Error |
|
63 |
false |
|
64 |
end |
|
65 | ||
66 |
def result |
|
67 |
scope.first |
|
68 |
end |
|
69 | ||
70 |
private |
|
71 | ||
72 |
def finished? |
|
73 |
Attachment.uncached { scope.any? } |
|
74 |
end |
|
75 | ||
76 |
def scope |
|
77 |
Attachment.where(author: @user, filename: @filename) |
|
78 |
end |
|
79 | ||
80 |
end |
|
81 |
end |
|
82 |
end |
lib/redmine/export/issue_export.rb | ||
---|---|---|
1 |
# frozen_string_literal: true |
|
2 | ||
3 |
module Redmine |
|
4 |
module Export |
|
5 |
class IssueExport |
|
6 |
include IssuesHelper |
|
7 |
include QueriesHelper |
|
8 |
include Redmine::Export::PDF::IssuesPdfHelper |
|
9 | ||
10 |
def initialize(query_hash, params) |
|
11 |
@format = params[:format] |
|
12 |
unless %w(csv pdf).include? @format |
|
13 |
raise "Cannot handle export format: #{params[:format]}" |
|
14 |
end |
|
15 | ||
16 |
if options = params[:options] |
|
17 |
@project_id = options[:project_id] |
|
18 |
@limit = options[:limit] |
|
19 |
@csv_options = options[:csv] |
|
20 |
end |
|
21 | ||
22 |
@project = Project.find @project_id if @project_id |
|
23 | ||
24 |
if id = query_hash[:query_id] |
|
25 |
@query = IssueQuery.find id |
|
26 |
else |
|
27 |
@query = IssueQuery.new name: "_" |
|
28 |
@query.build_from_params(query_hash) |
|
29 |
end |
|
30 |
@query.project = @project |
|
31 |
end |
|
32 | ||
33 |
# expected by QueriesHelper#query_to_csv |
|
34 |
def params |
|
35 |
@csv_options || {} |
|
36 |
end |
|
37 | ||
38 |
def generate_data |
|
39 |
issues = @query.lazy_issues limit: @limit |
|
40 | ||
41 |
case @format |
|
42 |
when "csv" |
|
43 |
query_to_csv(issues, @query, @csv_options) |
|
44 |
when "pdf" |
|
45 |
issues_to_pdf(issues, @project, @query) |
|
46 |
end |
|
47 |
end |
|
48 | ||
49 |
def content_type |
|
50 |
self.class.content_type @format |
|
51 |
end |
|
52 | ||
53 |
def filename |
|
54 |
self.class.filename @format |
|
55 |
end |
|
56 | ||
57 | ||
58 |
# format potentially is params[:format] which may be something |
|
59 |
# unexpected, which is why we dont just use "issues#{format}". |
|
60 |
def self.filename(format) |
|
61 |
case format |
|
62 |
when "csv" |
|
63 |
"issues.csv" |
|
64 |
when "pdf" |
|
65 |
"issues.pdf" |
|
66 |
end |
|
67 |
end |
|
68 | ||
69 |
def self.content_type(format) |
|
70 |
case format |
|
71 |
when "csv" |
|
72 |
"text/csv; header=present" |
|
73 |
when "pdf" |
|
74 |
"application/pdf" |
|
75 |
end |
|
76 |
end |
|
77 | ||
78 | ||
79 |
end |
|
80 |
end |
|
81 |
end |
test/unit/export_job_test.rb | ||
---|---|---|
1 |
require_relative '../test_helper' |
|
2 | ||
3 |
class DummyWorker |
|
4 |
def initialize(hsh, params) |
|
5 |
@data = hsh[:data] + params[:more_data] |
|
6 |
end |
|
7 | ||
8 |
def generate_data |
|
9 |
@data |
|
10 |
end |
|
11 |
def content_type |
|
12 |
'text/plain' |
|
13 |
end |
|
14 |
def filename |
|
15 |
'dummy.txt' |
|
16 |
end |
|
17 |
end |
|
18 | ||
19 |
class ExportJobTest < ActiveJob::TestCase |
|
20 |
fixtures :users |
|
21 | ||
22 |
setup do |
|
23 |
set_tmp_attachments_directory |
|
24 |
end |
|
25 | ||
26 |
test 'should call worker class and generate attachment' do |
|
27 |
assert_difference 'Attachment.count' do |
|
28 |
ExportJob.perform_now( |
|
29 |
'random-filename', {data: 'some test'}, 1, Time.now.to_i, |
|
30 |
'DummyWorker', {more_data: ' data'} |
|
31 |
) |
|
32 |
end |
|
33 |
assert a = Attachment.find_by_filename('random-filename') |
|
34 |
assert_equal 1, a.author_id |
|
35 |
assert_equal 'text/plain', a.content_type |
|
36 |
assert_equal "some test data", IO.read(a.diskfile) |
|
37 |
end |
|
38 |
end |
test/unit/lib/redmine/export/background_export_test.rb | ||
---|---|---|
1 |
require_relative '../../../../test_helper' |
|
2 | ||
3 |
class BackgroundExportTest < ActiveJob::TestCase |
|
4 |
fixtures :projects, :enabled_modules, :users, :members, |
|
5 |
:member_roles, :roles, :trackers, :issue_statuses, |
|
6 |
:issue_categories, :enumerations, :issues, |
|
7 |
:watchers, :custom_fields, :custom_values, :versions, |
|
8 |
:queries, |
|
9 |
:projects_trackers, |
|
10 |
:custom_fields_trackers, |
|
11 |
:workflows |
|
12 | ||
13 |
setup do |
|
14 |
@query = IssueQuery.new name: '_' |
|
15 |
@project = Project.find 1 |
|
16 |
User.current = @user = User.find 1 |
|
17 |
end |
|
18 | ||
19 |
test 'should enqueue export job' do |
|
20 |
assert_enqueued_with(job: ExportJob) do |
|
21 |
Redmine::Export::BackgroundExport.new @query, |
|
22 |
params: {format: 'csv', options:{limit: 10000}} |
|
23 |
end |
|
24 |
end |
|
25 | ||
26 |
test 'wait should wait and return false if not finished' do |
|
27 |
e = Redmine::Export::BackgroundExport.new @query, |
|
28 |
wait_for: 1.second, |
|
29 |
params: {format: 'csv', options:{limit: 10000}} |
|
30 |
t = Time.now |
|
31 |
refute e.wait |
|
32 |
assert Time.now - t > 1.second |
|
33 |
end |
|
34 | ||
35 |
test 'wait should return true if finished' do |
|
36 |
e = Redmine::Export::BackgroundExport.new @query, |
|
37 |
params: {format: 'csv', options:{limit: 10000}} |
|
38 |
class << e |
|
39 |
def finished?; true end |
|
40 |
end |
|
41 | ||
42 |
Timeout.timeout(1) { assert e.wait } |
|
43 |
end |
|
44 | ||
45 |
end |
test/unit/lib/redmine/export/issue_export_test.rb | ||
---|---|---|
1 |
require_relative '../../../../test_helper' |
|
2 | ||
3 |
class IssueExportTest < ActiveSupport::TestCase |
|
4 |
fixtures :projects, :enabled_modules, :users, :members, |
|
5 |
:member_roles, :roles, :trackers, :issue_statuses, |
|
6 |
:issue_categories, :enumerations, :issues, |
|
7 |
:watchers, :custom_fields, :custom_values, :versions, |
|
8 |
:queries, |
|
9 |
:projects_trackers, |
|
10 |
:custom_fields_trackers, |
|
11 |
:workflows |
|
12 | ||
13 |
setup do |
|
14 |
@query = IssueQuery.new name: '_' |
|
15 |
@project = Project.find 1 |
|
16 |
User.current = @user = User.find 1 |
|
17 |
end |
|
18 | ||
19 |
test 'should generate issues pdf' do |
|
20 |
export = Redmine::Export::IssueExport.new @query.as_params, |
|
21 |
format: 'pdf', |
|
22 |
options: { limit: 10000 } |
|
23 |
assert data = export.generate_data |
|
24 |
assert data.length > 0 |
|
25 |
assert_equal 'issues.pdf', export.filename |
|
26 |
end |
|
27 | ||
28 |
test 'should generate issues csv with new query' do |
|
29 |
export = Redmine::Export::IssueExport.new @query.as_params, |
|
30 |
format: 'csv', |
|
31 |
options: { limit: 10000 } |
|
32 |
assert data = export.generate_data |
|
33 |
assert data.length > 0 |
|
34 |
assert_equal 'issues.csv', export.filename |
|
35 | ||
36 |
assert s = data.lines |
|
37 |
s.shift # headers |
|
38 |
assert_equal Issue.visible.open.count, s.size |
|
39 |
end |
|
40 | ||
41 |
test 'should honor filter and project' do |
|
42 |
@query.add_filter("issue_id", '><', ['2','3']) |
|
43 | ||
44 |
export = Redmine::Export::IssueExport.new @query.as_params, |
|
45 |
format: 'csv', |
|
46 |
options: { limit: 10000, project_id: 1 } |
|
47 |
assert data = export.generate_data |
|
48 |
assert s = data.lines |
|
49 |
s.shift # headers |
|
50 |
assert_equal (@project.issues.visible.open.count - 2), s.size |
|
51 |
end |
|
52 | ||
53 |
test 'should honor limit' do |
|
54 |
export = Redmine::Export::IssueExport.new @query.as_params, |
|
55 |
format: 'csv', |
|
56 |
options: { limit: 2 } |
|
57 |
assert data = export.generate_data |
|
58 |
assert s = data.lines |
|
59 |
assert_equal 3, s.size |
|
60 |
end |
|
61 | ||
62 |
test 'should honor sorting' do |
|
63 |
@query.sort_criteria = [['id', 'desc']] |
|
64 |
export = Redmine::Export::IssueExport.new @query.as_params, format: 'csv' |
|
65 |
assert data = export.generate_data |
|
66 |
assert s = data.lines |
|
67 |
s.shift # headers |
|
68 |
ids = s.map{|l| l.split(',').first.to_i} |
|
69 |
assert_equal ids, ids.sort{|a, b| b <=> a} |
|
70 | ||
71 | ||
72 |
@query.sort_criteria = [['id', 'asc']] |
|
73 |
export = Redmine::Export::IssueExport.new @query.as_params, format: 'csv' |
|
74 |
assert data = export.generate_data |
|
75 |
assert s = data.lines |
|
76 |
s.shift # headers |
|
77 |
assert s.many? |
|
78 |
ids = s.map{|l| l.split(',').first.to_i} |
|
79 |
assert_equal ids, ids.sort{|a, b| a <=> b} |
|
80 |
end |
|
81 | ||
82 |
end |
|
83 |
- « Previous
- 1
- 2
- Next »