From ea61747f8cdeb90d7d6f9a78fb7182f640f37209 Mon Sep 17 00:00:00 2001 From: Vlad Bilanchuk Date: Tue, 31 Oct 2017 12:14:54 +0200 Subject: [PATCH] added detailed file with timelogs to issue export --- doc/user/project/issues/csv_export.md | 33 +++++++- ee/app/mailers/emails/csv_export.rb | 42 +++++++--- ee/app/services/issues/export_csv_service.rb | 64 ++++++++------- .../issues/export_timelog_csv_service.rb | 53 ++++++++++++ .../notify/_csv_issues_email_title.text.haml | 3 + .../_csv_issues_email_truncated.text.haml | 1 + .../views/notify/issues_csv_email.html.haml | 19 +++-- ee/app/views/notify/issues_csv_email.text.erb | 5 -- .../views/notify/issues_csv_email.text.haml | 9 +++ ee/app/workers/export_csv_worker.rb | 19 +++-- .../3803-individual-time-spent-tracking.yml | 6 ++ ee/lib/csv_builder.rb | 27 +++---- ee/lib/excel_sanitize.rb | 10 +++ ee/lib/extra_line_csv_builder.rb | 80 +++++++++++++++++++ .../extra_line_csv_builder/row_generator.rb | 41 ++++++++++ .../row_generator_spec.rb | 40 ++++++++++ ee/spec/lib/extra_line_csv_builder_spec.rb | 53 ++++++++++++ ee/spec/mailers/emails/csv_export_spec.rb | 79 ++++++++++++++---- .../issues/export_csv_service_spec.rb | 50 ++++++------ .../issues/export_timelog_csv_service_spec.rb | 55 +++++++++++++ 20 files changed, 574 insertions(+), 115 deletions(-) create mode 100644 ee/app/services/issues/export_timelog_csv_service.rb create mode 100644 ee/app/views/notify/_csv_issues_email_title.text.haml create mode 100644 ee/app/views/notify/_csv_issues_email_truncated.text.haml delete mode 100644 ee/app/views/notify/issues_csv_email.text.erb create mode 100644 ee/app/views/notify/issues_csv_email.text.haml create mode 100644 ee/changelogs/unreleased-ee/3803-individual-time-spent-tracking.yml create mode 100644 ee/lib/excel_sanitize.rb create mode 100644 ee/lib/extra_line_csv_builder.rb create mode 100644 ee/lib/extra_line_csv_builder/row_generator.rb create mode 100644 ee/spec/lib/extra_line_csv_builder/row_generator_spec.rb create mode 100644 ee/spec/lib/extra_line_csv_builder_spec.rb create mode 100644 ee/spec/services/issues/export_timelog_csv_service_spec.rb diff --git a/doc/user/project/issues/csv_export.md b/doc/user/project/issues/csv_export.md index 56b9458567287a..d1f7eb7eaadd77 100644 --- a/doc/user/project/issues/csv_export.md +++ b/doc/user/project/issues/csv_export.md @@ -46,8 +46,13 @@ Exported issues are always sorted by `Issue ID`. > > **Weight** and **Locked** columns were [introduced](https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/5300) in GitLab Starter 10.8. -Data will be encoded with a comma as the column delimiter, with `"` used to quote fields if needed, and newlines to separate rows. The first row will be the headers, which are listed in the following table along with a description of the values: +Data will be encoded with a comma as the column delimiter, with `"` used to quote fields if needed, and newlines to separate rows. The first row will be the headers, which are listed in the _table 1_ along with a description of the values. +Data will be separated in two files: +- In first one there will be general issue information accroding to the headers in _table 1_ +- In second one there will be information relevant to headers in _table 2_ and in next lines will be only information about following time tracking records(last three columns) and the rest columns will be empty. +When all time tracking records have written in the next line will be next issue with values appropriative to headers and so on. +_Table 1_ | Column | Description | |---------|-------------| @@ -69,8 +74,32 @@ Data will be encoded with a comma as the column delimiter, with `"` used to quot | Weight | Issue weight | | Labels | Title of any labels joined with a `,` | | Time Estimate | [Time estimate](../../../workflow/time_tracking.md#estimates) in seconds | -| Time Spent | [Time spent](../../../workflow/time_tracking.md#time-spent) in seconds | +| Total Time Spent | sum of [Time spent](../../../workflow/time_tracking.md#time-spent) in seconds | +_Table 2_ + +| Column | Description | +|---------|-------------| +| Issue ID | Issue `iid` | +| URL | A link to the issue on GitLab | +| Title | Issue `title` | +| State | `Open` or `Closed` | +| Description | Issue `description` | +| Author | Full name of the issue author | +| Author Username | Username of the author, with the `@` symbol omitted | +| Assignee | Full name of the issue assignee | +| Assignee Username | Username of the author, with the `@` symbol omitted | +| Confidential | `Yes` or `No` | +| Due Date | Formated as `YYYY-MM-DD` | +| Created At (UTC) | Formated as `YYYY-MM-DD HH:MM:SS` | +| Updated At (UTC) | Formated as `YYYY-MM-DD HH:MM:SS` | +| Milestone | Title of the issue milestone | +| Labels | Title of any labels joined with a `,` | +| Time Estimate | [Time estimate](../../../workflow/time_tracking.md#estimates) in seconds | +| Total Time Spent | sum of [Time spent](../../../workflow/time_tracking.md#time-spent) in seconds | +| Time Spent | [Time spent](../../../workflow/time_tracking.md#time-spent) in seconds(individual record) | +| Time Spent On | Time Spent date formated as `YYYY-MM-DD HH:MM:SS UTC` | +| Time Spent By | Username of the user, who recorded [Time spent](../../../workflow/time_tracking.md#time-spent), with the `@` symbol omitted | ## Limitations diff --git a/ee/app/mailers/emails/csv_export.rb b/ee/app/mailers/emails/csv_export.rb index a9aaff2e45e5dd..2014b3c7c19e16 100644 --- a/ee/app/mailers/emails/csv_export.rb +++ b/ee/app/mailers/emails/csv_export.rb @@ -1,17 +1,41 @@ module Emails module CsvExport - def issues_csv_email(user, project, csv_data, export_status) - @project = project - @issues_count = export_status.fetch(:rows_expected) - @written_count = export_status.fetch(:rows_written) - @truncated = export_status.fetch(:truncated) + def issues_csv_email(user, project, *csv_services) + @project = project + @csv_services = select_services(csv_services) - filename = "#{project.full_path.parameterize}_issues_#{Date.today.iso8601}.csv" - attachments[filename] = { content: csv_data, mime_type: 'text/csv' } - mail(to: user.notification_email, subject: subject("Exported issues")) do |format| + @csv_services.each do |service| + filename = generate_issues_filename(project.full_path.parameterize, service.postfix) + attachments[filename] = issues_attachment(service.csv_data) + end + + send_mail(user.notification_email) + end + + private + + def select_services(*csv_services) + csv_services.flatten.select { |service| service.attach_file? } + end + + def send_mail(email) + mail(to: email, subject: subject("Exported issues")) do |format| format.html { render layout: 'mailer' } - format.text { render layout: 'mailer' } + format.text end end + + def generate_issues_filename(project_name, postfix) + result_name = "#{project_name}_issues" + result_name << "_#{postfix}" if postfix.present? + result_name << "_#{Date.today.iso8601}.csv" + end + + def issues_attachment(data) + { + content: data, + mime_type: 'text/csv' + } + end end end diff --git a/ee/app/services/issues/export_csv_service.rb b/ee/app/services/issues/export_csv_service.rb index 6e851ae312a149..6a24d16896f7c8 100644 --- a/ee/app/services/issues/export_csv_service.rb +++ b/ee/app/services/issues/export_csv_service.rb @@ -3,8 +3,8 @@ class ExportCsvService include Gitlab::Routing.url_helpers include GitlabRoutingHelper - # Target attachment size before base64 encoding - TARGET_FILESIZE = 15000000 + TARGET_FILESIZE = 15000000 # Target attachment size before base64 encoding + POSTFIX = nil # CSV-file name postfix def initialize(issues_relation) @issues = issues_relation @@ -15,41 +15,49 @@ def csv_data csv_builder.render(TARGET_FILESIZE) end - def email(user, project) - Notify.issues_csv_email(user, project, csv_data, csv_builder.status).deliver_now + def csv_builder + @csv_builder ||= CsvBuilder.new(collection, header_to_value_hash) end - # rubocop: disable CodeReuse/ActiveRecord - def csv_builder - @csv_builder ||= - CsvBuilder.new(@issues.preload(:author, :assignees, :timelogs), header_to_value_hash) + # If no issues are found, an empty csv-file will be attached to the email + def attach_file? + true + end + + def postfix + self.class::POSTFIX end - # rubocop: enable CodeReuse/ActiveRecord private + # rubocop: disable CodeReuse/ActiveRecord + def collection + @issues.preload(:author, :assignees, :timelogs) + end + # rubocop: enable CodeReuse/ActiveRecord + def header_to_value_hash { - 'Issue ID' => 'iid', - 'URL' => -> (issue) { issue_url(issue) }, - 'Title' => 'title', - 'State' => -> (issue) { issue.closed? ? 'Closed' : 'Open' }, - 'Description' => 'description', - 'Author' => 'author_name', - 'Author Username' => -> (issue) { issue.author&.username }, - 'Assignee' => -> (issue) { issue.assignees.map(&:name).join(', ') }, + 'Issue ID' => :iid, + 'URL' => -> (issue) { issue_url(issue) }, + 'Title' => :title, + 'State' => -> (issue) { issue.closed? ? 'Closed' : 'Open' }, + 'Description' => :description, + 'Author' => :author_name, + 'Author Username' => -> (issue) { issue.author&.username }, + 'Assignee' => -> (issue) { issue.assignees.map(&:name).join(', ') }, 'Assignee Username' => -> (issue) { issue.assignees.map(&:username).join(', ') }, - 'Confidential' => -> (issue) { issue.confidential? ? 'Yes' : 'No' }, - 'Locked' => -> (issue) { issue.discussion_locked? ? 'Yes' : 'No' }, - '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) }, - 'Closed At (UTC)' => -> (issue) { issue.closed_at&.to_s(:csv) }, - 'Milestone' => -> (issue) { issue.milestone&.title }, - 'Weight' => -> (issue) { issue.weight }, - 'Labels' => -> (issue) { @labels[issue.id].sort.join(',').presence }, - 'Time Estimate' => ->(issue) { issue.time_estimate.to_s(:csv) }, - 'Time Spent' => -> (issue) { issue.timelogs.map(&:time_spent).inject(0, :+)} + 'Confidential' => -> (issue) { issue.confidential? ? 'Yes' : 'No' }, + 'Locked' => -> (issue) { issue.discussion_locked? ? 'Yes' : 'No' }, + '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) }, + 'Closed At (UTC)' => -> (issue) { issue.closed_at&.to_s(:csv) }, + 'Milestone' => -> (issue) { issue.milestone&.title }, + 'Weight' => :weight, + 'Labels' => -> (issue) { @labels[issue.id].sort.join(',').presence }, + 'Time Estimate' => -> (issue) { issue.time_estimate.to_s(:csv) }, + 'Total Time Spent' => -> (issue) { issue.timelogs.map(&:time_spent).inject(0, :+) } } end end diff --git a/ee/app/services/issues/export_timelog_csv_service.rb b/ee/app/services/issues/export_timelog_csv_service.rb new file mode 100644 index 00000000000000..99cc17afbd4d86 --- /dev/null +++ b/ee/app/services/issues/export_timelog_csv_service.rb @@ -0,0 +1,53 @@ +module Issues + class ExportTimelogCsvService < Issues::ExportCsvService + POSTFIX = 'with_timelogs'.freeze + + def csv_builder + @csv_builder ||= ExtraLineCsvBuilder.new(collection, + all_header_to_value_hash, + :timelogs, + repeatable_header_to_value_hash) + end + + # csv-file with timelogs will be attached to the email only if any of the filtered issues have timelogs + def attach_file? + collection.present? + end + + private + + # rubocop: disable CodeReuse/ActiveRecord + def collection + @issues.includes(:author, :assignees, :timelogs).where.not(timelogs: { id: nil }) + end + # rubocop: enable CodeReuse/ActiveRecord + + def repeatable_header_to_value_hash + { + 'Time Spent' => time_spent, + 'Time Spent On' => time_spent_on, + 'Time Spent By' => time_spent_by + } + end + + def all_header_to_value_hash + header_to_value_hash.merge(repeatable_header_to_value_hash) + end + + def time_spent + -> (issue, index = 0) { issue.timelogs[index].time_spent if timelog_in_range?(issue, index) } + end + + def time_spent_on + -> (issue, index = 0) { issue.timelogs[index].spent_at || issue.timelogs.first.created_at if timelog_in_range?(issue, index) } + end + + def time_spent_by + -> (issue, index = 0) { issue.timelogs[index].user.username if timelog_in_range?(issue, index) } + end + + def timelog_in_range?(issue, index) + issue.timelogs.size > index + end + end +end diff --git a/ee/app/views/notify/_csv_issues_email_title.text.haml b/ee/app/views/notify/_csv_issues_email_title.text.haml new file mode 100644 index 00000000000000..ccdc91c99f9613 --- /dev/null +++ b/ee/app/views/notify/_csv_issues_email_title.text.haml @@ -0,0 +1,3 @@ +Your CSV export from project += project_full_name +has been added to this email as an attachment. diff --git a/ee/app/views/notify/_csv_issues_email_truncated.text.haml b/ee/app/views/notify/_csv_issues_email_truncated.text.haml new file mode 100644 index 00000000000000..e48f8c45fde205 --- /dev/null +++ b/ee/app/views/notify/_csv_issues_email_truncated.text.haml @@ -0,0 +1 @@ +"#{attachment_type}" attachment has been truncated to avoid exceeding a maximum allowed attachment size of 20MB. #{written_count} of #{issues_count} issues have been included. Consider re-exporting with a narrower selection of issues. diff --git a/ee/app/views/notify/issues_csv_email.html.haml b/ee/app/views/notify/issues_csv_email.html.haml index f2df58507a7165..a3ecd6c3236eee 100644 --- a/ee/app/views/notify/issues_csv_email.html.haml +++ b/ee/app/views/notify/issues_csv_email.html.haml @@ -1,8 +1,13 @@ %p{ style: 'font-size:18px; text-align:center; line-height:30px;' } - Your CSV export of #{ pluralize(@written_count, 'issue') } from project - %a{ href: project_url(@project), style: "color:#3777b0; text-decoration:none; display:block;" } - = @project.full_name - has been added to this email as an attachment. - - if @truncated - %p - This attachment has been truncated to avoid exceeding a maximum allowed attachment size of 20MB. #{ @written_count } of #{ @issues_count } issues have been included. Consider re-exporting with a narrower selection of issues. + = render partial: 'csv_issues_email_title.text.haml', + locals: { project_full_name: (link_to @project.full_name, + project_url(@project), + { style: 'color: #3777b0; text-decoration: none; display: block;' }) } + + - @csv_services.each do |service| + - if service.csv_builder.truncated? + %p + = render partial: 'csv_issues_email_truncated.text.haml', + locals: { written_count: service.csv_builder.rows_written, + issues_count: service.csv_builder.rows_expected, + attachment_type: service.postfix } diff --git a/ee/app/views/notify/issues_csv_email.text.erb b/ee/app/views/notify/issues_csv_email.text.erb deleted file mode 100644 index c07534eb7bb428..00000000000000 --- a/ee/app/views/notify/issues_csv_email.text.erb +++ /dev/null @@ -1,5 +0,0 @@ -Your CSV export of <%= pluralize(@written_count, 'issue') %> from project <%= @project.full_name %> (<%= project_url(@project) %>) has been added to this email as an attachment. - -<% if @truncated %> -This attachment has been truncated to avoid exceeding a maximum allowed attachment size of 20MB. <%= @written_count %> of <%= @issues_count %> issues have been included. Consider re-exporting with a narrower selection of issues. -<% end %> \ No newline at end of file diff --git a/ee/app/views/notify/issues_csv_email.text.haml b/ee/app/views/notify/issues_csv_email.text.haml new file mode 100644 index 00000000000000..4469825a784891 --- /dev/null +++ b/ee/app/views/notify/issues_csv_email.text.haml @@ -0,0 +1,9 @@ += render partial: 'csv_issues_email_title', + locals: { project_full_name: @project.full_name } + +- @csv_services.each do |service| + - if service.csv_builder&.truncated? + = render partial: 'csv_issues_email_truncated', + locals: { written_count: service.csv_builder.rows_written, + issues_count: service.csv_builder.rows_expected, + attachment_type: service.postfix } diff --git a/ee/app/workers/export_csv_worker.rb b/ee/app/workers/export_csv_worker.rb index 914e9adc20c78d..243525c97f91e9 100644 --- a/ee/app/workers/export_csv_worker.rb +++ b/ee/app/workers/export_csv_worker.rb @@ -2,15 +2,24 @@ class ExportCsvWorker include ApplicationWorker def perform(current_user_id, project_id, params) - @current_user = User.find(current_user_id) - @project = Project.find(project_id) - params.symbolize_keys! params[:project_id] = project_id params.delete(:sort) - issues = IssuesFinder.new(@current_user, params).execute + current_user = User.find(current_user_id) + project = Project.find(project_id) + @issues = IssuesFinder.new(current_user, params).execute + + Notify.issues_csv_email(current_user, project, general_service, timelogs_service).deliver_now + end + + private + + def general_service + Issues::ExportCsvService.new(@issues) + end - Issues::ExportCsvService.new(issues).email(@current_user, @project) + def timelogs_service + Issues::ExportTimelogCsvService.new(@issues) end end diff --git a/ee/changelogs/unreleased-ee/3803-individual-time-spent-tracking.yml b/ee/changelogs/unreleased-ee/3803-individual-time-spent-tracking.yml new file mode 100644 index 00000000000000..e3993f9af50eb6 --- /dev/null +++ b/ee/changelogs/unreleased-ee/3803-individual-time-spent-tracking.yml @@ -0,0 +1,6 @@ +--- +title: Added detailed csv file with individual time tracking information to issues + export +merge_request: 3237 +author: g3dinua, LockiStrike +type: changed diff --git a/ee/lib/csv_builder.rb b/ee/lib/csv_builder.rb index 984ad8eee327a2..70b318ab321db2 100644 --- a/ee/lib/csv_builder.rb +++ b/ee/lib/csv_builder.rb @@ -12,6 +12,8 @@ # CsvBuilder.new(@posts, columns).render # class CsvBuilder + include ExcelSanitize + attr_reader :rows_written # @@ -33,7 +35,7 @@ def render(truncate_after_bytes = nil) tempfile = Tempfile.new('csv_export') csv = CSV.new(tempfile) - write_csv csv, until_condition: -> do + write_csv(csv) do truncate_after_bytes && tempfile.size > truncate_after_bytes end @@ -56,14 +58,6 @@ def rows_expected end end - def status - { - truncated: truncated?, - rows_written: rows_written, - rows_expected: rows_expected - } - end - private def headers @@ -84,25 +78,22 @@ def row(object) end end - def write_csv(csv, until_condition:) + def write_csv(csv, &until_condition) csv << headers @collection.find_each do |object| - csv << row(object) - - @rows_written += 1 + collect_rows(csv, object, &until_condition) - if until_condition.call + if yield @truncated = true break end end end - def excel_sanitize(line) - return if line.nil? + def collect_rows(csv, object, **) + csv << row(object) - line.prepend("'") if line =~ /^[=\+\-@;]/ - line + @rows_written += 1 end end diff --git a/ee/lib/excel_sanitize.rb b/ee/lib/excel_sanitize.rb new file mode 100644 index 00000000000000..95a31a0ee60a7f --- /dev/null +++ b/ee/lib/excel_sanitize.rb @@ -0,0 +1,10 @@ +module ExcelSanitize + module_function + + def excel_sanitize(line) + return if line.nil? + + line.prepend("'") if line =~ /^[=\+\-@;]/ + line + end +end diff --git a/ee/lib/extra_line_csv_builder.rb b/ee/lib/extra_line_csv_builder.rb new file mode 100644 index 00000000000000..cbb45d92d25b80 --- /dev/null +++ b/ee/lib/extra_line_csv_builder.rb @@ -0,0 +1,80 @@ +# Extends CsvBuilder to allow each object to be represented by multiple lines. +# One row is written initially, followed by multiple rows for a related association. +# For example, to include columns for time tracking where there are multiple records per issue. +# Example: +# +# header_to_value_hash = { +# 'Title' => :title, +# 'Comment' => :comment, +# 'Author' => -> (post) { post.author.full_name } +# 'Created At (UTC)' => -> (post) { post.created_at&.strftime('%Y-%m-%d %H:%M:%S') } +# 'Time Spent By' => -> (issue, index = 0) { issue.timelogs[index].user.username } +# } +# +# repeatable_fields = { +# 'Time Spent By' => -> (issue, index = 0) { issue.timelogs[index].user.username } +# } +# +# ExtraLineCsvBuilder.new(Issue.limit(1), header_to_value_hash, :timelogs, repeatable_fields).render +# +# Result would be: +# +# Title Description Author Create At (UTC) Time Spent By +# Twitter News and social networking service JackDorsey 15/07/2006 00:00:00 JackDorsey +# nil nil nil nil NoahGlass +# nil nil nil nil BizStone +# nil nil nil nil EvanWilliams +class ExtraLineCsvBuilder < CsvBuilder + EXTRA_ASSOCIATED_OBJECTS_LIMIT = 1 + START_EXTRA_OBJECTS_INDEX = 1 + + def initialize(collection, header_to_value_hash, association_name, repeatable_fields) + super(collection, header_to_value_hash) + + @association_name = association_name + @repeatable_fields = repeatable_fields + @extra_line_builder = RowGenerator.new(header_to_value_hash, repeatable_fields) + end + + def rows_expected + truncated? || rows_written == 0 ? prewrite_rows_expectation : rows_written + end + + private + + def associated_objects(object) + object.public_send(@association_name) # rubocop:disable GitlabSecurity/PublicSend + end + + def collect_rows(csv, object, &until_condition) + super(csv, object) + + write_extra_rows(csv, object, &until_condition) if extra_associated_objects?(object) + end + + def extra_associated_objects?(object) + associated_objects(object).size > EXTRA_ASSOCIATED_OBJECTS_LIMIT + end + + def write_extra_rows(csv, object) + START_EXTRA_OBJECTS_INDEX.upto(associated_objects(object).size - 1) do |index| + csv << @extra_line_builder.row_for(object, index) + + @rows_written += 1 + + if yield + @truncated = true + break + end + end + end + + def prewrite_rows_expectation + # get size of all non-empty relations + result = @collection.flat_map(&@association_name).size + # add to counter all empty([]) relations which flat_map doesn't include + @collection.each { |c| result += 1 if associated_objects(c).empty? } + + result + end +end diff --git a/ee/lib/extra_line_csv_builder/row_generator.rb b/ee/lib/extra_line_csv_builder/row_generator.rb new file mode 100644 index 00000000000000..a89f4e5a055ddf --- /dev/null +++ b/ee/lib/extra_line_csv_builder/row_generator.rb @@ -0,0 +1,41 @@ +# Generates rows for use in ExtraLineCsvBuilder. Determines which of the provided headers +# need data written and uses a hash of fields to compute the output value for each +# additional cell. Outputs one row for each index in a relation. +# Example: +# +# header_to_value_hash = { +# 'Title' => :title, +# 'Comment' => :comment, +# 'Author' => -> (post) { post.author.full_name } +# 'Created At (UTC)' => -> (post) { post.created_at&.strftime('%Y-%m-%d %H:%M:%S') } +# 'Time Spent By' => -> (issue, index = 0) { issue.timelogs[index].user.username } +# } +# +# repeatable_fields = { +# 'Time Spent By' => -> (issue, index = 0) { issue.timelogs[index].user.username } +# } +# +# ExtraLineBuilder::RowGenerator.new(header_to_value_hash, repeatable_fields).row_for(Issue.first, 2) +# +# Result would be: +# +# Title Comment Author Create At (UTC) Time Spent By +# nil nil nil nil LockiStrike +class ExtraLineCsvBuilder + class RowGenerator + include ExcelSanitize + + def initialize(header_to_value_hash, repeatable_fields) + @all_headers = header_to_value_hash.keys + @repeatable_headers = repeatable_fields + end + + def row_for(object, index) + @all_headers.map do |header| + if @repeatable_headers.keys.include?(header) + excel_sanitize(@repeatable_headers[header].call(object, index)) + end + end + end + end +end diff --git a/ee/spec/lib/extra_line_csv_builder/row_generator_spec.rb b/ee/spec/lib/extra_line_csv_builder/row_generator_spec.rb new file mode 100644 index 00000000000000..15a3c7ba6124cc --- /dev/null +++ b/ee/spec/lib/extra_line_csv_builder/row_generator_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe ExtraLineCsvBuilder::RowGenerator do + let(:header_to_value_hash) do + { + col1: -> { 'column1' }, + col2: -> (object, index) { "column2 with #{object}, #{index}" }, + col3: -> { 'column3' }, + col4: -> (object, index) { "column4 with #{object}, #{index}" } + } + end + let(:repeatable_fields) do + { + col2: -> (object, index) { "column2 with #{object}, #{index}" }, + col4: -> (object, index) { "column4 with #{object}, #{index}" } + } + end + let(:object) { create(:issue) } + let(:index) { 3 } + let(:correct_row) do + [ + nil, + repeatable_fields[:col2].call(object, index), + nil, + repeatable_fields[:col4].call(object, index) + ] + end + let(:subject) { described_class.new(header_to_value_hash, repeatable_fields) } + let(:subject_with_no_coincidence) { described_class.new(header_to_value_hash, {}) } + + describe '#row_for' do + it 'creates correct row' do + expect(subject.row_for(object, index)).to eq correct_row + end + + it 'works with no coincidence' do + expect(subject_with_no_coincidence.row_for(object, index)).to eq Array.new(header_to_value_hash.keys.size) + end + end +end diff --git a/ee/spec/lib/extra_line_csv_builder_spec.rb b/ee/spec/lib/extra_line_csv_builder_spec.rb new file mode 100644 index 00000000000000..5765eda5abb607 --- /dev/null +++ b/ee/spec/lib/extra_line_csv_builder_spec.rb @@ -0,0 +1,53 @@ +require 'spec_helper' + +describe ExtraLineCsvBuilder do + let(:user) { create(:user) } + let(:project) { create(:project, path: 'myproject') } + let(:issue) { create(:issue, project: project, author: user) } + let!(:second_issue) { create(:issue, project: project, author: user) } + let(:header_to_value_hash) do + { + 'Issue ID' => 'iid', + 'URL' => -> (issue) { issue_url(issue) }, + 'Title' => 'title', + 'State' => -> (issue) { issue.closed? ? 'Closed' : 'Open' }, + 'Description' => 'description', + 'Time Spent' => -> (issue, index = 0) { issue.timelogs[index].time_spent if timelog_in_range?(issue, index) }, + 'Time Spent On' => -> (issue, index = 0) { issue.timelogs[index].spent_at.to_date || issue.timelogs.first.created_at if timelog_in_range?(issue, index) } + } + end + let(:repeatable_fields) do + { + 'Time Spent' => -> (issue, index = 0) { issue.timelogs[index].time_spent if timelog_in_range?(issue, index) }, + 'Time Spent On' => -> (issue, index = 0) { issue.timelogs[index].spent_at.to_date || issue.timelogs.first.created_at if timelog_in_range?(issue, index) } + } + end + let(:timelogs) { :timelogs } + let(:subject) do + described_class.new( + Issue.all, + header_to_value_hash, + timelogs, + repeatable_fields + ) + end + let(:test) { subject.render } + + before do + issue.timelogs.create(time_spent: 360, user: user, spent_at: '2016-05-05') + issue.timelogs.create(time_spent: 200, user: user, spent_at: '2012-12-12') + issue.timelogs.create(time_spent: 2500, user: user, spent_at: '2000-01-01') + second_issue.timelogs.create(time_spent: 777, user: user, spent_at: '2004-06-22') + second_issue.timelogs.create(time_spent: 23, user: user, spent_at: '2007-10-27') + end + + describe '#rows_expected' do + it 'calculates expectation of rows considering relation object count' do + expect(subject.rows_expected).to eq 5 + end + end + + def csv + CSV.parse(subject.render, headers: true) + end +end diff --git a/ee/spec/mailers/emails/csv_export_spec.rb b/ee/spec/mailers/emails/csv_export_spec.rb index c1ead29fc50a00..d6b5b783c10c1c 100644 --- a/ee/spec/mailers/emails/csv_export_spec.rb +++ b/ee/spec/mailers/emails/csv_export_spec.rb @@ -6,25 +6,62 @@ include_context 'gitlab email notification' describe 'csv export email' do - let(:user) { create(:user) } + shared_context :define_services do |rows_expected: 10, rows_written: 10, with_timelogs: true| + let(:general_service) do + double(:general_service, + csv_builder: double( + :general_builder, + truncated?: rows_expected > rows_written, + rows_expected: rows_expected, + rows_written: rows_written), + csv_data: 'dummy content', + attach_file?: true, + postfix: ::Issues::ExportCsvService::POSTFIX) + end + + let(:timelogs_service) do + double(:timelogs_service, + csv_builder: double( + :timelogs_builder, + truncated?: rows_expected > rows_written, + rows_expected: rows_expected, + rows_written: rows_written), + csv_data: 'timelogs dummy content', + attach_file?: with_timelogs, + postfix: ::Issues::ExportTimelogCsvService::POSTFIX) + end + end + + let(:user) { create(:user) } let(:empty_project) { create(:project, path: 'myproject') } - let(:export_status) { { truncated: false, rows_expected: 3, rows_written: 3 } } - subject { Notify.issues_csv_email(user, empty_project, "dummy content", export_status) } - let(:attachment) { subject.attachments.first } - it 'attachment has csv mime type' do - expect(attachment.mime_type).to eq 'text/csv' + include_context :define_services + + subject { Notify.issues_csv_email(user, empty_project, general_service, timelogs_service) } + + let(:attachment) { subject.attachments.fetch(0, nil) } + let(:timelog_attachment) { subject.attachments.fetch(1, nil) } + + shared_examples :check_filename do |attachment, service| + it 'generates a useful filename' do + expect(public_send(attachment).filename).to include('myproject') + expect(public_send(attachment).filename).to include('issues') + expect(public_send(attachment).filename).to include(service::POSTFIX) unless service::POSTFIX.nil? + expect(public_send(attachment).filename).to include(Date.today.year.to_s) + expect(public_send(attachment).filename).to end_with('.csv') + end end - it 'generates a useful filename' do - expect(attachment.filename).to include(Date.today.year.to_s) - expect(attachment.filename).to include('issues') - expect(attachment.filename).to include('myproject') - expect(attachment.filename).to end_with('.csv') + include_examples :check_filename, :attachment, ::Issues::ExportCsvService + include_examples :check_filename, :timelog_attachment, ::Issues::ExportTimelogCsvService + + it 'attachment has csv mime type' do + subject.attachments.each do |attachment| + expect(attachment.mime_type).to eq 'text/csv' + end end - it 'mentions number of issues and project name' do - expect(subject).to have_content '3' + it 'mentions project name' do expect(subject).to have_content empty_project.name end @@ -33,14 +70,26 @@ end context 'when truncated' do - let(:export_status) { { truncated: true, rows_expected: 12, rows_written: 10 } } + include_context :define_services, + rows_expected: 50, + rows_written: 42 it 'mentions that the csv has been truncated' do expect(subject).to have_content 'truncated' end it 'mentions the number of issues written and expected' do - expect(subject).to have_content '10 of 12 issues' + expect(subject).to have_content '42 of 50 issues' + end + end + + context 'when issues without timelogs' do + include_context :define_services, + with_timelogs: false + + it 'only one file will be attached' do + expect(attachment).not_to be_nil + expect(timelog_attachment).to be_nil end end end diff --git a/ee/spec/services/issues/export_csv_service_spec.rb b/ee/spec/services/issues/export_csv_service_spec.rb index 134a20263718c9..057ebe00b73c1d 100644 --- a/ee/spec/services/issues/export_csv_service_spec.rb +++ b/ee/spec/services/issues/export_csv_service_spec.rb @@ -11,18 +11,6 @@ expect(subject.csv_data).to be_a String end - describe '#email' 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 CSV.parse(subject.csv_data, headers: true) end @@ -35,8 +23,8 @@ def csv before do # Creating a timelog touches the updated_at timestamp of issue, # so create these first. - issue.timelogs.create(time_spent: 360, user: user) - issue.timelogs.create(time_spent: 200, user: user) + issue.timelogs.create(time_spent: 360, user: user, spent_at: '2016-05-05') + issue.timelogs.create(time_spent: 200, user: user, spent_at: '2012-12-12') issue.update!(milestone: milestone, assignees: [user], description: 'Issue with details', @@ -56,7 +44,8 @@ def csv end specify 'url' do - expect(csv[0]['URL']).to match(/http.*#{project.full_path}.*#{issue.iid}/) + url = /http.*#{project.full_path}.*#{issue.iid}/ + expect(csv[0]['URL']).to match url end specify 'title' do @@ -69,7 +58,6 @@ def csv specify 'description' do expect(csv[0]['Description']).to eq issue.description - expect(csv[1]['Description']).to eq nil end specify 'author name' do @@ -82,12 +70,10 @@ def csv specify 'assignee name' do expect(csv[0]['Assignee']).to eq user.name - expect(csv[1]['Assignee']).to eq '' end specify 'assignee username' do expect(csv[0]['Assignee Username']).to eq user.username - expect(csv[1]['Assignee Username']).to eq '' end specify 'confidential' do @@ -96,17 +82,14 @@ def csv specify 'milestone' do expect(csv[0]['Milestone']).to eq issue.milestone.title - expect(csv[1]['Milestone']).to eq nil end specify 'labels' do expect(csv[0]['Labels']).to eq 'Feature,Idea' - expect(csv[1]['Labels']).to eq nil end specify 'due_date' do expect(csv[0]['Due Date']).to eq '2014-03-02' - expect(csv[1]['Due Date']).to eq nil end specify 'created_at' do @@ -119,7 +102,6 @@ def csv specify 'closed_at' do expect(csv[0]['Closed At (UTC)']).to eq '2017-06-05 04:03:02' - expect(csv[1]['Closed At (UTC)']).to eq nil end specify 'discussion_locked' do @@ -132,12 +114,28 @@ def csv specify 'time estimate' do expect(csv[0]['Time Estimate']).to eq '72000' - expect(csv[1]['Time Estimate']).to eq '0' end - specify 'time spent' do - expect(csv[0]['Time Spent']).to eq '560' - expect(csv[1]['Time Spent']).to eq '0' + specify 'total time spent' do + expect(csv[0]['Total Time Spent']).to eq '560' + end + + specify 'fields on bad issue' do + empty_fields = ['Description', 'Assignee', 'Assignee Username', 'Milestone', 'Labels', 'Due Date', + 'Closed At (UTC)', 'Time Estimate', 'Total Time Spent'] + + empty_fields.each do |field| + expect(csv[1][field]).to eq bad_issue_expectations[field] + end + end + + def bad_issue_expectations + { + 'Assignee' => '', + 'Assignee Username' => '', + 'Time Estimate' => '0', + 'Total Time Spent' => '0' + } end context 'with issues filtered by labels and project' do diff --git a/ee/spec/services/issues/export_timelog_csv_service_spec.rb b/ee/spec/services/issues/export_timelog_csv_service_spec.rb new file mode 100644 index 00000000000000..8a3b71d53f954e --- /dev/null +++ b/ee/spec/services/issues/export_timelog_csv_service_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +describe Issues::ExportTimelogCsvService do + SPENT_VALUES = [ + SPENT_TIME_1 = '360'.freeze, + SPENT_TIME_2 = '200'.freeze + ].freeze + + SPENT_AT_DATES = [ + SPENT_AT_DATE_1 = '2016-05-05'.freeze, + SPENT_AT_DATE_2 = '2012-12-12'.freeze + ].freeze + + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + let(:issue) { create(:issue, project: project, author: user) } + let(:subject) { described_class.new(Issue.all) } + + context 'includes' do + before do + issue.timelogs.create(time_spent: SPENT_TIME_1, user: user, spent_at: SPENT_AT_DATE_1) + issue.timelogs.create(time_spent: SPENT_TIME_2, user: user, spent_at: SPENT_AT_DATE_2) + end + + def csv + CSV.parse(subject.csv_data, headers: true) + end + + context 'includes' do + it 'Time Spent field' do + expect(csv['Time Spent'].sort).to eq SPENT_VALUES.sort + end + + it 'Time Spent By field' do + csv.each do |line| + expect(line['Time Spent By']).to eq user.username + end + end + + it 'Time Spent On field' do + target_array = SPENT_AT_DATES.map { |date| Date.parse(date).beginning_of_day.to_s } + + expect(csv['Time Spent On'].sort).to eq target_array.sort + end + end + + context 'second and next issue rows don\'t include' do + it 'common fields' do + %w(Title Author State).each do |field| + expect(csv[1][field]).to eq nil + end + end + end + end +end -- GitLab