Commit 164f3c38 authored by James Edwards-Jones's avatar James Edwards-Jones

Issues CSV attachment can be truncated by target size

parent 4918aabb
......@@ -49,11 +49,12 @@ module Emails
mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id))
end
def issues_csv_email(user, project, content, issues_count)
def issues_csv_email(user, project, csv_data, issues_count, truncated = false)
@project = project
@issues_count = issues_count
@truncated = truncated
attachments['issues.csv'] = { content: content, mime_type: 'text/csv' }
attachments['issues.csv'] = { content: csv_data, mime_type: 'text/csv' }
mail(to: user.notification_email, subject: subject("Issues exported as CSV"))
end
......
module Issues
class ExportCsvService
# Target attachment size before base64 encoding
TARGET_FILESIZE = 15000000
def initialize(issues_relation)
@issues = issues_relation
@labels = @issues.labels_hash
end
def csv_data
csv_builder.render
csv_builder.render(TARGET_FILESIZE)
end
def email(user, project)
Notify.issues_csv_email(user, project, csv_data, @issues.count).deliver_now
Notify.issues_csv_email(user, project, csv_data, @issues.count, csv_builder.truncated?).deliver_now
end
def csv_builder
@csv_builder ||=
CsvBuilder.new(@issues.includes(:author, :assignee), header_to_value_hash)
end
private
def csv_builder
@csv_builder ||= CsvBuilder.new(@issues.includes(:author, :assignee),
'Issue ID' => 'iid',
'Title' => 'title',
'State' => 'state',
'Description' => 'description',
'Author' => 'author_name',
'Assignee' => 'assignee_name',
'Confidential' => 'confidential',
'Due Date' => -> (issue) { issue.due_date&.to_s(:csv) },
'Created At (UTC)' => -> (issue) { issue.created_at&.to_s(:csv) },
'Updated At (UTC)' => -> (issue) { issue.updated_at&.to_s(:csv) },
'Milestone' => -> (issue) { issue.milestone&.title },
'Labels' => -> (issue) { @labels[issue.id].sort.join(',').presence }
)
def header_to_value_hash
{
'Issue ID' => 'iid',
'Title' => 'title',
'State' => 'state',
'Description' => 'description',
'Author' => 'author_name',
'Assignee' => 'assignee_name',
'Confidential' => 'confidential',
'Due Date' => -> (issue) { issue.due_date&.to_s(:csv) },
'Created At (UTC)' => -> (issue) { issue.created_at&.to_s(:csv) },
'Updated At (UTC)' => -> (issue) { issue.updated_at&.to_s(:csv) },
'Milestone' => -> (issue) { issue.milestone&.title },
'Labels' => -> (issue) { @labels[issue.id].sort.join(',').presence }
}
end
end
end
......@@ -59,6 +59,10 @@
%a{ href: project_url(@project), style: "color:#3777b0;text-decoration:none;" }
= @project.name
has been added to this email as an attachment.
-if @truncated
%br
%br
This attachment has been truncated due to exceeding the maximum attachment size. Consider re-exporting with a narrower selection of issues.
%tr.footer
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" }
%img{ alt: "GitLab", height: "33", src: image_url('mailers/ci_pipeline_notif_v1/gitlab-logo-full-horizontal.gif'), style: "display:block;margin:0 auto 1em;", width: "90" }/
......
Your .csv export of <%= @issues_count %> issues from project <%= @project.name %> ( <%= project_url(@project) %> ) has been added to this email as an attachment.
\ No newline at end of file
Your .csv export of <%= @issues_count %> issues from project <%= @project.name %> ( <%= project_url(@project) %> ) has been added to this email as an attachment.
<% if @truncated %>
This attachment has been truncated due to exceeding the maximum attachment size. Consider re-exporting with a narrower selection of issues.
<% end %>
\ No newline at end of file
......@@ -22,17 +22,27 @@ class CsvBuilder
def initialize(collection, header_to_value_hash)
@header_to_value_hash = header_to_value_hash
@collection = collection
@truncated = false
end
# Renders the csv to a string
def render
generate_csv_with_tempfile do |csv|
csv << headers
def render(truncate_after_bytes = nil)
tempfile = Tempfile.new('issues_csv')
csv = CSV.new(tempfile)
@collection.find_each do |object|
csv << row(object)
end
write_csv(csv) do
truncate_after_bytes && tempfile.size > truncate_after_bytes
end
tempfile.rewind
tempfile.read
ensure
tempfile.close
tempfile.unlink
end
def truncated?
@truncated
end
private
......@@ -55,16 +65,16 @@ class CsvBuilder
end
end
def generate_csv_with_tempfile
tempfile = Tempfile.new('issues_csv')
csv = CSV.new(tempfile)
def write_csv(csv, &until_block)
csv << headers
yield(csv)
@collection.find_each do |object|
csv << row(object)
tempfile.rewind
tempfile.read
ensure
tempfile.close
tempfile.unlink
if until_block.call
@truncated = true
break
end
end
end
end
......@@ -20,6 +20,26 @@ describe CsvBuilder, lib: true do
subject.render
end
describe 'truncation' do
let(:big_object) { double(question: 'Long' * 1024) }
let(:row_size) { big_object.question.length * 2 }
before do
allow(fake_relation).to receive(:find_each).and_yield(big_object)
.and_yield(big_object)
.and_yield(big_object)
end
it 'after given number of bytes' do
expect(subject.render(row_size * 2).length).to be_between(row_size * 2, row_size * 3)
expect(subject).to be_truncated
end
it 'is ignored by default' do
expect(subject.render.length).to be > row_size * 3
end
end
it 'avoids loading all data in a single query' do
expect(fake_relation).to receive(:find_each)
......
......@@ -8,7 +8,8 @@ describe Notify do
describe 'csv export email' do
let(:user) { create(:user) }
let(:empty_project) { create(:empty_project) }
subject { Notify.issues_csv_email(user, empty_project, "dummy content", 3) }
let(:truncated) { false }
subject { Notify.issues_csv_email(user, empty_project, "dummy content", 3, truncated) }
it 'attachment has csv mime type' do
attachment = subject.attachments.first
......@@ -19,5 +20,17 @@ describe Notify do
expect(subject).to have_content '3'
expect(subject).to have_content empty_project.name
end
it "doesn't need to mention truncation by default" do
expect(subject).not_to have_content 'truncated'
end
context 'when truncated' do
let(:truncated) { true }
it 'mentions that the csv has been truncated' do
expect(subject).to have_content 'truncated'
end
end
end
end
......@@ -4,6 +4,6 @@ class IssuesCsvMailerPreview < ActionMailer::Preview
project = Project.unscoped.first
issues_count = 891
Notify.issues_csv_email(user, project, "Dummy,Csv\n0,1", issues_count)
Notify.issues_csv_email(user, project, "Dummy,Csv\n0,1", issues_count, [true, false].sample)
end
end
......@@ -14,6 +14,12 @@ describe Issues::ExportCsvService, services: true do
it 'emails csv' do
expect{ subject.email(user, project) }.to change(ActionMailer::Base.deliveries, :count)
end
it 'renders with a target filesize' do
expect(subject.csv_builder).to receive(:render).with(described_class::TARGET_FILESIZE)
subject.email(user, project)
end
end
def csv
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment