Commit 6d9b901d authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch 'issue_212330_2' into 'master'

Move export issues feature to core

See merge request gitlab-org/gitlab!28703
parents 5cdaf0ec 9492ef31
...@@ -11,7 +11,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -11,7 +11,7 @@ class Projects::IssuesController < Projects::ApplicationController
include RecordUserLastActivity include RecordUserLastActivity
def issue_except_actions def issue_except_actions
%i[index calendar new create bulk_update import_csv] %i[index calendar new create bulk_update import_csv export_csv]
end end
def set_issuables_index_only_actions def set_issuables_index_only_actions
...@@ -20,7 +20,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -20,7 +20,7 @@ class Projects::IssuesController < Projects::ApplicationController
prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) }
prepend_before_action(only: [:calendar]) { authenticate_sessionless_user!(:ics) } prepend_before_action(only: [:calendar]) { authenticate_sessionless_user!(:ics) }
prepend_before_action :authenticate_user!, only: [:new] prepend_before_action :authenticate_user!, only: [:new, :export_csv]
# designs is only applicable to EE, but defining a prepend_before_action in EE code would overwrite this # designs is only applicable to EE, but defining a prepend_before_action in EE code would overwrite this
prepend_before_action :store_uri, only: [:new, :show, :designs] prepend_before_action :store_uri, only: [:new, :show, :designs]
...@@ -189,6 +189,13 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -189,6 +189,13 @@ class Projects::IssuesController < Projects::ApplicationController
end end
end end
def export_csv
ExportCsvWorker.perform_async(current_user.id, project.id, finder_options.to_h) # rubocop:disable CodeReuse/Worker
index_path = project_issues_path(project)
redirect_to(index_path, notice: "Your CSV export has started. It will be emailed to #{current_user.notification_email} when complete.")
end
def import_csv def import_csv
if uploader = UploadService.new(project, params[:file]).execute if uploader = UploadService.new(project, params[:file]).execute
ImportIssuesCsvWorker.perform_async(current_user.id, project.id, uploader.upload.id) # rubocop:disable CodeReuse/Worker ImportIssuesCsvWorker.perform_async(current_user.id, project.id, uploader.upload.id) # rubocop:disable CodeReuse/Worker
......
...@@ -91,6 +91,20 @@ module Emails ...@@ -91,6 +91,20 @@ module Emails
end end
end end
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)
filename = "#{project.full_path.parameterize}_issues_#{Date.today.iso8601}.csv"
attachments[filename] = { content: csv_data, mime_type: 'text/csv' }
mail(to: user.notification_email_for(@project.group), subject: subject("Exported issues")) do |format|
format.html { render layout: 'mailer' }
format.text { render layout: 'mailer' }
end
end
private private
def setup_issue_mail(issue_id, recipient_id, closed_via: nil) def setup_issue_mail(issue_id, recipient_id, closed_via: nil)
......
...@@ -80,6 +80,10 @@ class NotifyPreview < ActionMailer::Preview ...@@ -80,6 +80,10 @@ class NotifyPreview < ActionMailer::Preview
Notify.import_issues_csv_email(user.id, project.id, { success: 3, errors: [5, 6, 7], valid_file: true }) Notify.import_issues_csv_email(user.id, project.id, { success: 3, errors: [5, 6, 7], valid_file: true })
end end
def issues_csv_email
Notify.issues_csv_email(user, project, '1997,Ford,E350', { truncated: false, rows_expected: 3, rows_written: 3 }).message
end
def closed_merge_request_email def closed_merge_request_email
Notify.closed_merge_request_email(user.id, issue.id, user.id).message Notify.closed_merge_request_email(user.id, issue.id, user.id).message
end end
......
...@@ -131,8 +131,21 @@ module Issuable ...@@ -131,8 +131,21 @@ module Issuable
strip_attributes :title strip_attributes :title
def self.locking_enabled? class << self
false def labels_hash
issue_labels = Hash.new { |h, k| h[k] = [] }
relation = unscoped.where(id: self.select(:id)).eager_load(:labels)
relation.pluck(:id, 'labels.title').each do |issue_id, label|
issue_labels[issue_id] << label if label.present?
end
issue_labels
end
def locking_enabled?
false
end
end end
# We want to use optimistic lock for cases when only title or description are involved # We want to use optimistic lock for cases when only title or description are involved
...@@ -478,5 +491,4 @@ module Issuable ...@@ -478,5 +491,4 @@ module Issuable
end end
end end
Issuable.prepend_if_ee('EE::Issuable') # rubocop: disable Cop/InjectEnterpriseEditionModule Issuable.prepend_if_ee('EE::Issuable')
Issuable::ClassMethods.prepend_if_ee('EE::Issuable::ClassMethods')
...@@ -27,12 +27,16 @@ module Issues ...@@ -27,12 +27,16 @@ module Issues
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def csv_builder def csv_builder
@csv_builder ||= @csv_builder ||=
CsvBuilder.new(@issues.preload(:author, :assignees, :timelogs, :epic), header_to_value_hash) CsvBuilder.new(@issues.preload(associations_to_preload), header_to_value_hash)
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
private private
def associations_to_preload
%i(author assignees timelogs)
end
def header_to_value_hash def header_to_value_hash
{ {
'Issue ID' => 'iid', 'Issue ID' => 'iid',
...@@ -55,12 +59,7 @@ module Issues ...@@ -55,12 +59,7 @@ module Issues
'Labels' => -> (issue) { issue_labels(issue) }, 'Labels' => -> (issue) { issue_labels(issue) },
'Time Estimate' => ->(issue) { issue.time_estimate.to_s(:csv) }, 'Time Estimate' => ->(issue) { issue.time_estimate.to_s(:csv) },
'Time Spent' => -> (issue) { issue_time_spent(issue) } 'Time Spent' => -> (issue) { issue_time_spent(issue) }
}.tap do |hash| }
if project.group&.feature_available?(:epics)
hash['Epic ID'] = -> (issue) { issue.epic&.id }
hash['Epic Title'] = -> (issue) { issue.epic&.title }
end
end
end end
def issue_labels(issue) def issue_labels(issue)
...@@ -74,3 +73,5 @@ module Issues ...@@ -74,3 +73,5 @@ module Issues
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
end end
end end
Issues::ExportCsvService.prepend_if_ee('EE::Issues::ExportCsvService')
-# haml-lint:disable NoPlainNodes
%p{ style: 'font-size:18px; text-align:center; line-height:30px;' } %p{ style: 'font-size:18px; text-align:center; line-height:30px;' }
Your CSV export of #{ pluralize(@written_count, 'issue') } from project Your CSV export of #{ pluralize(@written_count, 'issue') } from project
%a{ href: project_url(@project), style: "color:#3777b0; text-decoration:none; display:block;" } %a{ href: project_url(@project), style: "color:#3777b0; text-decoration:none; display:block;" }
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
.btn-group .btn-group
- if show_export_button - if show_export_button
= render_if_exists 'projects/issues/export_csv/button' = render 'projects/issues/export_csv/button'
- if show_import_button - if show_import_button
= render 'projects/issues/import_csv/button' = render 'projects/issues/import_csv/button'
...@@ -23,7 +23,7 @@ ...@@ -23,7 +23,7 @@
id: "new_issue_link" id: "new_issue_link"
- if show_export_button - if show_export_button
= render_if_exists 'projects/issues/export_csv/modal' = render 'projects/issues/export_csv/modal'
- if show_import_button - if show_import_button
= render 'projects/issues/import_csv/modal' = render 'projects/issues/import_csv/modal'
-# haml-lint:disable NoPlainNodes
- if current_user - if current_user
.issues-export-modal.modal .issues-export-modal.modal
.modal-dialog .modal-dialog
...@@ -7,7 +8,8 @@ ...@@ -7,7 +8,8 @@
= _('Export issues') = _('Export issues')
.svg-content.import-export-svg-container .svg-content.import-export-svg-container
= image_tag 'illustrations/export-import.svg', alt: _('Import/Export illustration'), class: 'illustration' = image_tag 'illustrations/export-import.svg', alt: _('Import/Export illustration'), class: 'illustration'
%a.close{ href: '#', 'data-dismiss' => 'modal' } × %a.close{ href: '#', 'data-dismiss' => 'modal' }
= sprite_icon('close', size: 16, css_class: 'gl-icon')
.modal-body .modal-body
.modal-subheader .modal-subheader
= icon('check', { class: 'checkmark' }) = icon('check', { class: 'checkmark' })
...@@ -18,5 +20,3 @@ ...@@ -18,5 +20,3 @@
= _('The CSV export will be created in the background. Once finished, it will be sent to <strong>%{email}</strong> in an attachment.').html_safe % { email: @current_user.notification_email } = _('The CSV export will be created in the background. Once finished, it will be sent to <strong>%{email}</strong> in an attachment.').html_safe % { email: @current_user.notification_email }
.modal-footer .modal-footer
= link_to _('Export issues'), export_csv_project_issues_path(@project, request.query_parameters), method: :post, class: 'btn btn-success float-left', title: _('Export issues'), data: { track_label: "export_issues_csv", track_event: "click_button", track_value: "", qa_selector: "export_issues_button" } = link_to _('Export issues'), export_csv_project_issues_path(@project, request.query_parameters), method: :post, class: 'btn btn-success float-left', title: _('Export issues'), data: { track_label: "export_issues_csv", track_event: "click_button", track_value: "", qa_selector: "export_issues_button" }
- elsif show_promotions?
= render 'shared/promotions/promote_csv_export'
...@@ -1046,6 +1046,13 @@ ...@@ -1046,6 +1046,13 @@
:resource_boundary: :unknown :resource_boundary: :unknown
:weight: 1 :weight: 1
:idempotent: :idempotent:
- :name: export_csv
:feature_category: :issue_tracking
:has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 1
:idempotent:
- :name: file_hook - :name: file_hook
:feature_category: :integrations :feature_category: :integrations
:has_external_dependencies: :has_external_dependencies:
......
---
title: Move export issues feature to core
merge_request: 28703
author:
type: added
...@@ -18,5 +18,6 @@ resources :issues, concerns: :awardable, constraints: { id: /\d+/ } do ...@@ -18,5 +18,6 @@ resources :issues, concerns: :awardable, constraints: { id: /\d+/ } do
collection do collection do
post :bulk_update post :bulk_update
post :import_csv post :import_csv
post :export_csv
end end
end end
# Export Issues to CSV # Export Issues to CSV
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1126) in [GitLab Starter 9.0](https://about.gitlab.com/releases/2017/03/22/gitlab-9-0-released/#export-issues-ees-eep). > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1126) in [GitLab Starter 9.0](https://about.gitlab.com/releases/2017/03/22/gitlab-9-0-released/#export-issues-ees-eep).
> - Moved to GitLab Core in GitLab 12.10.
Issues can be exported as CSV from GitLab and are sent to your default notification email as an attachment. Issues can be exported as CSV from GitLab and are sent to your default notification email as an attachment.
......
...@@ -9,19 +9,13 @@ module EE ...@@ -9,19 +9,13 @@ module EE
prepended do prepended do
include DescriptionDiffActions include DescriptionDiffActions
# Specifying before_action :authenticate_user! multiple times
# doesn't work, since the last filter will override the previous
# ones.
alias_method :export_csv_authenticate_user!, :authenticate_user!
before_action :export_csv_authenticate_user!, only: [:export_csv]
before_action :check_service_desk_available!, only: [:service_desk] before_action :check_service_desk_available!, only: [:service_desk]
before_action :whitelist_query_limiting_ee, only: [:update] before_action :whitelist_query_limiting_ee, only: [:update]
end end
override :issue_except_actions override :issue_except_actions
def issue_except_actions def issue_except_actions
super + %i[export_csv service_desk] super + %i[service_desk]
end end
override :set_issuables_index_only_actions override :set_issuables_index_only_actions
...@@ -34,13 +28,6 @@ module EE ...@@ -34,13 +28,6 @@ module EE
@users.push(::User.support_bot) # rubocop:disable Gitlab/ModuleWithInstanceVariables @users.push(::User.support_bot) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end end
def export_csv
ExportCsvWorker.perform_async(current_user.id, project.id, finder_options.to_h)
index_path = project_issues_path(project)
redirect_to(index_path, notice: "Your CSV export has started. It will be emailed to #{current_user.notification_email} when complete.")
end
private private
def issue_params_attributes def issue_params_attributes
......
...@@ -10,7 +10,6 @@ module EE ...@@ -10,7 +10,6 @@ module EE
# See https://gitlab.com/gitlab-org/gitlab/issues/7846 # See https://gitlab.com/gitlab-org/gitlab/issues/7846
prepended do prepended do
include ::Emails::AdminNotification include ::Emails::AdminNotification
include ::Emails::CsvExport
include ::Emails::ServiceDesk include ::Emails::ServiceDesk
include ::Emails::Epics include ::Emails::Epics
include ::Emails::Reviews include ::Emails::Reviews
......
...@@ -12,10 +12,6 @@ module EE ...@@ -12,10 +12,6 @@ module EE
::Notify.add_merge_request_approver_email(user.id, merge_request.id, user.id).message ::Notify.add_merge_request_approver_email(user.id, merge_request.id, user.id).message
end end
def issues_csv_email
::Notify.issues_csv_email(user, project, '1997,Ford,E350', { truncated: false, rows_expected: 3, rows_written: 3 }).message
end
def approved_merge_request_email def approved_merge_request_email
::Notify.approved_merge_request_email(user.id, merge_request.id, approver.id).message ::Notify.approved_merge_request_email(user.id, merge_request.id, approver.id).message
end end
......
# frozen_string_literal: true
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)
filename = "#{project.full_path.parameterize}_issues_#{Date.today.iso8601}.csv"
attachments[filename] = { content: csv_data, mime_type: 'text/csv' }
mail(to: user.notification_email_for(@project.group), subject: subject("Exported issues")) do |format|
format.html { render layout: 'mailer' }
format.text { render layout: 'mailer' }
end
end
end
end
...@@ -5,19 +5,6 @@ module EE ...@@ -5,19 +5,6 @@ module EE
extend ActiveSupport::Concern extend ActiveSupport::Concern
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
class_methods do
def labels_hash
issue_labels = Hash.new { |h, k| h[k] = [] }
relation = unscoped.where(id: self.select(:id)).eager_load(:labels)
relation.pluck(:id, 'labels.title').each do |issue_id, label|
issue_labels[issue_id] << label if label.present?
end
issue_labels
end
end
def supports_epic? def supports_epic?
is_a?(Issue) && project.group is_a?(Issue) && project.group
end end
......
# frozen_string_literal: true
module EE
module Issues
module ExportCsvService
extend ::Gitlab::Utils::Override
include ::Gitlab::Utils::StrongMemoize
override :associations_to_preload
def associations_to_preload
return super unless epics_available?
super << :epic
end
override :header_to_value_hash
def header_to_value_hash
return super unless epics_available?
super.merge({
'Epic ID' => -> (issue) { issue.epic&.id },
'Epic Title' => -> (issue) { issue.epic&.title }
})
end
def epics_available?
strong_memoize(:epics_available) do
project.group&.feature_available?(:epics)
end
end
end
end
end
.issues-export-modal.modal.promotion-modal
.modal-dialog
.modal-content
.modal-header
%a.close{ href: '#', 'data-dismiss' => 'modal' } ×
.modal-body.center
%div
.svg-container
= custom_icon('icon_export_issues')
.user-callout-copy
%h4
Export issues with GitLab Enterprise Edition.
%p
Export Issues to CSV enables you and your team to export all the data collected from issues into a comma-separated values (CSV) file, which stores tabular data in plain text.
= link_to 'Read more', help_page_path('user/project/issues/csv_export.html'), target: '_blank'
= render 'shared/promotions/promotion_link_project'
...@@ -521,13 +521,6 @@ ...@@ -521,13 +521,6 @@
:resource_boundary: :unknown :resource_boundary: :unknown
:weight: 1 :weight: 1
:idempotent: :idempotent:
- :name: export_csv
:feature_category: :issue_tracking
:has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 1
:idempotent:
- :name: ldap_group_sync - :name: ldap_group_sync
:feature_category: :authentication_and_authorization :feature_category: :authentication_and_authorization
:has_external_dependencies: true :has_external_dependencies: true
......
...@@ -8,7 +8,6 @@ resources :issues, only: [], constraints: { id: /\d+/ } do ...@@ -8,7 +8,6 @@ resources :issues, only: [], constraints: { id: /\d+/ } do
end end
collection do collection do
post :export_csv
get :service_desk get :service_desk
end end
......
...@@ -7,65 +7,6 @@ describe Projects::IssuesController do ...@@ -7,65 +7,6 @@ describe Projects::IssuesController do
let(:project) { create(:project_empty_repo, :public, namespace: namespace) } let(:project) { create(:project_empty_repo, :public, namespace: namespace) }
let(:user) { create(:user) } let(:user) { create(:user) }
describe 'POST export_csv' do
let(:viewer) { user }
let(:issue) { create(:issue, project: project) }
before do
project.add_developer(user)
sign_in(viewer) if viewer
allow(License).to receive(:feature_available?).and_call_original
end
def request_csv
post :export_csv, params: { namespace_id: project.namespace.to_param, project_id: project.to_param }
end
context 'globally licensed' do
it 'allows CSV export' do
expect(ExportCsvWorker).to receive(:perform_async).with(viewer.id, project.id, anything)
request_csv
expect(response).to redirect_to(project_issues_path(project))
expect(response.flash[:notice]).to match(/\AYour CSV export has started/i)
end
context 'anonymous user' do
let(:project) { create(:project_empty_repo, :public, namespace: namespace) }
let(:viewer) { nil }
it 'redirects to the sign in page' do
request_csv
expect(ExportCsvWorker).not_to receive(:perform_async)
expect(response).to redirect_to(new_user_session_path)
end
end
end
context 'licensed by namespace' do
let(:namespace) { create(:group, :private) }
let!(:gitlab_subscriptions) { create(:gitlab_subscription, :bronze, namespace: namespace) }
let(:project) { create(:project, namespace: namespace) }
before do
stub_application_setting(check_namespace_plan: true)
end
it 'allows CSV export' do
expect(ExportCsvWorker).to receive(:perform_async).with(viewer.id, project.id, anything)
request_csv
expect(response).to redirect_to(project_issues_path(project))
expect(response.flash[:notice]).to match(/\AYour CSV export has started/i)
end
end
end
describe 'licensed features' do describe 'licensed features' do
let(:project) { create(:project, group: namespace) } let(:project) { create(:project, group: namespace) }
let(:user) { create(:user) } let(:user) { create(:user) }
......
# frozen_string_literal: true
require 'spec_helper'
require 'email_spec'
describe Emails::CsvExport do
include EmailSpec::Matchers
include_context 'gitlab email notification'
it 'adds email methods to Notify' do
subject.instance_methods.each do |email_method|
expect(Notify).to be_respond_to(email_method)
end
end
describe 'csv export email' do
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'
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')
end
it 'mentions number of issues and project name' 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(:export_status) { { truncated: true, rows_expected: 12, rows_written: 10 } }
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'
end
end
end
end
...@@ -22,40 +22,6 @@ describe EE::Issuable do ...@@ -22,40 +22,6 @@ describe EE::Issuable do
end end
end end
describe '.labels_hash' do
let(:feature_label) { create(:label, title: 'Feature') }
let(:second_label) { create(:label, title: 'Second Label') }
let!(:issues) { create_list(:labeled_issue, 3, labels: [feature_label, second_label]) }
let(:issue_id) { issues.first.id }
it 'maps issue ids to labels titles' do
expect(Issue.labels_hash[issue_id]).to include('Feature')
end
it 'works on relations filtered by multiple labels' do
relation = Issue.with_label(['Feature', 'Second Label'])
expect(relation.labels_hash[issue_id]).to include('Feature', 'Second Label')
end
# This tests the workaround for the lack of a NOT NULL constraint in
# label_links.label_id:
# https://gitlab.com/gitlab-org/gitlab/issues/197307
context 'with a NULL label ID in the link' do
let(:issue) { create(:labeled_issue, labels: [feature_label, second_label]) }
before do
label_link = issue.label_links.find_by(label_id: second_label.id)
label_link.label_id = nil
label_link.save(validate: false)
end
it 'filters out bad labels' do
expect(Issue.where(id: issue.id).labels_hash[issue.id]).to match_array(['Feature'])
end
end
end
describe '#matches_cross_reference_regex?' do describe '#matches_cross_reference_regex?' do
context "epic description with long path string" do context "epic description with long path string" do
let(:mentionable) { build(:epic, description: "/a" * 50000) } let(:mentionable) { build(:epic, description: "/a" * 50000) }
......
...@@ -7,146 +7,14 @@ describe Issues::ExportCsvService do ...@@ -7,146 +7,14 @@ describe Issues::ExportCsvService do
let(:group) { create(:group) } let(:group) { create(:group) }
let(:project) { create(:project, :public, group: group) } let(:project) { create(:project, :public, group: group) }
let!(:issue) { create(:issue, project: project, author: user) } let!(:issue) { create(:issue, project: project, author: user) }
let!(:bad_issue) { create(:issue, project: project, author: user) } let!(:issue2) { create(:issue, project: project, author: user) }
let(:subject) { described_class.new(Issue.all, project) } let(:subject) { described_class.new(Issue.all, project) }
it 'renders csv to string' do
expect(subject.csv_data).to be_a String
end
describe '#email' do
it 'emails csv' do
expect { subject.email(user) }.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)
end
end
def csv def csv
CSV.parse(subject.csv_data, headers: true) CSV.parse(subject.csv_data, headers: true)
end end
context 'includes' do context 'includes' do
let(:milestone) { create(:milestone, title: 'v1.0', project: project) }
let(:idea_label) { create(:label, project: project, title: 'Idea') }
let(:feature_label) { create(:label, project: project, title: 'Feature') }
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.update!(milestone: milestone,
assignees: [user],
description: 'Issue with details',
state: :opened,
due_date: DateTime.new(2014, 3, 2),
created_at: DateTime.new(2015, 4, 3, 2, 1, 0),
updated_at: DateTime.new(2016, 5, 4, 3, 2, 1),
closed_at: DateTime.new(2017, 6, 5, 4, 3, 2),
weight: 4,
discussion_locked: true,
labels: [feature_label, idea_label],
time_estimate: 72000)
end
it 'includes the columns required for import' do
expect(csv.headers).to include('Title', 'Description')
end
specify 'iid' do
expect(csv[0]['Issue ID']).to eq issue.iid.to_s
end
specify 'url' do
expect(csv[0]['URL']).to match(/http.*#{project.full_path}.*#{issue.iid}/)
end
specify 'title' do
expect(csv[0]['Title']).to eq issue.title
end
specify 'state' do
expect(csv[0]['State']).to eq 'Open'
end
specify 'description' do
expect(csv[0]['Description']).to eq issue.description
expect(csv[1]['Description']).to eq nil
end
specify 'author name' do
expect(csv[0]['Author']).to eq issue.author_name
end
specify 'author username' do
expect(csv[0]['Author Username']).to eq issue.author.username
end
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
expect(csv[0]['Confidential']).to eq 'No'
end
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
expect(csv[0]['Created At (UTC)']).to eq '2015-04-03 02:01:00'
end
specify 'updated_at' do
expect(csv[0]['Updated At (UTC)']).to eq '2016-05-04 03:02:01'
end
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
expect(csv[0]['Locked']).to eq 'Yes'
end
specify 'weight' do
expect(csv[0]['Weight']).to eq '4'
end
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'
end
context 'handling epics' do context 'handling epics' do
let(:epic) { create(:epic, group: group) } let(:epic) { create(:epic, group: group) }
...@@ -176,25 +44,5 @@ describe Issues::ExportCsvService do ...@@ -176,25 +44,5 @@ describe Issues::ExportCsvService do
end end
end end
end end
context 'with issues filtered by labels and project' do
let(:subject) do
described_class.new(
IssuesFinder.new(user,
project_id: project.id,
label_name: %w(Idea Feature)).execute, project)
end
it 'returns only filtered objects' do
expect(csv.count).to eq(1)
expect(csv[0]['Issue ID']).to eq issue.iid.to_s
end
end
end
context 'with minimal details' do
it 'renders labels as nil' do
expect(csv[0]['Labels']).to eq nil
end
end end
end end
...@@ -17,11 +17,11 @@ module QA ...@@ -17,11 +17,11 @@ module QA
element :issuable_weight element :issuable_weight
end end
view 'ee/app/views/projects/issues/export_csv/_button.html.haml' do view 'app/views/projects/issues/export_csv/_button.html.haml' do
element :export_as_csv_button element :export_as_csv_button
end end
view 'ee/app/views/projects/issues/export_csv/_modal.html.haml' do view 'app/views/projects/issues/export_csv/_modal.html.haml' do
element :export_issues_button element :export_issues_button
element :export_issues_modal element :export_issues_modal
end end
......
...@@ -1427,6 +1427,45 @@ describe Projects::IssuesController do ...@@ -1427,6 +1427,45 @@ describe Projects::IssuesController do
end end
end end
describe 'POST export_csv' do
let(:viewer) { user }
let(:issue) { create(:issue, project: project) }
before do
project.add_developer(user)
end
def request_csv
post :export_csv, params: { namespace_id: project.namespace.to_param, project_id: project.to_param }
end
context 'when logged in' do
before do
sign_in(viewer)
end
it 'allows CSV export' do
expect(ExportCsvWorker).to receive(:perform_async).with(viewer.id, project.id, anything)
request_csv
expect(response).to redirect_to(project_issues_path(project))
expect(response.flash[:notice]).to match(/\AYour CSV export has started/i)
end
end
context 'when not logged in' do
let(:project) { create(:project_empty_repo, :public) }
it 'redirects to the sign in page' do
request_csv
expect(ExportCsvWorker).not_to receive(:perform_async)
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe 'GET #discussions' do describe 'GET #discussions' do
let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) } let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) }
......
...@@ -6,6 +6,12 @@ require 'email_spec' ...@@ -6,6 +6,12 @@ require 'email_spec'
describe Emails::Issues do describe Emails::Issues do
include EmailSpec::Matchers include EmailSpec::Matchers
it 'adds email methods to Notify' do
subject.instance_methods.each do |email_method|
expect(Notify).to be_respond_to(email_method)
end
end
describe "#import_issues_csv_email" do describe "#import_issues_csv_email" do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project) } let(:project) { create(:project) }
...@@ -39,4 +45,47 @@ describe Emails::Issues do ...@@ -39,4 +45,47 @@ describe Emails::Issues do
it_behaves_like 'appearance header and footer not enabled' it_behaves_like 'appearance header and footer not enabled'
end end
end end
describe '#issues_csv_email' do
let(:user) { create(:user) }
let(:empty_project) { create(:project, path: 'myproject') }
let(:export_status) { { truncated: false, rows_expected: 3, rows_written: 3 } }
let(:attachment) { subject.attachments.first }
subject { Notify.issues_csv_email(user, empty_project, "dummy content", export_status) }
include_context 'gitlab email notification'
it 'attachment has csv mime type' do
expect(attachment.mime_type).to eq 'text/csv'
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')
end
it 'mentions number of issues and project name' 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(:export_status) { { truncated: true, rows_expected: 12, rows_written: 10 } }
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'
end
end
end
end end
...@@ -496,6 +496,40 @@ describe Issuable do ...@@ -496,6 +496,40 @@ describe Issuable do
end end
end end
describe '.labels_hash' do
let(:feature_label) { create(:label, title: 'Feature') }
let(:second_label) { create(:label, title: 'Second Label') }
let!(:issues) { create_list(:labeled_issue, 3, labels: [feature_label, second_label]) }
let(:issue_id) { issues.first.id }
it 'maps issue ids to labels titles' do
expect(Issue.labels_hash[issue_id]).to include('Feature')
end
it 'works on relations filtered by multiple labels' do
relation = Issue.with_label(['Feature', 'Second Label'])
expect(relation.labels_hash[issue_id]).to include('Feature', 'Second Label')
end
# This tests the workaround for the lack of a NOT NULL constraint in
# label_links.label_id:
# https://gitlab.com/gitlab-org/gitlab/issues/197307
context 'with a NULL label ID in the link' do
let(:issue) { create(:labeled_issue, labels: [feature_label, second_label]) }
before do
label_link = issue.label_links.find_by(label_id: second_label.id)
label_link.label_id = nil
label_link.save(validate: false)
end
it 'filters out bad labels' do
expect(Issue.where(id: issue.id).labels_hash[issue.id]).to match_array(['Feature'])
end
end
end
describe '#user_notes_count' do describe '#user_notes_count' do
let(:project) { create(:project) } let(:project) { create(:project) }
let(:issue1) { create(:issue, project: project) } let(:issue1) { create(:issue, project: project) }
......
# frozen_string_literal: true
require 'spec_helper'
describe Issues::ExportCsvService do
let_it_be(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, :public, group: group) }
let!(:issue) { create(:issue, project: project, author: user) }
let!(:bad_issue) { create(:issue, project: project, author: user) }
let(:subject) { described_class.new(Issue.all, project) }
it 'renders csv to string' do
expect(subject.csv_data).to be_a String
end
describe '#email' do
it 'emails csv' do
expect { subject.email(user) }.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)
end
end
def csv
CSV.parse(subject.csv_data, headers: true)
end
context 'includes' do
let(:milestone) { create(:milestone, title: 'v1.0', project: project) }
let(:idea_label) { create(:label, project: project, title: 'Idea') }
let(:feature_label) { create(:label, project: project, title: 'Feature') }
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.update!(milestone: milestone,
assignees: [user],
description: 'Issue with details',
state: :opened,
due_date: DateTime.new(2014, 3, 2),
created_at: DateTime.new(2015, 4, 3, 2, 1, 0),
updated_at: DateTime.new(2016, 5, 4, 3, 2, 1),
closed_at: DateTime.new(2017, 6, 5, 4, 3, 2),
weight: 4,
discussion_locked: true,
labels: [feature_label, idea_label],
time_estimate: 72000)
end
it 'includes the columns required for import' do
expect(csv.headers).to include('Title', 'Description')
end
specify 'iid' do
expect(csv[0]['Issue ID']).to eq issue.iid.to_s
end
specify 'url' do
expect(csv[0]['URL']).to match(/http.*#{project.full_path}.*#{issue.iid}/)
end
specify 'title' do
expect(csv[0]['Title']).to eq issue.title
end
specify 'state' do
expect(csv[0]['State']).to eq 'Open'
end
specify 'description' do
expect(csv[0]['Description']).to eq issue.description
expect(csv[1]['Description']).to eq nil
end
specify 'author name' do
expect(csv[0]['Author']).to eq issue.author_name
end
specify 'author username' do
expect(csv[0]['Author Username']).to eq issue.author.username
end
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
expect(csv[0]['Confidential']).to eq 'No'
end
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
expect(csv[0]['Created At (UTC)']).to eq '2015-04-03 02:01:00'
end
specify 'updated_at' do
expect(csv[0]['Updated At (UTC)']).to eq '2016-05-04 03:02:01'
end
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
expect(csv[0]['Locked']).to eq 'Yes'
end
specify 'weight' do
expect(csv[0]['Weight']).to eq '4'
end
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'
end
context 'with issues filtered by labels and project' do
let(:subject) do
described_class.new(
IssuesFinder.new(user,
project_id: project.id,
label_name: %w(Idea Feature)).execute, project)
end
it 'returns only filtered objects' do
expect(csv.count).to eq(1)
expect(csv[0]['Issue ID']).to eq issue.iid.to_s
end
end
end
context 'with minimal details' do
it 'renders labels as nil' do
expect(csv[0]['Labels']).to eq nil
end
end
end
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