Add CSV project export functionality

pull/9782/head
Oliver Günther 3 years ago
parent d86aded425
commit eb4d7a19d9
No known key found for this signature in database
GPG Key ID: A3A8BDAD7C0C552C
  1. 43
      app/controllers/projects_controller.rb
  2. 59
      app/controllers/work_packages_controller.rb
  3. 4
      app/models/exports/register.rb
  4. 10
      app/models/projects/export.rb
  5. 35
      app/models/projects/exports.rb
  6. 2
      app/services/work_packages/exports/schedule_service.rb
  7. 16
      app/views/projects/_project_export_modal.html.erb
  8. 50
      app/views/projects/index.html.erb
  9. 16
      app/workers/exports/cleanup_outdated_job.rb
  10. 117
      app/workers/exports/export_job.rb
  11. 13
      app/workers/projects/export_job.rb
  12. 27
      app/workers/work_packages/export_job.rb
  13. 116
      app/workers/work_packages/exports/export_job.rb
  14. 15
      config/initializers/export_formats.rb
  15. 8
      modules/bim/lib/open_project/bim/engine.rb
  16. 6
      modules/xls_export/lib/open_project/xls_export/engine.rb
  17. 85
      spec/features/projects/export_spec.rb
  18. 50
      spec/models/projects/exporter/csv_integration_spec.rb
  19. 7
      spec/support/pages/projects/index.rb

@ -56,7 +56,23 @@ class ProjectsController < ApplicationController
@projects = load_projects query
@orders = set_sorting query
render layout: 'no_menu'
respond_to do |format|
format.html do
render layout: 'no_menu'
end
format.any(*supported_export_formats) do
export_list(request.format.symbol)
end
format.atom do
atom_list
end
end
end
current_menu_item :index do
:list_projects
end
def new
@ -70,8 +86,8 @@ class ProjectsController < ApplicationController
# Delete @project
def destroy
service_call = ::Projects::ScheduleDeletionService
.new(user: current_user, model: @project)
.call
.new(user: current_user, model: @project)
.call
if service_call.success?
flash[:notice] = I18n.t('projects.delete.scheduled')
@ -135,7 +151,20 @@ class ProjectsController < ApplicationController
@query
end
protected
def export_list(mime_type)
job = Projects::ExportJob.perform_later(
export: Projects::Export.create,
user: current_user,
mime_type: mime_type,
query: Marshal.dump(@query)
)
if request.headers['Accept']&.include?('application/json')
render json: { job_id: job.job_id }
else
redirect_to job_status_path(job.job_id)
end
end
def load_projects(query)
query
@ -149,4 +178,10 @@ class ProjectsController < ApplicationController
def set_sorting(query)
query.orders.select(&:valid?).map { |o| [o.attribute.to_s, o.direction.to_s] }
end
def supported_export_formats
::Exports::Register.list_formats(Project).map(&:to_s)
end
helper_method :supported_export_formats
end

@ -87,9 +87,9 @@ class WorkPackagesController < ApplicationController
def export_list(mime_type)
job_id = WorkPackages::Exports::ScheduleService
.new(user: current_user)
.call(query: @query, mime_type: mime_type, params: params)
.result
.new(user: current_user)
.call(query: @query, mime_type: mime_type, params: params)
.result
if request.headers['Accept']&.include?('application/json')
render json: { job_id: job_id }
@ -99,9 +99,17 @@ class WorkPackagesController < ApplicationController
end
def export_single(mime_type)
exporter = WorkPackage::Exporter.for_single(mime_type)
exporter.single(work_package, params) do |export|
render_export_response export, fallback_path: work_package_path(work_package)
exporter = Exports::Register
.single_exporter(WorkPackage, mime_type)
.new(work_package, params)
exporter.export! do |export|
send_data(export.content,
type: export.mime_type,
filename: export.title)
rescue ::Exports::ExportError => e
flash[:error] = e.message
redirect_back(fallback_location: work_package_path(work_package))
end
end
@ -120,24 +128,13 @@ class WorkPackagesController < ApplicationController
private
def render_export_response(export, fallback_path:)
if export.error?
flash[:error] = export.message
redirect_back(fallback_location: fallback_path)
else
send_data(export.content,
type: export.mime_type,
filename: export.title)
end
end
def authorize_on_work_package
deny_access(not_found: true) unless work_package
end
def protect_from_unauthorized_export
if (supported_list_formats + %w[atom]).include?(params[:format]) &&
!User.current.allowed_to?(:export_work_packages, @project, global: @project.nil?)
!User.current.allowed_to?(:export_work_packages, @project, global: @project.nil?)
deny_access
false
@ -183,19 +180,19 @@ class WorkPackagesController < ApplicationController
def journals
@journals ||= begin
order =
if current_user.wants_comments_in_reverse_order?
Journal.arel_table['created_at'].desc
else
Journal.arel_table['created_at'].asc
end
work_package
.journals
.changing
.includes(:user)
.order(order).to_a
end
order =
if current_user.wants_comments_in_reverse_order?
Journal.arel_table['created_at'].desc
else
Journal.arel_table['created_at'].asc
end
work_package
.journals
.changing
.includes(:user)
.order(order).to_a
end
end
def index_redirect_path

@ -33,6 +33,10 @@ module Exports
class << self
attr_reader :lists, :singles, :formatters
def register(&block)
instance_exec(&block)
end
def list(model, exporter)
@lists ||= Hash.new do |hash, model_key|
hash[model_key] = []

@ -0,0 +1,10 @@
class Projects::Export < Export
acts_as_attachable view_permission: :view_project,
add_permission: :view_project,
delete_permission: :view_project,
only_user_allowed: true
def ready?
attachments.any?
end
end

@ -1,35 +0,0 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module Projects::Exports
register = ::Exports::Register
register.list(Project, Projects::Exports::CSV)
end

@ -45,7 +45,7 @@ class WorkPackages::Exports::ScheduleService
private
def schedule_export(export_storage, mime_type, params, query)
WorkPackages::Exports::ExportJob.perform_later(export: export_storage,
WorkPackages::ExportJob.perform_later(export: export_storage,
user: user,
mime_type: mime_type,
options: params,

@ -43,13 +43,15 @@ See COPYRIGHT and LICENSE files for more details.
<div class="op-modal--body">
<ul class="op-export-options">
<li class="op-export-options--option">
<%= link_to url_for(action: 'index', format: 'csv'),
class: 'op-export-options--option-link' do %>
<%= op_icon('icon-big icon-export-csv') %>
<span class="op-export-options--option-label"><%= t('export.format.csv') %></span>
<% end %>
</li>
<% supported_export_formats.each do |key| %>
<li class="op-export-options--option">
<%= link_to url_for(action: 'index', format: key),
class: 'op-export-options--option-link' do %>
<%= op_icon("icon-big icon-export-#{key}") %>
<span class="op-export-options--option-label"><%= t("export.format.#{key}") %></span>
<% end %>
</li>
<% end %>
</ul>
</div>
</div>

@ -65,36 +65,42 @@ See COPYRIGHT and LICENSE files for more details.
<% end %>
</li>
<% if current_user.admin? %>
<li class="toolbar-item drop-down">
<a href="#" aria-haspopup="true" title="<%= t(:label_more) %>" class="button">
<%= op_icon('button--icon icon-show-more') %>
</a>
<ul style="display:none;" class="menu-drop-down-container">
<li>
<%= link_to t('button_configure'), admin_settings_projects_path, target: '_blank', class: 'icon-context icon-settings' %>
</li>
<section data-augmented-model-wrapper
data-modal-class-name="project-export---modal">
<li class="toolbar-item drop-down">
<a
href="#"
aria-haspopup="true"
title="<%= t(:label_more) %>"
class="button"
data-qa-selector="project-more-dropdown-menu"
>
<%= op_icon('button--icon icon-show-more') %>
</a>
<ul style="display:none;" class="menu-drop-down-container">
<li>
<%= link_to I18n.t('js.label_export'),
'',
title: I18n.t('js.label_export'),
class: 'modal-delivery-element--activation-link icon-context icon-export' %>
<%= link_to t('button_configure'), admin_settings_projects_path, target: '_blank', class: 'icon-context icon-settings' %>
</li>
<%= render partial: 'project_export_modal' %>
</section>
</ul>
</li>
<section data-augmented-model-wrapper
data-modal-class-name="project-export---modal">
<li>
<%= link_to I18n.t('js.label_export'),
'',
title: I18n.t('js.label_export'),
class: 'modal-delivery-element--activation-link icon-context icon-export' %>
</li>
<%= render partial: 'project_export_modal' %>
</section>
</ul>
</li>
<% end %>
<% end %>
<%= render partial: 'projects/filters/form', locals: { query: @query } %>
<%= rails_cell Projects::TableCell,
@projects,
current_user: current_user,
orders: @orders,
params: params %>
@projects,
current_user: current_user,
orders: @orders,
params: params %>
<% if User.current.admin? %>
<p class="information-section">

@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class WorkPackages::Exports::CleanupOutdatedJob < ApplicationJob
class Exports::CleanupOutdatedJob < ApplicationJob
queue_with_priority :low
def self.perform_after_grace
@ -36,18 +36,8 @@ class WorkPackages::Exports::CleanupOutdatedJob < ApplicationJob
end
def perform
WorkPackages::Export
.where(too_old)
Export
.where('created_at <= ?', Time.current - OpenProject::Configuration.attachments_grace_period.minutes)
.destroy_all
end
private
def too_old
table = WorkPackages::Export.arel_table
table[:created_at]
.lteq(Time.now - OpenProject::Configuration.attachments_grace_period.minutes)
.to_sql
end
end

@ -0,0 +1,117 @@
require 'active_storage/filename'
module Exports
class ExportJob < ::ApplicationJob
def perform(export:, user:, mime_type:, query:, **options)
self.export = export
self.current_user = user
self.mime_type = mime_type
self.query = query
self.options = options
User.execute_as(user) do
prepare!
export!
schedule_cleanup
rescue StandardError => e
Rails.logger.error "Failed to run export job for #{user}: #{e.message}"
raise e
end
end
def status_reference
arguments.first[:export]
end
def updates_own_status?
true
end
protected
class_attribute :model
attr_accessor :export, :current_user, :mime_type, :query, :options
def prepare!
raise NotImplementedError
end
def export!
exporter_instance.export! do |result|
handle_export_result(export, result)
end
end
def exporter_instance
::Exports::Register
.list_exporter(model, mime_type)
.new(query, options)
end
def handle_export_result(export, result)
case result.content
when File
store_attachment(export, result.content)
when Tempfile
store_from_tempfile(export, result)
else
store_from_string(export, result)
end
end
def store_from_tempfile(export, export_result)
renamed_file_path = target_file_name(export_result)
File.rename(export_result.content.path, renamed_file_path)
file = File.open(renamed_file_path)
store_attachment(export, file)
file.close
end
##
# Create a target file name, replacing any invalid characters
def target_file_name(export_result)
target_name = ActiveStorage::Filename.new(export_result.title).sanitized
File.join(File.dirname(export_result.content.path), target_name)
end
def schedule_cleanup
CleanupOutdatedJob.perform_after_grace
end
def store_from_string(export, export_result)
with_tempfile(export_result.title, export_result.content) do |file|
store_attachment(export, file)
end
end
def with_tempfile(title, content)
name_parts = [title[0..title.rindex('.') - 1], title[title.rindex('.')..-1]]
Tempfile.create(name_parts, encoding: content.encoding) do |file|
file.write content
yield file
end
end
def store_attachment(container, file)
call = Attachments::CreateService
.bypass_whitelist(user: User.current)
.call(container: container, file: file, filename: File.basename(file), description: '')
call.on_success do
download_url = ::API::V3::Utilities::PathHelper::ApiV3Path.attachment_content(call.result.id)
upsert_status status: :success,
message: I18n.t('export.succeeded'),
payload: download_payload(download_url)
end
call.on_failure do
upsert_status status: :failure,
message: I18n.t('export.failed', message: call.message)
end
end
end
end

@ -0,0 +1,13 @@
require 'active_storage/filename'
module Projects
class ExportJob < ::Exports::ExportJob
self.model = Project
private
def prepare!
self.query = Marshal.load(query)
end
end
end

@ -0,0 +1,27 @@
require 'active_storage/filename'
module WorkPackages
class ExportJob < ::Exports::ExportJob
self.model = WorkPackage
def title
I18n.t('export.your_work_packages_export')
end
private
def prepare!
self.query = set_query_props(query || Query.new, options[:query_attributes])
end
def set_query_props(query, query_attributes)
filters = query_attributes.delete('filters')
filters = Queries::WorkPackages::FilterSerializer.load(filters)
query.tap do |q|
q.attributes = query_attributes
q.filters = filters
end
end
end
end

@ -1,116 +0,0 @@
require 'active_storage/filename'
module WorkPackages
module Exports
class ExportJob < ::ApplicationJob
def perform(export:, user:, mime_type:, query:, query_attributes:, options:)
User.execute_as user do
query = set_query_props(query || Query.new, query_attributes)
export_work_packages(export, mime_type, query, options)
schedule_cleanup
end
end
def status_reference
arguments.first[:export]
end
def updates_own_status?
true
end
protected
def title
I18n.t('export.your_work_packages_export')
end
private
def export_work_packages(export, mime_type, query, options)
::Exports::Register
.list_exporter(WorkPackage, mime_type)
.new(query, options)
.export! do |result|
handle_export_result(export, result)
end
end
def handle_export_result(export, result)
case result.content
when File
store_attachment(export, result.content)
when Tempfile
store_from_tempfile(export, result)
else
store_from_string(export, result)
end
end
def store_from_tempfile(export, export_result)
renamed_file_path = target_file_name(export_result)
File.rename(export_result.content.path, renamed_file_path)
file = File.open(renamed_file_path)
store_attachment(export, file)
file.close
end
##
# Create a target file name, replacing any invalid characters
def target_file_name(export_result)
target_name = ActiveStorage::Filename.new(export_result.title).sanitized
File.join(File.dirname(export_result.content.path), target_name)
end
def schedule_cleanup
::WorkPackages::Exports::CleanupOutdatedJob.perform_after_grace
end
def set_query_props(query, query_attributes)
filters = query_attributes.delete('filters')
filters = Queries::WorkPackages::FilterSerializer.load(filters)
query.tap do |q|
q.attributes = query_attributes
q.filters = filters
end
end
def store_from_string(export, export_result)
with_tempfile(export_result.title, export_result.content) do |file|
store_attachment(export, file)
end
end
def with_tempfile(title, content)
name_parts = [title[0..title.rindex('.') - 1], title[title.rindex('.')..-1]]
Tempfile.create(name_parts, encoding: content.encoding) do |file|
file.write content
yield file
end
end
def store_attachment(container, file)
call = Attachments::CreateService
.bypass_whitelist(user: User.current)
.call(container: container, file: file, filename: File.basename(file), description: '')
call.on_success do
download_url = ::API::V3::Utilities::PathHelper::ApiV3Path.attachment_content(call.result.id)
upsert_status status: :success,
message: I18n.t('export.succeeded'),
payload: download_payload(download_url)
end
call.on_failure do
upsert_status status: :failure,
message: I18n.t('export.failed', message: call.message)
end
end
end
end
end

@ -0,0 +1,15 @@
OpenProject::Application.configure do |application|
application.config.to_prepare do
::Exports::Register.register do
list WorkPackage, WorkPackage::Exports::CSV
list WorkPackage, ::WorkPackage::PDFExport::WorkPackageListToPdf
single WorkPackage, ::WorkPackage::PDFExport::WorkPackageToPdf
formatter WorkPackage, WorkPackage::Exports::Formatters::Costs
formatter WorkPackage, WorkPackage::Exports::Formatters::EstimatedHours
list Project, Projects::Exports::CSV
end
end
end

@ -209,10 +209,10 @@ module OpenProject::Bim
end
config.to_prepare do
register = ::Exports::Register
register.list(WorkPackage, OpenProject::Bim::BcfXml::Exporter)
register.formatter(WorkPackage, OpenProject::Bim::WorkPackage::Exporter::Formatters::BcfThumbnail)
::Exports::Register.register do
list ::WorkPackage, OpenProject::Bim::BcfXml::Exporter
formatter ::WorkPackage, OpenProject::Bim::WorkPackage::Exporter::Formatters::BcfThumbnail
end
::Queries::Register.filter ::Query, ::Bim::Queries::WorkPackages::Filter::BcfIssueAssociatedFilter
::Queries::Register.column ::Query, ::Bim::Queries::WorkPackages::Columns::BcfThumbnailColumn

@ -35,9 +35,9 @@ module OpenProject::XlsExport
class_inflection_override('xls' => 'XLS')
config.to_prepare do
register = ::Exports::Register
register.list(WorkPackage, XlsExport::WorkPackage::Exporter::XLS)
::Exports::Register.register do
list(::WorkPackage, XlsExport::WorkPackage::Exporter::XLS)
end
end
end
end

@ -0,0 +1,85 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require 'spec_helper'
require 'features/work_packages/work_packages_page'
describe 'project export', type: :feature, js: true do
shared_let(:project1) { FactoryBot.create :project }
shared_let(:project2) { FactoryBot.create :project }
shared_let(:admin) { FactoryBot.create :admin }
let(:index_page) { ::Pages::Projects::Index.new }
let(:current_user) { admin }
before do
@download_list = DownloadList.new
login_as(current_user)
index_page.visit!
end
after do
DownloadList.clear
end
subject { @download_list.refresh_from(page).latest_downloaded_content }
def export!(expect_success = true)
index_page.click_more_menu_item 'Export'
click_on export_type
# Expect to get a response regarding queuing
expect(page).to have_content I18n.t('js.job_status.generic_messages.in_queue'),
wait: 10
begin
perform_enqueued_jobs
rescue StandardError
# nothing
end
if expect_success
expect(page).to have_text("The export has completed successfully")
end
end
describe 'CSV export' do
let(:export_type) { 'CSV' }
it 'exports the visible projects' do
expect(page).to have_selector('td.name', text: project1.name)
export!
expect(subject).to have_text(project1.name)
end
end
end

@ -28,14 +28,50 @@
# See COPYRIGHT and LICENSE files for more details.
#++
module WorkPackage::Exports
register = ::Exports::Register
require 'spec_helper'
register.list(WorkPackage, WorkPackage::Exports::CSV)
register.list(WorkPackage, ::WorkPackage::PDFExport::WorkPackageListToPdf)
describe Projects::Exports::CSV, 'integration', type: :model do
before do
login_as current_user
end
register.single(WorkPackage, ::WorkPackage::PDFExport::WorkPackageToPdf)
let(:project) { FactoryBot.create(:project) }
register.formatter(WorkPackage, WorkPackage::Exports::Formatters::Costs)
register.formatter(WorkPackage, WorkPackage::Exports::Formatters::EstimatedHours)
let(:current_user) do
FactoryBot.create(:user,
member_in_project: project,
member_with_permissions: %i(view_projects))
end
let(:query) { Queries::Projects::ProjectQuery.new }
let(:instance) do
described_class.new(query)
end
it 'performs a successful export' do
data = ''
instance.export! do |result|
data = result.content
end
data = CSV.parse(data)
expect(data.size).to eq(2)
expect(data.last).to eq [project.id.to_s, project.identifier, project.name, '', 'false']
end
context 'with no project visible' do
let(:current_user) { User.anonymous }
it 'does not include the project' do
data = ''
instance.export! do |result|
data = result.content
end
expect(data).not_to include project.identifier
data = CSV.parse(data)
expect(data.size).to eq(1)
end
end
end

@ -128,6 +128,13 @@ module Pages
click_button('Show/hide filters')
end
def click_more_menu_item(item)
page.find('[data-qa-selector="project-more-dropdown-menu"]').click
page.within('.menu-drop-down-container') do
click_link(item)
end
end
def click_menu_item_of(title, project)
activate_menu_of(project) do
click_link title

Loading…
Cancel
Save