From ac410e5be485f69f251a0c8193323259b4767ff4 Mon Sep 17 00:00:00 2001 From: as-op Date: Mon, 25 Nov 2024 10:17:40 +0100 Subject: [PATCH 01/45] [#26180] PDF export for time sheets; part I: PDF skeleton https://community.openproject.org/work_packages/26180 --- .../work_package/pdf_export/common/common.rb | 2 +- .../pdf_export/document_generator.rb | 2 +- .../pdf_export/work_package_list_to_pdf.rb | 2 +- .../pdf_export/work_package_to_pdf.rb | 2 +- .../index_page_header_component.html.erb | 11 ++ .../controllers/cost_reports_controller.rb | 10 +- .../cost_query/schedule_export_service.rb | 9 +- .../app/workers/cost_query/pdf/export_job.rb | 43 ++++++++ .../cost_query/pdf/timesheet_generator.rb | 102 ++++++++++++++++++ .../cost_query/{ => xls}/export_job.rb | 11 +- modules/reporting/config/locales/en.yml | 3 + .../cost_query/{ => xls}/export_job_spec.rb | 2 +- 12 files changed, 184 insertions(+), 15 deletions(-) create mode 100644 modules/reporting/app/workers/cost_query/pdf/export_job.rb create mode 100644 modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb rename modules/reporting/app/workers/cost_query/{ => xls}/export_job.rb (96%) rename modules/reporting/spec/workers/cost_query/{ => xls}/export_job_spec.rb (99%) diff --git a/app/models/work_package/pdf_export/common/common.rb b/app/models/work_package/pdf_export/common/common.rb index c91d44836ab5..7ebeeb899ef4 100644 --- a/app/models/work_package/pdf_export/common/common.rb +++ b/app/models/work_package/pdf_export/common/common.rb @@ -35,7 +35,7 @@ module WorkPackage::PDFExport::Common::Common private - def get_pdf(_language) + def get_pdf ::WorkPackage::PDFExport::Common::View.new(current_language) end diff --git a/app/models/work_package/pdf_export/document_generator.rb b/app/models/work_package/pdf_export/document_generator.rb index 37b69c6b4ea0..26a3a46ceb88 100644 --- a/app/models/work_package/pdf_export/document_generator.rb +++ b/app/models/work_package/pdf_export/document_generator.rb @@ -52,7 +52,7 @@ def initialize(work_package, _options = {}) end def setup_page! - self.pdf = get_pdf(current_language) + self.pdf = get_pdf end def export! diff --git a/app/models/work_package/pdf_export/work_package_list_to_pdf.rb b/app/models/work_package/pdf_export/work_package_list_to_pdf.rb index 9a1ca49c949e..9b399c22d0d5 100644 --- a/app/models/work_package/pdf_export/work_package_list_to_pdf.rb +++ b/app/models/work_package/pdf_export/work_package_list_to_pdf.rb @@ -86,7 +86,7 @@ def export! private def setup_page! - self.pdf = get_pdf(current_language) + self.pdf = get_pdf configure_page_size!(wants_report? ? :portrait : :landscape) end diff --git a/app/models/work_package/pdf_export/work_package_to_pdf.rb b/app/models/work_package/pdf_export/work_package_to_pdf.rb index 5dcfa09dd8c8..2006616c0fc4 100644 --- a/app/models/work_package/pdf_export/work_package_to_pdf.rb +++ b/app/models/work_package/pdf_export/work_package_to_pdf.rb @@ -63,7 +63,7 @@ def export! end def setup_page! - self.pdf = get_pdf(current_language) + self.pdf = get_pdf @page_count = 0 configure_page_size!(:portrait) end diff --git a/modules/reporting/app/components/cost_reports/index_page_header_component.html.erb b/modules/reporting/app/components/cost_reports/index_page_header_component.html.erb index 4c59a126a115..040926aa6603 100644 --- a/modules/reporting/app/components/cost_reports/index_page_header_component.html.erb +++ b/modules/reporting/app/components/cost_reports/index_page_header_component.html.erb @@ -15,6 +15,17 @@ button.with_leading_visual_icon(icon: "op-file-xls-descriptions") t(:export_to_excel) end + header.with_action_button(scheme: :default, + aria: { label: t("export.timesheet.button")}, + title: t("export.timesheet.button"), + mobile_icon: "op-file-xls-descriptions", + mobile_label: t("export.timesheet.button"), + tag: :a, + href: url_for({ controller: "cost_reports" , action: :index, format: 'pdf', project_id: @project }) + ) do |button| + button.with_leading_visual_icon(icon: "op-file-xls-descriptions") + t("export.timesheet.button") + end call_hook(:view_cost_report_toolbar) end end diff --git a/modules/reporting/app/controllers/cost_reports_controller.rb b/modules/reporting/app/controllers/cost_reports_controller.rb index edb12e8baf26..f88e7b3c8ca5 100644 --- a/modules/reporting/app/controllers/cost_reports_controller.rb +++ b/modules/reporting/app/controllers/cost_reports_controller.rb @@ -87,7 +87,15 @@ def index format.xls do job_id = ::CostQuery::ScheduleExportService .new(user: current_user) - .call(filter_params:, project: @project, cost_types: @cost_types) + .call(:xls, filter_params:, project: @project, cost_types: @cost_types) + .result + redirect_to job_status_path(job_id) + end + + format.pdf do + job_id = ::CostQuery::ScheduleExportService + .new(user: current_user) + .call(:pdf, filter_params:, project: @project, cost_types: @cost_types) .result redirect_to job_status_path(job_id) diff --git a/modules/reporting/app/services/cost_query/schedule_export_service.rb b/modules/reporting/app/services/cost_query/schedule_export_service.rb index 991a38334040..31f722d7e429 100644 --- a/modules/reporting/app/services/cost_query/schedule_export_service.rb +++ b/modules/reporting/app/services/cost_query/schedule_export_service.rb @@ -33,17 +33,18 @@ def initialize(user:) self.user = user end - def call(filter_params:, project:, cost_types:) + def call(format, filter_params:, project:, cost_types:) export_storage = ::CostQuery::Export.create - job = schedule_export(export_storage, filter_params, project, cost_types) + job = schedule_export(format, export_storage, filter_params, project, cost_types) ServiceResult.success result: job.job_id end private - def schedule_export(export_storage, filter_params, project, cost_types) - ::CostQuery::ExportJob.perform_later(export: export_storage, + def schedule_export(format, export_storage, filter_params, project, cost_types) + job = format == :pdf ? ::CostQuery::PDF::ExportJob : ::CostQuery::XLS::ExportJob + job.perform_later(export: export_storage, user:, mime_type: :xls, query: filter_params, diff --git a/modules/reporting/app/workers/cost_query/pdf/export_job.rb b/modules/reporting/app/workers/cost_query/pdf/export_job.rb new file mode 100644 index 000000000000..fe6e94bedec6 --- /dev/null +++ b/modules/reporting/app/workers/cost_query/pdf/export_job.rb @@ -0,0 +1,43 @@ +require "active_storage/filename" + +class CostQuery::PDF::ExportJob < Exports::ExportJob + self.model = ::CostQuery + + def project + options[:project] + end + + def cost_types + options[:cost_types] + end + + def title + I18n.t("export.timesheet.title") + end + + private + + def export! + handle_export_result(export, pdf_report_result) + end + + def prepare! + CostQuery::Cache.check + end + + def pdf_report_result + content = generate_timesheet + time = Time.current.strftime("%Y-%m-%d-T-%H-%M-%S") + export_title = "timesheet-#{time}.pdf" + ::Exports::Result.new(format: :pdf, + title: export_title, + mime_type: "application/pdf", + content:) + end + + def generate_timesheet + self.query = CostQuery.new(project:) + generator = ::CostQuery::PDF::TimesheetGenerator.new(self.query, project, cost_types) + generator.generate! + end +end diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb new file mode 100644 index 000000000000..c4cef17f2493 --- /dev/null +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -0,0 +1,102 @@ +class CostQuery::PDF::TimesheetGenerator + include WorkPackage::PDFExport::Common::Common + include WorkPackage::PDFExport::Export::Cover + include WorkPackage::PDFExport::Common::Logo + include WorkPackage::PDFExport::Export::Page + include WorkPackage::PDFExport::Export::Style + + attr_accessor :pdf + + def initialize(query, project, cost_types) + @query = query + @project = project + @cost_types = cost_types + setup_page! + end + + def heading + "Timesheet" + end + + def project + @project + end + + def query + @query + end + + def options + {} + end + + def setup_page! + self.pdf = get_pdf + @page_count = 0 + configure_page_size!(:portrait) + end + + def generate! + render_doc + pdf.render + rescue StandardError => e + Rails.logger.error { "Failed to generate PDF: #{e} #{e.message}}." } + error(I18n.t(:error_pdf_failed_to_export, error: e.message)) + end + + def render_doc + write_cover_page! if with_cover? + + pdf.formatted_text([{ text: heading }]) + write_hr + query + .each_direct_result + .map(&:itself) + .group_by { |r| r.fields["user_id"] } + .each do |user_id, result| + write_table(user_id, result) + end + end + + def write_table(user_id, entries) + rows = [] + rows.push(["Date", "Work package", "Time", "Hours", "Activity"]) + entries + .group_by { |r| DateTime.parse(r.fields["spent_on"]) } + .sort + .each do |spent_on, lines| + lines.each do |r| + rows.push([ + lines[0]["spent_on"], + WorkPackage.find(r.fields["work_package_id"]).subject, + "??:00-??:00", + r.fields["units"].inspect + 'h', + "Activity" + ]) + end + end + # TODO: write user on new page if table does not fit on the same + write_user(user_id) + table = pdf.make_table(rows, header: false, width: 500, column_widths: [100, 100, 100, 100, 100]) + table.draw + end + + def write_user(user_id) + pdf.formatted_text([{ text: User.select_for_name.find(user_id).name }]) + end + + def sorted_results + query.each_direct_result.map(&:itself) + end + + def write_hr + hr_style = styles.cover_header_border + pdf.stroke_color = hr_style[:color] + pdf.line_width = hr_style[:height] + pdf.stroke_horizontal_line(pdf.bounds.left, pdf.bounds.right, at: pdf.cursor) + end + + def with_cover? + true + end +end diff --git a/modules/reporting/app/workers/cost_query/export_job.rb b/modules/reporting/app/workers/cost_query/xls/export_job.rb similarity index 96% rename from modules/reporting/app/workers/cost_query/export_job.rb rename to modules/reporting/app/workers/cost_query/xls/export_job.rb index baf38d88896b..443de7742abd 100644 --- a/modules/reporting/app/workers/cost_query/export_job.rb +++ b/modules/reporting/app/workers/cost_query/xls/export_job.rb @@ -1,12 +1,8 @@ require "active_storage/filename" -class CostQuery::ExportJob < Exports::ExportJob +class CostQuery::XLS::ExportJob < Exports::ExportJob self.model = ::CostQuery - def title - I18n.t("export.cost_reports.title") - end - def project options[:project] end @@ -15,6 +11,10 @@ def cost_types options[:cost_types] end + def title + I18n.t("export.cost_reports.title") + end + private def prepare! @@ -40,6 +40,7 @@ def xls_report_result content:) end + # rubocop:disable Metrics/AbcSize def build_query(filters, groups = {}) query = CostQuery.new(project:) diff --git a/modules/reporting/config/locales/en.yml b/modules/reporting/config/locales/en.yml index f028c008e55a..bf5feeb9ea5a 100644 --- a/modules/reporting/config/locales/en.yml +++ b/modules/reporting/config/locales/en.yml @@ -105,6 +105,9 @@ en: validation_failure_integer: "is not a valid integer" export: + timesheet: + title: "Your PDF timesheet export" + button: "Export PDF timesheet" cost_reports: title: "Your Cost Reports XLS export" diff --git a/modules/reporting/spec/workers/cost_query/export_job_spec.rb b/modules/reporting/spec/workers/cost_query/xls/export_job_spec.rb similarity index 99% rename from modules/reporting/spec/workers/cost_query/export_job_spec.rb rename to modules/reporting/spec/workers/cost_query/xls/export_job_spec.rb index 9fd966b4dc1f..9591f99fa78e 100644 --- a/modules/reporting/spec/workers/cost_query/export_job_spec.rb +++ b/modules/reporting/spec/workers/cost_query/xls/export_job_spec.rb @@ -30,7 +30,7 @@ require "spec_helper" -RSpec.describe CostQuery::ExportJob do +RSpec.describe CostQuery::XLS::ExportJob do let(:user) { build_stubbed(:user) } let(:project) { build_stubbed(:project) } From bd2b59648b60bc326ca408f9554185c869e59ec8 Mon Sep 17 00:00:00 2001 From: as-op Date: Mon, 25 Nov 2024 11:57:18 +0100 Subject: [PATCH 02/45] obey rubocop --- .../app/workers/cost_query/pdf/export_job.rb | 2 +- .../cost_query/pdf/timesheet_generator.rb | 20 +++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/modules/reporting/app/workers/cost_query/pdf/export_job.rb b/modules/reporting/app/workers/cost_query/pdf/export_job.rb index fe6e94bedec6..0e56f916350d 100644 --- a/modules/reporting/app/workers/cost_query/pdf/export_job.rb +++ b/modules/reporting/app/workers/cost_query/pdf/export_job.rb @@ -37,7 +37,7 @@ def pdf_report_result def generate_timesheet self.query = CostQuery.new(project:) - generator = ::CostQuery::PDF::TimesheetGenerator.new(self.query, project, cost_types) + generator = ::CostQuery::PDF::TimesheetGenerator.new(query, project, cost_types) generator.generate! end end diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index c4cef17f2493..1c6a6bde4255 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -58,23 +58,29 @@ def render_doc end end - def write_table(user_id, entries) + # rubocop:disable Metrics/AbcSize + def build_table_rows(entries) rows = [] - rows.push(["Date", "Work package", "Time", "Hours", "Activity"]) entries .group_by { |r| DateTime.parse(r.fields["spent_on"]) } .sort - .each do |spent_on, lines| + .each do |_spent_on, lines| lines.each do |r| rows.push([ lines[0]["spent_on"], WorkPackage.find(r.fields["work_package_id"]).subject, "??:00-??:00", - r.fields["units"].inspect + 'h', - "Activity" + "#{r.fields['units'].inspect}h", + TimeEntryActivity.find(r.fields["activity_id"]).name ]) end end + rows + end + # rubocop:enable Metrics/AbcSize + + def write_table(user_id, entries) + rows = [["Date", "Work package", "Time", "Hours", "Activity"]].concat(build_table_rows(entries)) # TODO: write user on new page if table does not fit on the same write_user(user_id) table = pdf.make_table(rows, header: false, width: 500, column_widths: [100, 100, 100, 100, 100]) @@ -89,12 +95,14 @@ def sorted_results query.each_direct_result.map(&:itself) end + # rubocop:disable Metrics/AbcSize def write_hr hr_style = styles.cover_header_border pdf.stroke_color = hr_style[:color] pdf.line_width = hr_style[:height] - pdf.stroke_horizontal_line(pdf.bounds.left, pdf.bounds.right, at: pdf.cursor) + pdf.stroke_horizontal_line pdf.bounds.left, pdf.bounds.right, at: pdf.cursor end + # rubocop:enable Metrics/AbcSize def with_cover? true From 2e1ec454fc96c7fc84d61ab19b0f49571d5b8c53 Mon Sep 17 00:00:00 2001 From: as-op Date: Mon, 25 Nov 2024 12:05:14 +0100 Subject: [PATCH 03/45] obey rubocop --- .../controllers/cost_reports_controller.rb | 36 +++++++++---------- .../app/workers/cost_query/xls/export_job.rb | 2 -- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/modules/reporting/app/controllers/cost_reports_controller.rb b/modules/reporting/app/controllers/cost_reports_controller.rb index f88e7b3c8ca5..7a06e2404a20 100644 --- a/modules/reporting/app/controllers/cost_reports_controller.rb +++ b/modules/reporting/app/controllers/cost_reports_controller.rb @@ -83,27 +83,21 @@ def index session[report_engine.name.underscore.to_sym].try(:delete, :name) render locals: { menu_name: project_or_global_menu } end - - format.xls do - job_id = ::CostQuery::ScheduleExportService - .new(user: current_user) - .call(:xls, filter_params:, project: @project, cost_types: @cost_types) - .result - redirect_to job_status_path(job_id) - end - - format.pdf do - job_id = ::CostQuery::ScheduleExportService - .new(user: current_user) - .call(:pdf, filter_params:, project: @project, cost_types: @cost_types) - .result - - redirect_to job_status_path(job_id) - end + format.xls { export(:xls) } + format.pdf { export(:pdf) } end end end + def export(format) + job_id = ::CostQuery::ScheduleExportService + .new(user: current_user) + .call(format, filter_params:, project: @project, cost_types: @cost_types) + .result + + redirect_to job_status_path(job_id) + end + ## # Render the report. Renders either the complete index or the table only def table @@ -366,7 +360,8 @@ def determine_engine # save_private_cost_reports permission as well # # @Override - def allowed_in_report?(action, report, user = User.current) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity + # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity + def allowed_in_report?(action, report, user = User.current) # admins may do everything return true if user.admin? @@ -398,6 +393,7 @@ def allowed_in_report?(action, report, user = User.current) # rubocop:disable Me Array(permissions).any? { |permission| user.allowed_in_any_project?(permission) } end end + # rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity private @@ -407,8 +403,8 @@ def find_optional_user def get_filter_class(name) filter = report_engine::Filter - .all - .detect { |cls| cls.to_s.demodulize.underscore == name.to_s } + .all + .detect { |cls| cls.to_s.demodulize.underscore == name.to_s } raise ArgumentError.new("Filter with name #{name} does not exist.") unless filter diff --git a/modules/reporting/app/workers/cost_query/xls/export_job.rb b/modules/reporting/app/workers/cost_query/xls/export_job.rb index 443de7742abd..06f6270056f1 100644 --- a/modules/reporting/app/workers/cost_query/xls/export_job.rb +++ b/modules/reporting/app/workers/cost_query/xls/export_job.rb @@ -40,7 +40,6 @@ def xls_report_result content:) end - # rubocop:disable Metrics/AbcSize def build_query(filters, groups = {}) query = CostQuery.new(project:) @@ -58,6 +57,5 @@ def build_query(filters, groups = {}) groups[:rows].try(:reverse_each) { |r| query.row(r) } query end - # rubocop:enable Metrics/AbcSize end From 45244d5503a20245db5b9fc39188911e9a6fc780 Mon Sep 17 00:00:00 2001 From: as-op Date: Mon, 25 Nov 2024 13:05:32 +0100 Subject: [PATCH 04/45] costs exports: use inline job status dialogs to not to navigate away to the job status url --- .../dynamic/costs/export.controller.ts | 61 +++++++++++++++++++ .../index_page_header_component.html.erb | 20 ++++-- .../controllers/cost_reports_controller.rb | 6 +- 3 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 frontend/src/stimulus/controllers/dynamic/costs/export.controller.ts diff --git a/frontend/src/stimulus/controllers/dynamic/costs/export.controller.ts b/frontend/src/stimulus/controllers/dynamic/costs/export.controller.ts new file mode 100644 index 000000000000..183aa9ffc91e --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/costs/export.controller.ts @@ -0,0 +1,61 @@ +import { Controller } from '@hotwired/stimulus'; +import * as Turbo from '@hotwired/turbo'; +import { HttpErrorResponse } from '@angular/common/http'; + +export default class ExportController extends Controller { + static values = { + jobStatusDialogUrl: String, + }; + + declare jobStatusDialogUrlValue:string; + + jobModalUrl(job_id:string):string { + return this.jobStatusDialogUrlValue.replace('_job_uuid_', job_id); + } + + async showJobModal(job_id:string) { + console.log(this.jobModalUrl(job_id)); + const response = await fetch(this.jobModalUrl(job_id), { + method: 'GET', + headers: { Accept: 'text/vnd.turbo-stream.html' }, + }); + if (response.ok) { + Turbo.renderStreamMessage(await response.text()); + } else { + throw new Error(response.statusText || 'Invalid response from server'); + } + } + + async requestExport(exportURL:string):Promise { + const response = await fetch(exportURL, { + method: 'GET', + headers: { Accept: 'application/json' }, + credentials: 'same-origin', + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const result = await response.json() as { job_id:string }; + if (!result.job_id) { + throw new Error('Invalid response from server'); + } + return result.job_id; + } + + get href() { + return (this.element as HTMLLinkElement).href; + } + + download(evt:CustomEvent) { + evt.preventDefault(); // Don't follow the href + this.requestExport(this.href) + .then((job_id) => this.showJobModal(job_id)) + .catch((error:HttpErrorResponse) => this.handleError(error)); + } + + private handleError(error:HttpErrorResponse) { + void window.OpenProject.getPluginContext().then((pluginContext) => { + pluginContext.services.notifications.addError(error); + }); + } +} diff --git a/modules/reporting/app/components/cost_reports/index_page_header_component.html.erb b/modules/reporting/app/components/cost_reports/index_page_header_component.html.erb index 040926aa6603..92d133f1d002 100644 --- a/modules/reporting/app/components/cost_reports/index_page_header_component.html.erb +++ b/modules/reporting/app/components/cost_reports/index_page_header_component.html.erb @@ -5,23 +5,35 @@ if show_export_button? header.with_action_button(scheme: :default, - aria: { label: t(:export_to_excel)}, + aria: { label: t(:export_to_excel) }, title: t(:export_to_excel), mobile_icon: "op-file-xls-descriptions", mobile_label: t(:export_to_excel), tag: :a, - href: url_for({ controller: "cost_reports" , action: :index, format: 'xls', project_id: @project }) + href: url_for({ controller: "cost_reports", action: :index, format: 'xls', project_id: @project }), + data: { + controller: "costs--export", + "application-target": "dynamic", + "costs--export-job-status-dialog-url-value": job_status_dialog_path('_job_uuid_'), + action: "click->costs--export#download" + } ) do |button| button.with_leading_visual_icon(icon: "op-file-xls-descriptions") t(:export_to_excel) end header.with_action_button(scheme: :default, - aria: { label: t("export.timesheet.button")}, + aria: { label: t("export.timesheet.button") }, title: t("export.timesheet.button"), mobile_icon: "op-file-xls-descriptions", mobile_label: t("export.timesheet.button"), tag: :a, - href: url_for({ controller: "cost_reports" , action: :index, format: 'pdf', project_id: @project }) + href: url_for({ controller: "cost_reports", action: :index, format: 'pdf', project_id: @project }), + data: { + controller: "costs--export", + "application-target": "dynamic", + "costs--export-job-status-dialog-url-value": job_status_dialog_path('_job_uuid_'), + action: "click->costs--export#download" + } ) do |button| button.with_leading_visual_icon(icon: "op-file-xls-descriptions") t("export.timesheet.button") diff --git a/modules/reporting/app/controllers/cost_reports_controller.rb b/modules/reporting/app/controllers/cost_reports_controller.rb index 7a06e2404a20..f2a7e8ef7292 100644 --- a/modules/reporting/app/controllers/cost_reports_controller.rb +++ b/modules/reporting/app/controllers/cost_reports_controller.rb @@ -95,7 +95,11 @@ def export(format) .call(format, filter_params:, project: @project, cost_types: @cost_types) .result - redirect_to job_status_path(job_id) + if request.headers["Accept"]&.include?("application/json") + render json: { job_id: } + else + redirect_to job_status_path(job_id) + end end ## From e7bb9cab5b73c81f00ab8ca50e98be1922d149bd Mon Sep 17 00:00:00 2001 From: as-op Date: Mon, 25 Nov 2024 14:06:09 +0100 Subject: [PATCH 05/45] costs exports: filter for time entries; add comments --- .../cost_query/pdf/timesheet_generator.rb | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index 1c6a6bde4255..907228cbd281 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -52,13 +52,13 @@ def render_doc query .each_direct_result .map(&:itself) + .filter { |r| r.fields["type"] == "TimeEntry" } .group_by { |r| r.fields["user_id"] } .each do |user_id, result| write_table(user_id, result) end end - # rubocop:disable Metrics/AbcSize def build_table_rows(entries) rows = [] entries @@ -66,18 +66,28 @@ def build_table_rows(entries) .sort .each do |_spent_on, lines| lines.each do |r| - rows.push([ - lines[0]["spent_on"], - WorkPackage.find(r.fields["work_package_id"]).subject, - "??:00-??:00", - "#{r.fields['units'].inspect}h", - TimeEntryActivity.find(r.fields["activity_id"]).name - ]) + row = [ + { content: lines[0]["spent_on"], rowspan: 1 }, + WorkPackage.find(r.fields["work_package_id"]).subject, + "??:00-??:00", + format_duration(r.fields["units"]), + TimeEntryActivity.find(r.fields["activity_id"]).name + ] + rows.push(row) + if r.fields["comments"].present? + row[0][:rowspan] = 2 + rows.push([{ content: r.fields["comments"], colspan: 4 }]) + end end end rows end - # rubocop:enable Metrics/AbcSize + + def format_duration(hours) + return "" if hours < 0 + + "#{hours}h" + end def write_table(user_id, entries) rows = [["Date", "Work package", "Time", "Hours", "Activity"]].concat(build_table_rows(entries)) @@ -95,14 +105,12 @@ def sorted_results query.each_direct_result.map(&:itself) end - # rubocop:disable Metrics/AbcSize def write_hr hr_style = styles.cover_header_border pdf.stroke_color = hr_style[:color] pdf.line_width = hr_style[:height] pdf.stroke_horizontal_line pdf.bounds.left, pdf.bounds.right, at: pdf.cursor end - # rubocop:enable Metrics/AbcSize def with_cover? true From 4fd2a2d0d9d90fe1fe712c3a620921e8f6d29e46 Mon Sep 17 00:00:00 2001 From: as-op Date: Mon, 25 Nov 2024 14:21:27 +0100 Subject: [PATCH 06/45] obey rubocop --- .../dynamic/costs/export.controller.ts | 3 +- .../cost_query/pdf/timesheet_generator.rb | 36 ++++++++++++++----- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/costs/export.controller.ts b/frontend/src/stimulus/controllers/dynamic/costs/export.controller.ts index 183aa9ffc91e..ba839789d487 100644 --- a/frontend/src/stimulus/controllers/dynamic/costs/export.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/costs/export.controller.ts @@ -14,7 +14,6 @@ export default class ExportController extends Controller { } async showJobModal(job_id:string) { - console.log(this.jobModalUrl(job_id)); const response = await fetch(this.jobModalUrl(job_id), { method: 'GET', headers: { Accept: 'text/vnd.turbo-stream.html' }, @@ -43,7 +42,7 @@ export default class ExportController extends Controller { } get href() { - return (this.element as HTMLLinkElement).href; + return this.element.href; } download(evt:CustomEvent) { diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index 907228cbd281..127c32571b19 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -34,6 +34,7 @@ def setup_page! self.pdf = get_pdf @page_count = 0 configure_page_size!(:portrait) + pdf.title = heading end def generate! @@ -46,19 +47,31 @@ def generate! def render_doc write_cover_page! if with_cover? + write_heading! + write_hr! + write_entries! + end + def write_heading! pdf.formatted_text([{ text: heading }]) - write_hr - query - .each_direct_result - .map(&:itself) - .filter { |r| r.fields["type"] == "TimeEntry" } + end + + def write_entries! + all_entries .group_by { |r| r.fields["user_id"] } .each do |user_id, result| write_table(user_id, result) end end + def all_entries + query + .each_direct_result + .map(&:itself) + .filter { |r| r.fields["type"] == "TimeEntry" } + end + + # rubocop:disable Metrics/AbcSize def build_table_rows(entries) rows = [] entries @@ -82,6 +95,7 @@ def build_table_rows(entries) end rows end + # rubocop:enable Metrics/AbcSize def format_duration(hours) return "" if hours < 0 @@ -105,12 +119,16 @@ def sorted_results query.each_direct_result.map(&:itself) end - def write_hr + # rubocop:disable Metrics/AbcSize + def write_hr! hr_style = styles.cover_header_border - pdf.stroke_color = hr_style[:color] - pdf.line_width = hr_style[:height] - pdf.stroke_horizontal_line pdf.bounds.left, pdf.bounds.right, at: pdf.cursor + pdf.stroke do + pdf.line_width = hr_style[:color] + pdf.stroke_color hr_style[:height] + pdf.stroke_horizontal_line pdf.bounds.left, pdf.bounds.right, at: pdf.cursor + end end + # rubocop:enable Metrics/AbcSize def with_cover? true From e6793e84a3dcb34bcb03f8e70ea27d1c1c47fb38 Mon Sep 17 00:00:00 2001 From: as-op Date: Tue, 26 Nov 2024 13:01:25 +0100 Subject: [PATCH 07/45] follow mockup styling; use current query in pdf export --- .../controllers/cost_reports_controller.rb | 2 +- .../cost_query/schedule_export_service.rb | 16 +-- .../app/workers/cost_query/pdf/export_job.rb | 1 - .../cost_query/pdf/timesheet_generator.rb | 129 ++++++++++++++---- 4 files changed, 110 insertions(+), 38 deletions(-) diff --git a/modules/reporting/app/controllers/cost_reports_controller.rb b/modules/reporting/app/controllers/cost_reports_controller.rb index f2a7e8ef7292..c39cf15932ef 100644 --- a/modules/reporting/app/controllers/cost_reports_controller.rb +++ b/modules/reporting/app/controllers/cost_reports_controller.rb @@ -92,7 +92,7 @@ def index def export(format) job_id = ::CostQuery::ScheduleExportService .new(user: current_user) - .call(format, filter_params:, project: @project, cost_types: @cost_types) + .call(format, filter_params:, query: @query, project: @project, cost_types: @cost_types) .result if request.headers["Accept"]&.include?("application/json") diff --git a/modules/reporting/app/services/cost_query/schedule_export_service.rb b/modules/reporting/app/services/cost_query/schedule_export_service.rb index 31f722d7e429..9f42b4fa34f1 100644 --- a/modules/reporting/app/services/cost_query/schedule_export_service.rb +++ b/modules/reporting/app/services/cost_query/schedule_export_service.rb @@ -33,22 +33,22 @@ def initialize(user:) self.user = user end - def call(format, filter_params:, project:, cost_types:) + def call(format, filter_params:, query:, project:, cost_types:) export_storage = ::CostQuery::Export.create - job = schedule_export(format, export_storage, filter_params, project, cost_types) + job = schedule_export(format, export_storage, filter_params, query, project, cost_types) ServiceResult.success result: job.job_id end private - def schedule_export(format, export_storage, filter_params, project, cost_types) + def schedule_export(format, export_storage, filter_params, query, project, cost_types) job = format == :pdf ? ::CostQuery::PDF::ExportJob : ::CostQuery::XLS::ExportJob job.perform_later(export: export_storage, - user:, - mime_type: :xls, - query: filter_params, - project:, - cost_types:) + user:, + mime_type: format, + query: format == :pdf ? query : filter_params, + project:, + cost_types:) end end diff --git a/modules/reporting/app/workers/cost_query/pdf/export_job.rb b/modules/reporting/app/workers/cost_query/pdf/export_job.rb index 0e56f916350d..3453c3005cc2 100644 --- a/modules/reporting/app/workers/cost_query/pdf/export_job.rb +++ b/modules/reporting/app/workers/cost_query/pdf/export_job.rb @@ -36,7 +36,6 @@ def pdf_report_result end def generate_timesheet - self.query = CostQuery.new(project:) generator = ::CostQuery::PDF::TimesheetGenerator.new(query, project, cost_types) generator.generate! end diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index 127c32571b19..43d54de075c2 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -15,7 +15,11 @@ def initialize(query, project, cost_types) end def heading - "Timesheet" + query.name || "Timesheet" + end + + def footer_title + heading end def project @@ -50,10 +54,8 @@ def render_doc write_heading! write_hr! write_entries! - end - - def write_heading! - pdf.formatted_text([{ text: heading }]) + write_headers! + write_footers! end def write_entries! @@ -77,43 +79,81 @@ def build_table_rows(entries) entries .group_by { |r| DateTime.parse(r.fields["spent_on"]) } .sort - .each do |_spent_on, lines| + .each do |spent_on, lines| + day_rows = [] lines.each do |r| - row = [ - { content: lines[0]["spent_on"], rowspan: 1 }, - WorkPackage.find(r.fields["work_package_id"]).subject, - "??:00-??:00", - format_duration(r.fields["units"]), - TimeEntryActivity.find(r.fields["activity_id"]).name - ] - rows.push(row) + day_rows.push( + [ + wp_subject(r.fields["work_package_id"]), + with_times_column? ? "??:00-??:00" : nil, + format_duration(r.fields["units"]), + activity_name(r.fields["activity_id"]) + ].compact + ) if r.fields["comments"].present? - row[0][:rowspan] = 2 - rows.push([{ content: r.fields["comments"], colspan: 4 }]) + day_rows.push ([{ content: r.fields["comments"], text_color: "636C76", colspan: table_columns_span }]) end end + day_rows[0].unshift({ content: format_date(spent_on), rowspan: day_rows.length }) + rows.concat(day_rows) end rows end + # rubocop:enable Metrics/AbcSize - def format_duration(hours) - return "" if hours < 0 + def table_header_columns + with_times_column? ? ["Date", "Work package", "Time", "Hours", "Activity"] : ["Date", "Work package", "Hours", "Activity"] + end - "#{hours}h" + def table_columns_widths + with_times_column? ? [80, 193, 80, 70, 100] : [80, 270, 70, 100] + end + + def table_width + table_columns_widths.sum end + def table_columns_span + with_times_column? ? 4 : 3 + end + + # rubocop:disable Metrics/AbcSize def write_table(user_id, entries) - rows = [["Date", "Work package", "Time", "Hours", "Activity"]].concat(build_table_rows(entries)) + rows = [table_header_columns].concat(build_table_rows(entries)) # TODO: write user on new page if table does not fit on the same write_user(user_id) - table = pdf.make_table(rows, header: false, width: 500, column_widths: [100, 100, 100, 100, 100]) - table.draw - end - - def write_user(user_id) - pdf.formatted_text([{ text: User.select_for_name.find(user_id).name }]) + pdf.make_table( + rows, header: false, + width: table_width, + column_widths: table_columns_widths, + cell_style: { + border_color: "BBBBBB", + border_width: 0.5, + borders: [:top], + padding: [5, 5, 8, 5] + } + ) do |table| + table.columns(0).borders = %i[top bottom left right] + table.rows(0).style do |c| + c.borders = c.borders + [:top] + c.font_style = :bold + end + table.rows(-1).style do |c| + c.borders = c.borders + [:bottom] + end + table.columns(-1).style do |c| + c.borders = c.borders + [:right] + end + table.columns(1).style do |c| + if c.colspan > 1 + c.borders = %i[left right] + c.padding = [0, 5, 8, 5] + end + end + end.draw end + # rubocop:enable Metrics/AbcSize def sorted_results query.each_direct_result.map(&:itself) @@ -123,13 +163,46 @@ def sorted_results def write_hr! hr_style = styles.cover_header_border pdf.stroke do - pdf.line_width = hr_style[:color] - pdf.stroke_color hr_style[:height] + pdf.line_width = hr_style[:height] + pdf.stroke_color hr_style[:color] pdf.stroke_horizontal_line pdf.bounds.left, pdf.bounds.right, at: pdf.cursor end + pdf.move_down(16) end # rubocop:enable Metrics/AbcSize + def write_heading! + pdf.formatted_text([{ text: heading, size: 26, style: :bold }]) + pdf.move_down(2) + end + + def write_user(user_id) + pdf.formatted_text([{ text: user_name(user_id), size: 20 }]) + pdf.move_down(10) + end + + def user_name(user_id) + User.select_for_name.find(user_id).name + end + + def activity_name(activity_id) + TimeEntryActivity.find(activity_id).name + end + + def wp_subject(wp_id) + WorkPackage.find(wp_id).subject + end + + def format_duration(hours) + return "" if hours < 0 + + "#{hours}h" + end + + def with_times_column? + true + end + def with_cover? true end From 677956ebc291f43b5d5f46b7e85410a096c2e23e Mon Sep 17 00:00:00 2001 From: as-op Date: Tue, 26 Nov 2024 13:16:00 +0100 Subject: [PATCH 08/45] obey rubocop --- .../app/controllers/cost_reports_controller.rb | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/modules/reporting/app/controllers/cost_reports_controller.rb b/modules/reporting/app/controllers/cost_reports_controller.rb index c39cf15932ef..e6bdd7e53801 100644 --- a/modules/reporting/app/controllers/cost_reports_controller.rb +++ b/modules/reporting/app/controllers/cost_reports_controller.rb @@ -77,15 +77,17 @@ def check_cache def index table - unless performed? - respond_to do |format| - format.html do - session[report_engine.name.underscore.to_sym].try(:delete, :name) - render locals: { menu_name: project_or_global_menu } - end - format.xls { export(:xls) } - format.pdf { export(:pdf) } + perform unless performed? + end + + def perform + respond_to do |format| + format.html do + session[report_engine.name.underscore.to_sym].try(:delete, :name) + render locals: { menu_name: project_or_global_menu } end + format.xls { export(:xls) } + format.pdf { export(:pdf) } end end From 11d5dc8b1b70e1a38bbfbf72788e45426c06a45f Mon Sep 17 00:00:00 2001 From: as-op Date: Tue, 26 Nov 2024 13:54:10 +0100 Subject: [PATCH 09/45] send unsaved cost query settings to export worker --- .../controllers/cost_reports_controller.rb | 18 +++++++++++----- modules/reporting/app/models/cost_query.rb | 19 +++++++++++++++++ .../cost_query/schedule_export_service.rb | 10 +++++---- .../app/workers/cost_query/pdf/export_job.rb | 2 ++ .../app/workers/cost_query/xls/export_job.rb | 21 +------------------ 5 files changed, 41 insertions(+), 29 deletions(-) diff --git a/modules/reporting/app/controllers/cost_reports_controller.rb b/modules/reporting/app/controllers/cost_reports_controller.rb index e6bdd7e53801..b691364abcd7 100644 --- a/modules/reporting/app/controllers/cost_reports_controller.rb +++ b/modules/reporting/app/controllers/cost_reports_controller.rb @@ -94,8 +94,14 @@ def perform def export(format) job_id = ::CostQuery::ScheduleExportService .new(user: current_user) - .call(format, filter_params:, query: @query, project: @project, cost_types: @cost_types) - .result + .call( + format:, + query_id: @query.id, + query_name: @query.name, + filter_params: filter_params, + project: @project, + cost_types: @cost_types + ).result if request.headers["Accept"]&.include?("application/json") render json: { job_id: } @@ -133,7 +139,8 @@ def create if request.xhr? # Update via AJAX - return url for redirect render plain: url_for(**redirect_params) - else # Redirect to the new record + else + # Redirect to the new record redirect_to **redirect_params end end @@ -399,6 +406,7 @@ def allowed_in_report?(action, report, user = User.current) Array(permissions).any? { |permission| user.allowed_in_any_project?(permission) } end end + # rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity private @@ -501,11 +509,11 @@ def prepare_query def determine_settings if force_default? filters = default_filter_parameters - groups = default_group_parameters + groups = default_group_parameters session[report_engine.name.underscore.to_sym].try :delete, :name else filters = filter_params - groups = group_params + groups = group_params end cookie = session[report_engine.name.underscore.to_sym] || {} session[report_engine.name.underscore.to_sym] = cookie.merge(filters:, groups:) diff --git a/modules/reporting/app/models/cost_query.rb b/modules/reporting/app/models/cost_query.rb index 4d29547b7f6d..5317137f98b3 100644 --- a/modules/reporting/app/models/cost_query.rb +++ b/modules/reporting/app/models/cost_query.rb @@ -85,6 +85,25 @@ def self.exists_in?(project, user) public(project).or(private(project, user)).exists? end + # rubocop:disable Metrics/AbcSize + def self.build_query(project, filters, groups = {}) + query = CostQuery.new(project:) + query.tap do |q| + filters[:operators].each do |filter, operator| + unless filters[:values][filter] == ["<>"] + values = Array(filters[:values][filter]).map { |v| v == "<>" ? nil : v } + q.filter(filter.to_sym, + operator:, + values:) + end + end + end + groups[:columns].try(:reverse_each) { |c| query.column(c) } + groups[:rows].try(:reverse_each) { |r| query.row(r) } + query + end + # rubocop:enable Metrics/AbcSize + def serialize # have to take the reverse group_bys to retain the original order when deserializing self.serialized = { filters: filters.map(&:serialize).reject(&:nil?).sort_by(&:first), diff --git a/modules/reporting/app/services/cost_query/schedule_export_service.rb b/modules/reporting/app/services/cost_query/schedule_export_service.rb index 9f42b4fa34f1..2042dcdd7723 100644 --- a/modules/reporting/app/services/cost_query/schedule_export_service.rb +++ b/modules/reporting/app/services/cost_query/schedule_export_service.rb @@ -33,21 +33,23 @@ def initialize(user:) self.user = user end - def call(format, filter_params:, query:, project:, cost_types:) + def call(format:, query_id:, query_name:, filter_params:, project:, cost_types:) export_storage = ::CostQuery::Export.create - job = schedule_export(format, export_storage, filter_params, query, project, cost_types) + job = schedule_export(format, export_storage, query_id, query_name, filter_params, project, cost_types) ServiceResult.success result: job.job_id end private - def schedule_export(format, export_storage, filter_params, query, project, cost_types) + def schedule_export(format, export_storage, query_id, query_name, filter_params, project, cost_types) job = format == :pdf ? ::CostQuery::PDF::ExportJob : ::CostQuery::XLS::ExportJob job.perform_later(export: export_storage, user:, mime_type: format, - query: format == :pdf ? query : filter_params, + query_id:, + query_name:, + query: filter_params, project:, cost_types:) end diff --git a/modules/reporting/app/workers/cost_query/pdf/export_job.rb b/modules/reporting/app/workers/cost_query/pdf/export_job.rb index 3453c3005cc2..de22ffc5a575 100644 --- a/modules/reporting/app/workers/cost_query/pdf/export_job.rb +++ b/modules/reporting/app/workers/cost_query/pdf/export_job.rb @@ -23,6 +23,8 @@ def export! def prepare! CostQuery::Cache.check + self.query = CostQuery.build_query(project, query) + query.name = options[:query_name] end def pdf_report_result diff --git a/modules/reporting/app/workers/cost_query/xls/export_job.rb b/modules/reporting/app/workers/cost_query/xls/export_job.rb index 06f6270056f1..e9cdda62daab 100644 --- a/modules/reporting/app/workers/cost_query/xls/export_job.rb +++ b/modules/reporting/app/workers/cost_query/xls/export_job.rb @@ -19,7 +19,7 @@ def title def prepare! CostQuery::Cache.check - self.query = build_query(query) + self.query = CostQuery.build_query(project, query) end def export! @@ -39,23 +39,4 @@ def xls_report_result mime_type: "application/vnd.ms-excel", content:) end - - # rubocop:disable Metrics/AbcSize - def build_query(filters, groups = {}) - query = CostQuery.new(project:) - query.tap do |q| - filters[:operators].each do |filter, operator| - unless filters[:values][filter] == ["<>"] - values = Array(filters[:values][filter]).map { |v| v == "<>" ? nil : v } - q.filter(filter.to_sym, - operator:, - values:) - end - end - end - groups[:columns].try(:reverse_each) { |c| query.column(c) } - groups[:rows].try(:reverse_each) { |r| query.row(r) } - query - end - # rubocop:enable Metrics/AbcSize end From 9395e87d3c3c3ca5bf078bcdf5c80571badef6cc Mon Sep 17 00:00:00 2001 From: as-op Date: Tue, 26 Nov 2024 14:44:24 +0100 Subject: [PATCH 10/45] fix missing border if comment is last line --- .../app/controllers/cost_reports_controller.rb | 8 +++----- .../workers/cost_query/pdf/timesheet_generator.rb | 14 +++++++------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/modules/reporting/app/controllers/cost_reports_controller.rb b/modules/reporting/app/controllers/cost_reports_controller.rb index b691364abcd7..ffaf80d5056d 100644 --- a/modules/reporting/app/controllers/cost_reports_controller.rb +++ b/modules/reporting/app/controllers/cost_reports_controller.rb @@ -139,8 +139,7 @@ def create if request.xhr? # Update via AJAX - return url for redirect render plain: url_for(**redirect_params) - else - # Redirect to the new record + else # Redirect to the new record redirect_to **redirect_params end end @@ -406,7 +405,6 @@ def allowed_in_report?(action, report, user = User.current) Array(permissions).any? { |permission| user.allowed_in_any_project?(permission) } end end - # rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity private @@ -509,11 +507,11 @@ def prepare_query def determine_settings if force_default? filters = default_filter_parameters - groups = default_group_parameters + groups = default_group_parameters session[report_engine.name.underscore.to_sym].try :delete, :name else filters = filter_params - groups = group_params + groups = group_params end cookie = session[report_engine.name.underscore.to_sym] || {} session[report_engine.name.underscore.to_sym] = cookie.merge(filters:, groups:) diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index 43d54de075c2..c1a40cdccd6d 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -135,13 +135,6 @@ def write_table(user_id, entries) } ) do |table| table.columns(0).borders = %i[top bottom left right] - table.rows(0).style do |c| - c.borders = c.borders + [:top] - c.font_style = :bold - end - table.rows(-1).style do |c| - c.borders = c.borders + [:bottom] - end table.columns(-1).style do |c| c.borders = c.borders + [:right] end @@ -151,6 +144,13 @@ def write_table(user_id, entries) c.padding = [0, 5, 8, 5] end end + table.rows(0).style do |c| + c.borders = c.borders + [:top] + c.font_style = :bold + end + table.rows(-1).style do |c| + c.borders = c.borders + [:bottom] + end end.draw end # rubocop:enable Metrics/AbcSize From a6fc8eeb70698554c26d6fc76717cdc56b8fa671 Mon Sep 17 00:00:00 2001 From: as-op Date: Tue, 26 Nov 2024 16:01:16 +0100 Subject: [PATCH 11/45] fix missing border on table split after page break --- .../cost_query/pdf/timesheet_generator.rb | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index c1a40cdccd6d..f22a01ea24b0 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -124,15 +124,16 @@ def write_table(user_id, entries) # TODO: write user on new page if table does not fit on the same write_user(user_id) pdf.make_table( - rows, header: false, - width: table_width, - column_widths: table_columns_widths, - cell_style: { - border_color: "BBBBBB", - border_width: 0.5, - borders: [:top], - padding: [5, 5, 8, 5] - } + rows, + header: false, + width: table_width, + column_widths: table_columns_widths, + cell_style: { + border_color: "BBBBBB", + border_width: 0.5, + borders: %i[top bottom], + padding: [5, 5, 8, 5] + } ) do |table| table.columns(0).borders = %i[top bottom left right] table.columns(-1).style do |c| @@ -142,17 +143,20 @@ def write_table(user_id, entries) if c.colspan > 1 c.borders = %i[left right] c.padding = [0, 5, 8, 5] + row_nr = c.row - 1 + values = table.columns(1..-1).rows(row_nr..row_nr) + values.each do |cell| + cell.borders = cell.borders - [:bottom] + end end end table.rows(0).style do |c| c.borders = c.borders + [:top] c.font_style = :bold end - table.rows(-1).style do |c| - c.borders = c.borders + [:bottom] - end end.draw end + # rubocop:enable Metrics/AbcSize def sorted_results @@ -169,6 +173,7 @@ def write_hr! end pdf.move_down(16) end + # rubocop:enable Metrics/AbcSize def write_heading! @@ -204,6 +209,6 @@ def with_times_column? end def with_cover? - true + false end end From 2708e0f5e0a38902a0665b8569d7bcce1b68392c Mon Sep 17 00:00:00 2001 From: as-op Date: Wed, 27 Nov 2024 11:46:54 +0100 Subject: [PATCH 12/45] fix missing border if comment is last line --- .../reporting/app/workers/cost_query/pdf/timesheet_generator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index f22a01ea24b0..8b248ced4b56 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -141,7 +141,7 @@ def write_table(user_id, entries) end table.columns(1).style do |c| if c.colspan > 1 - c.borders = %i[left right] + c.borders = %i[left right bottom] c.padding = [0, 5, 8, 5] row_nr = c.row - 1 values = table.columns(1..-1).rows(row_nr..row_nr) From 7b7233f296d97a75045e7929bf37acd206a681aa Mon Sep 17 00:00:00 2001 From: as-op Date: Wed, 27 Nov 2024 12:13:44 +0100 Subject: [PATCH 13/45] get rid of cached unsaved filters when a query is changed --- .../controllers/cost_reports_controller.rb | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/modules/reporting/app/controllers/cost_reports_controller.rb b/modules/reporting/app/controllers/cost_reports_controller.rb index ffaf80d5056d..4cdc4b59f227 100644 --- a/modules/reporting/app/controllers/cost_reports_controller.rb +++ b/modules/reporting/app/controllers/cost_reports_controller.rb @@ -83,7 +83,9 @@ def index def perform respond_to do |format| format.html do - session[report_engine.name.underscore.to_sym].try(:delete, :name) + session[session_name].try(:delete, :name) + # get rid of unsaved filters and grouping + store_query(@query) if @query&.id != session[session_name].try(:id) render locals: { menu_name: project_or_global_menu } end format.xls { export(:xls) } @@ -91,6 +93,10 @@ def perform end end + def session_name + report_engine.name.underscore.to_sym + end + def export(format) job_id = ::CostQuery::ScheduleExportService .new(user: current_user) @@ -255,7 +261,7 @@ def default_filter_parameters # Get the filter params with an optional project context def filter_params filters = http_filter_parameters if set_filter? - filters ||= session[report_engine.name.underscore.to_sym].try(:[], :filters) + filters ||= session[session_name].try(:[], :filters) filters ||= default_filter_parameters update_project_context!(filters) @@ -267,7 +273,7 @@ def filter_params # Return the active group bys def group_params groups = http_group_parameters if set_filter? - groups ||= session[report_engine.name.underscore.to_sym].try(:[], :groups) + groups ||= session[session_name].try(:[], :groups) groups || default_group_parameters end @@ -495,8 +501,8 @@ def force_default? # Prepare the query from the request def prepare_query determine_settings - @query = build_query(session[report_engine.name.underscore.to_sym][:filters], - session[report_engine.name.underscore.to_sym][:groups]) + @query = build_query(session[session_name][:filters], + session[session_name][:groups]) set_cost_type if @unit_id.present? end @@ -507,14 +513,14 @@ def prepare_query def determine_settings if force_default? filters = default_filter_parameters - groups = default_group_parameters - session[report_engine.name.underscore.to_sym].try :delete, :name + groups = default_group_parameters + session[session_name].try :delete, :name else filters = filter_params - groups = group_params + groups = group_params end - cookie = session[report_engine.name.underscore.to_sym] || {} - session[report_engine.name.underscore.to_sym] = cookie.merge(filters:, groups:) + cookie = session[session_name] || {} + session[session_name] = cookie.merge(filters:, groups:) end ## @@ -549,7 +555,8 @@ def store_query(_query) h end cookie[:name] = @query.name if @query.name - session[report_engine.name.underscore.to_sym] = cookie + cookie[:id] = @query.id + session[session_name] = cookie end ## From a0aebb1683f52310115ea33586c6677743fa31be Mon Sep 17 00:00:00 2001 From: as-op Date: Wed, 27 Nov 2024 12:31:41 +0100 Subject: [PATCH 14/45] move text into I18n --- .../app/workers/cost_query/pdf/timesheet_generator.rb | 10 ++++++++-- modules/reporting/config/locales/en.yml | 2 ++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index 8b248ced4b56..9a105d4c6242 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -15,7 +15,7 @@ def initialize(query, project, cost_types) end def heading - query.name || "Timesheet" + query.name || I18n.t(:"export.timesheet.timesheet") end def footer_title @@ -103,7 +103,13 @@ def build_table_rows(entries) # rubocop:enable Metrics/AbcSize def table_header_columns - with_times_column? ? ["Date", "Work package", "Time", "Hours", "Activity"] : ["Date", "Work package", "Hours", "Activity"] + [ + I18n.t(:"activerecord.attributes.time_entry.spent_on"), + I18n.t(:"activerecord.models.work_package"), + with_times_column? ? I18n.t(:"export.timesheet.time") : nil, + I18n.t(:"activerecord.attributes.time_entry.hours"), + I18n.t(:"activerecord.attributes.time_entry.activity") + ].compact end def table_columns_widths diff --git a/modules/reporting/config/locales/en.yml b/modules/reporting/config/locales/en.yml index bf5feeb9ea5a..1b56799a2c02 100644 --- a/modules/reporting/config/locales/en.yml +++ b/modules/reporting/config/locales/en.yml @@ -108,6 +108,8 @@ en: timesheet: title: "Your PDF timesheet export" button: "Export PDF timesheet" + timesheet: "Timesheet" + time: "Time" cost_reports: title: "Your Cost Reports XLS export" From e3ea7b932f9b503846dda8bb77987f3bee56b275 Mon Sep 17 00:00:00 2001 From: as-op Date: Wed, 27 Nov 2024 16:29:02 +0100 Subject: [PATCH 15/45] don't rely on prawn table for rowspan cells and page breaks; implement measuring, splitting and spanning --- .../cost_query/pdf/timesheet_generator.rb | 99 +++++++++++++++++-- 1 file changed, 90 insertions(+), 9 deletions(-) diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index 9a105d4c6242..1b555edcd46b 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -75,7 +75,7 @@ def all_entries # rubocop:disable Metrics/AbcSize def build_table_rows(entries) - rows = [] + rows = [table_header_columns] entries .group_by { |r| DateTime.parse(r.fields["spent_on"]) } .sort @@ -84,6 +84,7 @@ def build_table_rows(entries) lines.each do |r| day_rows.push( [ + { content: format_date(spent_on), rowspan: r.fields["comments"].present? ? 2 : 1 }, wp_subject(r.fields["work_package_id"]), with_times_column? ? "??:00-??:00" : nil, format_duration(r.fields["units"]), @@ -94,7 +95,8 @@ def build_table_rows(entries) day_rows.push ([{ content: r.fields["comments"], text_color: "636C76", colspan: table_columns_span }]) end end - day_rows[0].unshift({ content: format_date(spent_on), rowspan: day_rows.length }) + + # day_rows[0].unshift({ content: format_date(spent_on), rowspan: day_rows.length }) rows.concat(day_rows) end rows @@ -104,7 +106,7 @@ def build_table_rows(entries) def table_header_columns [ - I18n.t(:"activerecord.attributes.time_entry.spent_on"), + { content: I18n.t(:"activerecord.attributes.time_entry.spent_on"), rowspan: 1 }, I18n.t(:"activerecord.models.work_package"), with_times_column? ? I18n.t(:"export.timesheet.time") : nil, I18n.t(:"activerecord.attributes.time_entry.hours"), @@ -125,13 +127,10 @@ def table_columns_span end # rubocop:disable Metrics/AbcSize - def write_table(user_id, entries) - rows = [table_header_columns].concat(build_table_rows(entries)) - # TODO: write user on new page if table does not fit on the same - write_user(user_id) + def build_table(rows) pdf.make_table( rows, - header: false, + header: true, width: table_width, column_widths: table_columns_widths, cell_style: { @@ -160,11 +159,93 @@ def write_table(user_id, entries) c.borders = c.borders + [:top] c.font_style = :bold end - end.draw + end end + def split_group_rows(table_rows) + measure_table = build_table(table_rows) + groups = [] + index = 0 + while index < table_rows.length + row = table_rows[index] + rows = [row] + height = measure_table.row(index).height + index += 1 + if (row[0][:rowspan] || 1) > 1 + rows.push(table_rows[index]) + height += measure_table.row(index).height + index += 1 + end + groups.push({ rows:, height: }) + end + groups + end # rubocop:enable Metrics/AbcSize + def write_table(user_id, entries) + rows = build_table_rows(entries) + # prawn-table does not support splitting a rowspan cell on page break, so we have to merge the first column manually + # for easier handling existing rowspan cells are grouped as one row + grouped_rows = split_group_rows(rows) + # start a new page if the username would be printed alone at the end of the page + pdf.start_new_page if available_space_from_bottom < grouped_rows[0][:height] + grouped_rows[1][:height] + 20 + write_user(user_id) + write_grouped_tables(grouped_rows) + end + + def available_space_from_bottom + margin_bottom = pdf.options[:bottom_margin] + 20 + pdf.y - margin_bottom + end + + def write_grouped_tables(grouped_rows) + header_row = grouped_rows[0] + current_table = [] + current_table_height = 0 + grouped_rows.each do |grouped_row| + grouped_row_height = grouped_row[:height] + if current_table_height + grouped_row_height >= available_space_from_bottom + write_grouped_row_table(current_table) + pdf.start_new_page + current_table = [header_row] + current_table_height = header_row[:height] + end + current_table.push(grouped_row) + current_table_height += grouped_row_height + end + write_grouped_row_table(current_table) + pdf.move_down(28) + end + + def write_grouped_row_table(grouped_rows) + current_table = [] + merge_first_columns(grouped_rows) + grouped_rows.map! { |row| current_table.concat(row[:rows]) } + build_table(current_table).draw + end + + def merge_first_columns(grouped_rows) + last_row = grouped_rows[1] + index = 2 + while index < grouped_rows.length + grouped_row = grouped_rows[index] + last_row = merge_first_rows(grouped_row, last_row) + index += 1 + end + end + + def merge_first_rows(grouped_row, last_row) + grouped_cell = grouped_row[:rows][0][0] + last_cell = last_row[:rows][0][0] + if grouped_cell[:content] == last_cell[:content] + last_cell[:rowspan] += grouped_cell[:rowspan] + grouped_row[:rows][0].shift + last_row + else + grouped_row + end + end + def sorted_results query.each_direct_result.map(&:itself) end From 744d4af5a6211cef8c2edeee67905a4210e4c072 Mon Sep 17 00:00:00 2001 From: as-op Date: Thu, 28 Nov 2024 11:52:26 +0100 Subject: [PATCH 16/45] obey rubocop --- .../work_package/pdf_export/common/common.rb | 8 +++ .../controllers/cost_reports_controller.rb | 45 +++++++------ .../app/models/cost_query/sql_statement.rb | 6 +- .../cost_query/pdf/timesheet_generator.rb | 65 +++++++++++-------- 4 files changed, 76 insertions(+), 48 deletions(-) diff --git a/app/models/work_package/pdf_export/common/common.rb b/app/models/work_package/pdf_export/common/common.rb index 7ebeeb899ef4..7a6c07fc1197 100644 --- a/app/models/work_package/pdf_export/common/common.rb +++ b/app/models/work_package/pdf_export/common/common.rb @@ -285,4 +285,12 @@ def footer_date def current_page_nr pdf.page_number + @page_count - (with_cover? ? 1 : 0) end + + def write_horizontal_line(y_position, height, color) + draw_horizontal_line( + y_position, + pdf.bounds.left, pdf.bounds.right, + height, color + ) + end end diff --git a/modules/reporting/app/controllers/cost_reports_controller.rb b/modules/reporting/app/controllers/cost_reports_controller.rb index 4cdc4b59f227..62ca09c0d943 100644 --- a/modules/reporting/app/controllers/cost_reports_controller.rb +++ b/modules/reporting/app/controllers/cost_reports_controller.rb @@ -76,25 +76,20 @@ def check_cache def index table + return if performed? - perform unless performed? - end - - def perform respond_to do |format| - format.html do - session[session_name].try(:delete, :name) - # get rid of unsaved filters and grouping - store_query(@query) if @query&.id != session[session_name].try(:id) - render locals: { menu_name: project_or_global_menu } - end + format.html { render_html } format.xls { export(:xls) } format.pdf { export(:pdf) } end end - def session_name - report_engine.name.underscore.to_sym + def render_html + session[session_name].try(:delete, :name) + # get rid of unsaved filters and grouping + store_query(@query) if @query&.id != session[session_name].try(:id) + render locals: { menu_name: project_or_global_menu } end def export(format) @@ -546,17 +541,25 @@ def build_query(filters, groups = {}) # Store query in the session def store_query(_query) cookie = {} - cookie[:groups] = @query.group_bys.inject({}) do |h, group| - ((h[:"#{group.type}s"] ||= []) << group.underscore_name.to_sym) && h - end - cookie[:filters] = @query.filters.inject(operators: {}, values: {}) do |h, filter| + cookie[:groups] = cookie_groups + cookie[:filters] = cookie_filters + cookie[:name] = @query.name if @query.name + cookie[:id] = @query.id + session[session_name] = cookie + end + + def cookie_filters + @query.filters.inject(operators: {}, values: {}) do |h, filter| h[:operators][filter.underscore_name.to_sym] = filter.operator.to_s h[:values][filter.underscore_name.to_sym] = filter.values h end - cookie[:name] = @query.name if @query.name - cookie[:id] = @query.id - session[session_name] = cookie + end + + def cookie_groups + @query.group_bys.inject({}) do |h, group| + ((h[:"#{group.type}s"] ||= []) << group.underscore_name.to_sym) && h + end end ## @@ -589,4 +592,8 @@ def find_optional_report(query = "1=0") end rescue ActiveRecord::RecordNotFound end + + def session_name + report_engine.name.underscore.to_sym + end end diff --git a/modules/reporting/app/models/cost_query/sql_statement.rb b/modules/reporting/app/models/cost_query/sql_statement.rb index 973eaf8d14c7..d43b2dc2920c 100644 --- a/modules/reporting/app/models/cost_query/sql_statement.rb +++ b/modules/reporting/app/models/cost_query/sql_statement.rb @@ -74,6 +74,8 @@ def to_s # cost_type_id | -1 | cost_type_id # type | "TimeEntry" | "CostEntry" # count | 1 | 1 + # start_time | start_time | nil + # time_zone | time_zone | nil # # Also: This _should_ handle joining activities and cost_types, as the logic differs for time_entries # and cost_entries. @@ -102,7 +104,7 @@ def self.unified_entry(model) # # @param [CostQuery::SqlStatement] query The statement to adjust def self.unify_time_entries(query) - query.select :activity_id, :logged_by_id, units: :hours, cost_type_id: -1 + query.select :activity_id, :logged_by_id, :start_time, :time_zone, units: :hours, cost_type_id: -1 query.select cost_type: quoted_label(:caption_labor) end @@ -111,7 +113,7 @@ def self.unify_time_entries(query) # # @param [CostQuery::SqlStatement] query The statement to adjust def self.unify_cost_entries(query) - query.select :units, :cost_type_id, :logged_by_id, activity_id: -1 + query.select :units, :cost_type_id, :logged_by_id, activity_id: -1, start_time: nil, time_zone: nil query.select cost_type: "cost_types.name" query.join CostType end diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index 1b555edcd46b..aac8e33b6f19 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -80,30 +80,38 @@ def build_table_rows(entries) .group_by { |r| DateTime.parse(r.fields["spent_on"]) } .sort .each do |spent_on, lines| - day_rows = [] - lines.each do |r| - day_rows.push( - [ - { content: format_date(spent_on), rowspan: r.fields["comments"].present? ? 2 : 1 }, - wp_subject(r.fields["work_package_id"]), - with_times_column? ? "??:00-??:00" : nil, - format_duration(r.fields["units"]), - activity_name(r.fields["activity_id"]) - ].compact - ) - if r.fields["comments"].present? - day_rows.push ([{ content: r.fields["comments"], text_color: "636C76", colspan: table_columns_span }]) - end - end - - # day_rows[0].unshift({ content: format_date(spent_on), rowspan: day_rows.length }) - rows.concat(day_rows) + rows.concat(build_table_day_rows(spent_on, lines)) end rows end # rubocop:enable Metrics/AbcSize + def build_table_day_rows(spent_on, lines) + day_rows = [] + lines.each do |r| + day_rows.push(build_table_row(spent_on, r)) + if r.fields["comments"].present? + day_rows.push(build_table_row_comment(r)) + end + end + day_rows + end + + def build_table_row(spent_on, result_entry) + [ + { content: format_date(spent_on), rowspan: result_entry.fields["comments"].present? ? 2 : 1 }, + wp_subject(result_entry.fields["work_package_id"]), + with_times_column? ? format_spent_on_time(result_entry) : nil, + format_duration(result_entry.fields["units"]), + activity_name(result_entry.fields["activity_id"]) + ].compact + end + + def build_table_row_comment(result_entry) + [{ content: result_entry.fields["comments"], text_color: "636C76", colspan: table_columns_span }] + end + def table_header_columns [ { content: I18n.t(:"activerecord.attributes.time_entry.spent_on"), rowspan: 1 }, @@ -180,6 +188,7 @@ def split_group_rows(table_rows) end groups end + # rubocop:enable Metrics/AbcSize def write_table(user_id, entries) @@ -250,19 +259,12 @@ def sorted_results query.each_direct_result.map(&:itself) end - # rubocop:disable Metrics/AbcSize def write_hr! hr_style = styles.cover_header_border - pdf.stroke do - pdf.line_width = hr_style[:height] - pdf.stroke_color hr_style[:color] - pdf.stroke_horizontal_line pdf.bounds.left, pdf.bounds.right, at: pdf.cursor - end + write_horizontal_line(pdf.cursor, hr_style[:height], hr_style[:color]) pdf.move_down(16) end - # rubocop:enable Metrics/AbcSize - def write_heading! pdf.formatted_text([{ text: heading, size: 26, style: :bold }]) pdf.move_down(2) @@ -291,11 +293,20 @@ def format_duration(hours) "#{hours}h" end + def format_spent_on_time(_result_entry) + # TODO implement times column + # date = result_entry.fields["spent_on"] + # hours = result_entry.fields["units"] + # start_time = result_entry.fields["start_time"] + # time_zone = result_entry.fields["time_zone"] + "" + end + def with_times_column? true end def with_cover? - false + true end end From 02dd29f0651b10616f6d0d06f9b58dd18f99ca94 Mon Sep 17 00:00:00 2001 From: as-op Date: Thu, 28 Nov 2024 12:08:03 +0100 Subject: [PATCH 17/45] obey rubocop --- .../app/controllers/cost_reports_controller.rb | 18 +++++++++--------- .../cost_query/pdf/timesheet_generator.rb | 4 ---- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/modules/reporting/app/controllers/cost_reports_controller.rb b/modules/reporting/app/controllers/cost_reports_controller.rb index 62ca09c0d943..d66a649596ac 100644 --- a/modules/reporting/app/controllers/cost_reports_controller.rb +++ b/modules/reporting/app/controllers/cost_reports_controller.rb @@ -506,16 +506,16 @@ def prepare_query # Determine the query settings the current request and save it to # the session. def determine_settings - if force_default? - filters = default_filter_parameters - groups = default_group_parameters - session[session_name].try :delete, :name - else - filters = filter_params - groups = group_params - end + return reset_settings if force_default? + + cookie = session[session_name] || {} + session[session_name] = cookie.merge(filters: filter_params, groups: group_params) + end + + def reset_settings + session[session_name].try :delete, :name cookie = session[session_name] || {} - session[session_name] = cookie.merge(filters:, groups:) + session[session_name] = cookie.merge(filters: default_filter_parameters, groups: default_group_parameters) end ## diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index aac8e33b6f19..6fe23c27c92a 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -73,7 +73,6 @@ def all_entries .filter { |r| r.fields["type"] == "TimeEntry" } end - # rubocop:disable Metrics/AbcSize def build_table_rows(entries) rows = [table_header_columns] entries @@ -85,8 +84,6 @@ def build_table_rows(entries) rows end - # rubocop:enable Metrics/AbcSize - def build_table_day_rows(spent_on, lines) day_rows = [] lines.each do |r| @@ -188,7 +185,6 @@ def split_group_rows(table_rows) end groups end - # rubocop:enable Metrics/AbcSize def write_table(user_id, entries) From 6db6058691e4b8f9aeb02523c2e7910771c8727b Mon Sep 17 00:00:00 2001 From: as-op Date: Thu, 28 Nov 2024 16:17:37 +0100 Subject: [PATCH 18/45] text ellipsis for work package subject cell --- .../work_package/pdf_export/common/common.rb | 7 ++++++ .../cost_query/pdf/timesheet_generator.rb | 23 +++++++++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/app/models/work_package/pdf_export/common/common.rb b/app/models/work_package/pdf_export/common/common.rb index 7a6c07fc1197..63c055a40044 100644 --- a/app/models/work_package/pdf_export/common/common.rb +++ b/app/models/work_package/pdf_export/common/common.rb @@ -164,6 +164,13 @@ def draw_text_multiline_part(line, text_style, x_position, y_position) measure_text_height(line, text_style) end + def ellipsis_if_longer(text, available_width, text_style) + title_text_width = measure_text_width(text, text_style) + return text if title_text_width < available_width + + truncate_ellipsis(text, available_width, text_style) + end + def truncate_ellipsis(text, available_width, text_style) line = text.dup while line.present? && (measure_text_width("#{line}...", text_style) > available_width) diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index 6fe23c27c92a..1af613255cc8 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -123,6 +123,10 @@ def table_columns_widths with_times_column? ? [80, 193, 80, 70, 100] : [80, 270, 70, 100] end + def table_column_workpackage_text_width + (with_times_column? ? 193 : 270) - 10 # - padding + end + def table_width table_columns_widths.sum end @@ -131,6 +135,10 @@ def table_columns_span with_times_column? ? 4 : 3 end + def table_cell_style_font_size + 12 + end + # rubocop:disable Metrics/AbcSize def build_table(rows) pdf.make_table( @@ -139,6 +147,7 @@ def build_table(rows) width: table_width, column_widths: table_columns_widths, cell_style: { + size: table_cell_style_font_size, border_color: "BBBBBB", border_width: 0.5, borders: %i[top bottom], @@ -185,6 +194,7 @@ def split_group_rows(table_rows) end groups end + # rubocop:enable Metrics/AbcSize def write_table(user_id, entries) @@ -193,8 +203,8 @@ def write_table(user_id, entries) # for easier handling existing rowspan cells are grouped as one row grouped_rows = split_group_rows(rows) # start a new page if the username would be printed alone at the end of the page - pdf.start_new_page if available_space_from_bottom < grouped_rows[0][:height] + grouped_rows[1][:height] + 20 - write_user(user_id) + pdf.start_new_page if available_space_from_bottom < grouped_rows[0][:height] + grouped_rows[1][:height] + username_height + write_username(user_id) write_grouped_tables(grouped_rows) end @@ -266,7 +276,11 @@ def write_heading! pdf.move_down(2) end - def write_user(user_id) + def username_height + 20 + 10 + end + + def write_username(user_id) pdf.formatted_text([{ text: user_name(user_id), size: 20 }]) pdf.move_down(10) end @@ -280,7 +294,8 @@ def activity_name(activity_id) end def wp_subject(wp_id) - WorkPackage.find(wp_id).subject + text = WorkPackage.find(wp_id).subject + ellipsis_if_longer(text, table_column_workpackage_text_width, { size: table_cell_style_font_size }) end def format_duration(hours) From c9e35f93a60e96546618ad126bb3de954f08c14d Mon Sep 17 00:00:00 2001 From: as-op Date: Mon, 2 Dec 2024 12:01:31 +0100 Subject: [PATCH 19/45] reduce table font size, remove ellipsing, entry comment in italic font style, cleanup constants --- .../cost_query/pdf/timesheet_generator.rb | 59 +++++++++++-------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index 1af613255cc8..87c5e7ef828d 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -5,6 +5,21 @@ class CostQuery::PDF::TimesheetGenerator include WorkPackage::PDFExport::Export::Page include WorkPackage::PDFExport::Export::Style + H1_FONT_SIZE = 26 + H1_MARGIN_BOTTOM = 2 + HR_MARGIN_BOTTOM = 16 + TABLE_CELL_FONT_SIZE = 10 + TABLE_CELL_BORDER_COLOR = "BBBBBB" + TABLE_CELL_PADDING = 4 + COMMENT_FONT_COLOR = "636C76" + H2_FONT_SIZE = 20 + H2_MARGIN_BOTTOM = 10 + COLUMN_DATE_WIDTH = 66 + COLUMN_ACTIVITY_WIDTH = 100 + COLUMN_HOURS_WIDTH = 60 + COLUMN_TIME_WIDTH = 80 + COLUMN_WP_WIDTH = 217 + attr_accessor :pdf def initialize(query, project, cost_types) @@ -106,7 +121,12 @@ def build_table_row(spent_on, result_entry) end def build_table_row_comment(result_entry) - [{ content: result_entry.fields["comments"], text_color: "636C76", colspan: table_columns_span }] + [{ + content: result_entry.fields["comments"], + text_color: COMMENT_FONT_COLOR, + font_style: :italic, + colspan: table_columns_span + }] end def table_header_columns @@ -120,11 +140,9 @@ def table_header_columns end def table_columns_widths - with_times_column? ? [80, 193, 80, 70, 100] : [80, 270, 70, 100] - end - - def table_column_workpackage_text_width - (with_times_column? ? 193 : 270) - 10 # - padding + @table_columns_widths ||= with_times_column? ? + [COLUMN_DATE_WIDTH, COLUMN_WP_WIDTH, COLUMN_TIME_WIDTH, COLUMN_HOURS_WIDTH, COLUMN_ACTIVITY_WIDTH] : + [COLUMN_DATE_WIDTH, COLUMN_WP_WIDTH + COLUMN_TIME_WIDTH, COLUMN_HOURS_WIDTH, COLUMN_ACTIVITY_WIDTH] end def table_width @@ -132,11 +150,7 @@ def table_width end def table_columns_span - with_times_column? ? 4 : 3 - end - - def table_cell_style_font_size - 12 + table_columns_widths.size end # rubocop:disable Metrics/AbcSize @@ -147,11 +161,11 @@ def build_table(rows) width: table_width, column_widths: table_columns_widths, cell_style: { - size: table_cell_style_font_size, - border_color: "BBBBBB", + size: TABLE_CELL_FONT_SIZE, + border_color: TABLE_CELL_BORDER_COLOR, border_width: 0.5, borders: %i[top bottom], - padding: [5, 5, 8, 5] + padding: [TABLE_CELL_PADDING, TABLE_CELL_PADDING, TABLE_CELL_PADDING + 2, TABLE_CELL_PADDING] } ) do |table| table.columns(0).borders = %i[top bottom left right] @@ -209,7 +223,7 @@ def write_table(user_id, entries) end def available_space_from_bottom - margin_bottom = pdf.options[:bottom_margin] + 20 + margin_bottom = pdf.options[:bottom_margin] + 20 # 20 is the safety margin pdf.y - margin_bottom end @@ -268,12 +282,12 @@ def sorted_results def write_hr! hr_style = styles.cover_header_border write_horizontal_line(pdf.cursor, hr_style[:height], hr_style[:color]) - pdf.move_down(16) + pdf.move_down(HR_MARGIN_BOTTOM) end def write_heading! - pdf.formatted_text([{ text: heading, size: 26, style: :bold }]) - pdf.move_down(2) + pdf.formatted_text([{ text: heading, size: H1_FONT_SIZE, style: :bold }]) + pdf.move_down(H1_MARGIN_BOTTOM) end def username_height @@ -281,8 +295,8 @@ def username_height end def write_username(user_id) - pdf.formatted_text([{ text: user_name(user_id), size: 20 }]) - pdf.move_down(10) + pdf.formatted_text([{ text: user_name(user_id), size: H2_FONT_SIZE }]) + pdf.move_down(H2_MARGIN_BOTTOM) end def user_name(user_id) @@ -294,8 +308,7 @@ def activity_name(activity_id) end def wp_subject(wp_id) - text = WorkPackage.find(wp_id).subject - ellipsis_if_longer(text, table_column_workpackage_text_width, { size: table_cell_style_font_size }) + WorkPackage.find(wp_id).subject end def format_duration(hours) @@ -314,7 +327,7 @@ def format_spent_on_time(_result_entry) end def with_times_column? - true + Setting.allow_tracking_start_and_end_times end def with_cover? From 51bc5816718afc28f37f94bb55dca84877a61676 Mon Sep 17 00:00:00 2001 From: as-op Date: Mon, 2 Dec 2024 12:25:04 +0100 Subject: [PATCH 20/45] remove not needed functions --- .../workers/cost_query/pdf/timesheet_generator.rb | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index 87c5e7ef828d..a2783031aa97 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -125,7 +125,7 @@ def build_table_row_comment(result_entry) content: result_entry.fields["comments"], text_color: COMMENT_FONT_COLOR, font_style: :italic, - colspan: table_columns_span + colspan: table_columns_widths.size }] end @@ -145,20 +145,12 @@ def table_columns_widths [COLUMN_DATE_WIDTH, COLUMN_WP_WIDTH + COLUMN_TIME_WIDTH, COLUMN_HOURS_WIDTH, COLUMN_ACTIVITY_WIDTH] end - def table_width - table_columns_widths.sum - end - - def table_columns_span - table_columns_widths.size - end - # rubocop:disable Metrics/AbcSize def build_table(rows) pdf.make_table( rows, header: true, - width: table_width, + width: table_columns_widths.sum, column_widths: table_columns_widths, cell_style: { size: TABLE_CELL_FONT_SIZE, From 103aa0b2a2d7b3baaef0975a13fab4bd0f744168 Mon Sep 17 00:00:00 2001 From: as-op Date: Mon, 2 Dec 2024 16:09:59 +0100 Subject: [PATCH 21/45] use TimeEntry instead of CostQuery entry; use time if available --- .../cost_query/pdf/timesheet_generator.rb | 106 ++++++++---------- 1 file changed, 49 insertions(+), 57 deletions(-) diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index a2783031aa97..1c0f1d5fbfea 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -9,16 +9,16 @@ class CostQuery::PDF::TimesheetGenerator H1_MARGIN_BOTTOM = 2 HR_MARGIN_BOTTOM = 16 TABLE_CELL_FONT_SIZE = 10 - TABLE_CELL_BORDER_COLOR = "BBBBBB" + TABLE_CELL_BORDER_COLOR = "BBBBBB".freeze TABLE_CELL_PADDING = 4 - COMMENT_FONT_COLOR = "636C76" + COMMENT_FONT_COLOR = "636C76".freeze H2_FONT_SIZE = 20 H2_MARGIN_BOTTOM = 10 COLUMN_DATE_WIDTH = 66 COLUMN_ACTIVITY_WIDTH = 100 COLUMN_HOURS_WIDTH = 60 - COLUMN_TIME_WIDTH = 80 - COLUMN_WP_WIDTH = 217 + COLUMN_TIME_WIDTH = 100 + COLUMN_WP_WIDTH = 200 attr_accessor :pdf @@ -75,9 +75,9 @@ def render_doc def write_entries! all_entries - .group_by { |r| r.fields["user_id"] } - .each do |user_id, result| - write_table(user_id, result) + .group_by(&:user) + .each do |user, result| + write_table(user, result) end end @@ -86,12 +86,13 @@ def all_entries .each_direct_result .map(&:itself) .filter { |r| r.fields["type"] == "TimeEntry" } + .map { |r| TimeEntry.find(r.fields["id"]) } end def build_table_rows(entries) rows = [table_header_columns] entries - .group_by { |r| DateTime.parse(r.fields["spent_on"]) } + .group_by(&:spent_on) .sort .each do |spent_on, lines| rows.concat(build_table_day_rows(spent_on, lines)) @@ -99,34 +100,34 @@ def build_table_rows(entries) rows end - def build_table_day_rows(spent_on, lines) + def build_table_day_rows(spent_on, entries) day_rows = [] - lines.each do |r| - day_rows.push(build_table_row(spent_on, r)) - if r.fields["comments"].present? - day_rows.push(build_table_row_comment(r)) + entries.each do |entry| + day_rows.push(build_table_row(spent_on, entry)) + if entry.comments.present? + day_rows.push(build_table_row_comment(entry)) end end day_rows end - def build_table_row(spent_on, result_entry) + def build_table_row(spent_on, entry) [ - { content: format_date(spent_on), rowspan: result_entry.fields["comments"].present? ? 2 : 1 }, - wp_subject(result_entry.fields["work_package_id"]), - with_times_column? ? format_spent_on_time(result_entry) : nil, - format_duration(result_entry.fields["units"]), - activity_name(result_entry.fields["activity_id"]) + { content: format_date(spent_on), rowspan: entry.comments.present? ? 2 : 1 }, + entry.work_package.subject || "", + with_times_column? ? format_spent_on_time(entry) : nil, + format_duration(entry.hours), + entry.activity&.name || "" ].compact end - def build_table_row_comment(result_entry) + def build_table_row_comment(entry) [{ - content: result_entry.fields["comments"], - text_color: COMMENT_FONT_COLOR, - font_style: :italic, - colspan: table_columns_widths.size - }] + content: entry.comments, + text_color: COMMENT_FONT_COLOR, + font_style: :italic, + colspan: table_columns_widths.size + }] end def table_header_columns @@ -140,12 +141,15 @@ def table_header_columns end def table_columns_widths - @table_columns_widths ||= with_times_column? ? - [COLUMN_DATE_WIDTH, COLUMN_WP_WIDTH, COLUMN_TIME_WIDTH, COLUMN_HOURS_WIDTH, COLUMN_ACTIVITY_WIDTH] : - [COLUMN_DATE_WIDTH, COLUMN_WP_WIDTH + COLUMN_TIME_WIDTH, COLUMN_HOURS_WIDTH, COLUMN_ACTIVITY_WIDTH] + @table_columns_widths ||= if with_times_column? + [COLUMN_DATE_WIDTH, COLUMN_WP_WIDTH, COLUMN_TIME_WIDTH, COLUMN_HOURS_WIDTH, + COLUMN_ACTIVITY_WIDTH] + else + [COLUMN_DATE_WIDTH, COLUMN_WP_WIDTH + COLUMN_TIME_WIDTH, COLUMN_HOURS_WIDTH, + COLUMN_ACTIVITY_WIDTH] + end end - # rubocop:disable Metrics/AbcSize def build_table(rows) pdf.make_table( rows, @@ -161,13 +165,13 @@ def build_table(rows) } ) do |table| table.columns(0).borders = %i[top bottom left right] - table.columns(-1).style do |c| + table.columns(table_columns_widths.length - 1).style do |c| c.borders = c.borders + [:right] end table.columns(1).style do |c| if c.colspan > 1 c.borders = %i[left right bottom] - c.padding = [0, 5, 8, 5] + c.padding = [0, TABLE_CELL_PADDING, TABLE_CELL_PADDING + 2, TABLE_CELL_PADDING] row_nr = c.row - 1 values = table.columns(1..-1).rows(row_nr..row_nr) values.each do |cell| @@ -201,16 +205,14 @@ def split_group_rows(table_rows) groups end - # rubocop:enable Metrics/AbcSize - - def write_table(user_id, entries) + def write_table(user, entries) rows = build_table_rows(entries) # prawn-table does not support splitting a rowspan cell on page break, so we have to merge the first column manually # for easier handling existing rowspan cells are grouped as one row grouped_rows = split_group_rows(rows) # start a new page if the username would be printed alone at the end of the page pdf.start_new_page if available_space_from_bottom < grouped_rows[0][:height] + grouped_rows[1][:height] + username_height - write_username(user_id) + write_username(user) write_grouped_tables(grouped_rows) end @@ -286,36 +288,26 @@ def username_height 20 + 10 end - def write_username(user_id) - pdf.formatted_text([{ text: user_name(user_id), size: H2_FONT_SIZE }]) + def write_username(user) + pdf.formatted_text([{ text: user.name, size: H2_FONT_SIZE }]) pdf.move_down(H2_MARGIN_BOTTOM) end - def user_name(user_id) - User.select_for_name.find(user_id).name - end - - def activity_name(activity_id) - TimeEntryActivity.find(activity_id).name - end - - def wp_subject(wp_id) - WorkPackage.find(wp_id).subject - end - def format_duration(hours) return "" if hours < 0 - "#{hours}h" + ::OpenProject::Common::DurationComponent.new(hours.to_f, :hours, abbreviated: true).text end - def format_spent_on_time(_result_entry) - # TODO implement times column - # date = result_entry.fields["spent_on"] - # hours = result_entry.fields["units"] - # start_time = result_entry.fields["start_time"] - # time_zone = result_entry.fields["time_zone"] - "" + def format_spent_on_time(entry) + start_timestamp = entry.start_timestamp + return "" if start_timestamp.nil? + + result = format_time(start_timestamp, include_date: false) + end_timestamp = entry.end_timestamp + return result if end_timestamp.nil? + + "#{result} - #{format_time(end_timestamp, include_date: false)}" end def with_times_column? From 02be82e4e3d23898abb1d6217f9276ff2f30037e Mon Sep 17 00:00:00 2001 From: as-op Date: Mon, 2 Dec 2024 16:24:19 +0100 Subject: [PATCH 22/45] obey rubocop --- .../cost_query/pdf/timesheet_generator.rb | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index 1c0f1d5fbfea..dfabe2390a16 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -164,25 +164,29 @@ def build_table(rows) padding: [TABLE_CELL_PADDING, TABLE_CELL_PADDING, TABLE_CELL_PADDING + 2, TABLE_CELL_PADDING] } ) do |table| - table.columns(0).borders = %i[top bottom left right] - table.columns(table_columns_widths.length - 1).style do |c| - c.borders = c.borders + [:right] - end - table.columns(1).style do |c| - if c.colspan > 1 - c.borders = %i[left right bottom] - c.padding = [0, TABLE_CELL_PADDING, TABLE_CELL_PADDING + 2, TABLE_CELL_PADDING] - row_nr = c.row - 1 - values = table.columns(1..-1).rows(row_nr..row_nr) - values.each do |cell| - cell.borders = cell.borders - [:bottom] - end + adjust_prawn_table_cell_borders(table) + end + end + + def adjust_prawn_table_cell_borders(table) + table.columns(0).borders = %i[top bottom left right] + table.columns(table_columns_widths.length - 1).style do |c| + c.borders = c.borders + [:right] + end + table.columns(1).style do |c| + if c.colspan > 1 + c.borders = %i[left right bottom] + c.padding = [0, TABLE_CELL_PADDING, TABLE_CELL_PADDING + 2, TABLE_CELL_PADDING] + row_nr = c.row - 1 + values = table.columns(1..-1).rows(row_nr..row_nr) + values.each do |cell| + cell.borders = cell.borders - [:bottom] end end - table.rows(0).style do |c| - c.borders = c.borders + [:top] - c.font_style = :bold - end + end + table.rows(0).style do |c| + c.borders = c.borders + [:top] + c.font_style = :bold end end From 58957f23a631b43fd22f8eef35057a259258e07e Mon Sep 17 00:00:00 2001 From: as-op Date: Wed, 4 Dec 2024 10:38:04 +0100 Subject: [PATCH 23/45] obey rubocop --- .../cost_query/pdf/timesheet_generator.rb | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index dfabe2390a16..8d9239933b20 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -164,15 +164,22 @@ def build_table(rows) padding: [TABLE_CELL_PADDING, TABLE_CELL_PADDING, TABLE_CELL_PADDING + 2, TABLE_CELL_PADDING] } ) do |table| - adjust_prawn_table_cell_borders(table) + adjust_borders_first_column(table) + adjust_borders_last_column(table) + adjust_borders_spanned_column(table) + adjust_border_header_row(table) end end - def adjust_prawn_table_cell_borders(table) + def adjust_borders_first_column(table) table.columns(0).borders = %i[top bottom left right] - table.columns(table_columns_widths.length - 1).style do |c| - c.borders = c.borders + [:right] - end + end + + def adjust_borders_last_column(table) + table.columns(0).borders = %i[top bottom left right] + end + + def adjust_borders_spanned_column(table) table.columns(1).style do |c| if c.colspan > 1 c.borders = %i[left right bottom] @@ -184,6 +191,9 @@ def adjust_prawn_table_cell_borders(table) end end end + end + + def adjust_border_header_row(table) table.rows(0).style do |c| c.borders = c.borders + [:top] c.font_style = :bold From f0862e8374bb161a2adb422294b8529742d8e47e Mon Sep 17 00:00:00 2001 From: as-op Date: Wed, 4 Dec 2024 10:38:19 +0100 Subject: [PATCH 24/45] add tests for export job and costs view download --- .../spec/features/export_timesheet_spec.rb | 65 ++++++++++++ .../workers/cost_query/pdf/export_job_spec.rb | 100 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 modules/reporting/spec/features/export_timesheet_spec.rb create mode 100644 modules/reporting/spec/workers/cost_query/pdf/export_job_spec.rb diff --git a/modules/reporting/spec/features/export_timesheet_spec.rb b/modules/reporting/spec/features/export_timesheet_spec.rb new file mode 100644 index 000000000000..df3db35ad348 --- /dev/null +++ b/modules/reporting/spec/features/export_timesheet_spec.rb @@ -0,0 +1,65 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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_relative "../spec_helper" +require_relative "support/pages/cost_report_page" +require "pdf/inspector" + +RSpec.describe "Timesheet PDF export", :js do + shared_let(:project) { create(:project) } + shared_let(:user) { create(:admin) } + shared_let(:cost_type) { create(:cost_type, name: "Post-war", unit: "cap", unit_plural: "caps") } + shared_let(:work_package) { create(:work_package, project:, subject: "Some task") } + shared_let(:cost_entry) { create(:cost_entry, user:, work_package:, project:, cost_type:) } + let(:report_page) { Pages::CostReportPage.new project } + + subject { @download_list.refresh_from(page).latest_download.to_s } # rubocop:disable RSpec/InstanceVariable + + before do + @download_list = DownloadList.new + login_as(user) + end + + after do + DownloadList.clear + end + + it "can download the PDF" do + report_page.visit! + click_on I18n.t("export.timesheet.button") + + expect(page).to have_content I18n.t("job_status_dialog.generic_messages.in_queue"), + wait: 10 + perform_enqueued_jobs + + expect(page).to have_text(I18n.t("export.succeeded"), + wait: 10) + + expect(subject).to have_text(".pdf") + end +end diff --git a/modules/reporting/spec/workers/cost_query/pdf/export_job_spec.rb b/modules/reporting/spec/workers/cost_query/pdf/export_job_spec.rb new file mode 100644 index 000000000000..f18a56bf2c85 --- /dev/null +++ b/modules/reporting/spec/workers/cost_query/pdf/export_job_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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" + +RSpec.describe CostQuery::PDF::ExportJob do + let(:user) { build_stubbed(:user) } + let(:project) { build_stubbed(:project) } + + let(:initial_filter_params) do + { + project_context: project.id, + operators: { + user_id: "=", spent_on: ">d", project_id: "=" + }, + values: { + user_id: ["me"], spent_on: ["2024-03-30", ""], project_id: [project.id.to_s] + } + } + end + + before do + mock_permissions_for(user, &:allow_everything) + end + + # Performs a cost export with the given extra filters. + # + # @param extra_filters [Hash] A hash of attribute names and operator/value + # pairs to add to the filter. + # Example: `{ custom_field_17: ["=", "value"], user_id: ["=", "me"]}` + def perform_cost_export(extra_filters: {}) + query = initial_filter_params.deep_dup + extra_filters.each do |attribute_name, operator_and_value| + operator, value = operator_and_value + query[:operators][attribute_name] = operator + query[:values][attribute_name] = value + end + job = described_class.new( + export: CostQuery::Export.create, + user:, + mime_type: :pdf, + query:, + project:, + cost_types: [-1, 0] + ) + job.perform_now + job + end + + RSpec::Matchers.define :have_one_attachment_with_content_type do |expected_content_type| + def attachments(export_job) + export_job.status_reference.attachments + end + + match do |export_job| + attachments_content_types = attachments(export_job).pluck(:content_type) + attachments_content_types == [expected_content_type] + end + + failure_message do |export_job| + attachments_content_types = attachments(export_job).pluck(:content_type) + "expected that #{actual} would have one attachment with mime type #{expected.inspect}, " \ + "got #{attachments_content_types.inspect} instead" + end + end + + it "generates an PDF export successfully" do + job = perform_cost_export + + expect(job.job_status).to be_success, job.job_status.message + expect(job).to have_one_attachment_with_content_type("application/pdf") + end +end From cc588d4e4d58558dda2c2ad421046e5c42ca3126 Mon Sep 17 00:00:00 2001 From: as-op Date: Wed, 4 Dec 2024 10:55:53 +0100 Subject: [PATCH 25/45] fix last column borders --- .../app/workers/cost_query/pdf/timesheet_generator.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index 8d9239933b20..02954c35151f 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -176,7 +176,9 @@ def adjust_borders_first_column(table) end def adjust_borders_last_column(table) - table.columns(0).borders = %i[top bottom left right] + table.columns(table_columns_widths.length - 1).style do |c| + c.borders = c.borders + [:right] + end end def adjust_borders_spanned_column(table) From c94d80423474639ef4d3ae14a085fc5d00e4f439 Mon Sep 17 00:00:00 2001 From: as-op Date: Wed, 4 Dec 2024 12:23:30 +0100 Subject: [PATCH 26/45] modify pdf cover page to display export specific values --- .../work_package/pdf_export/export/cover.rb | 33 +++++++++++------ .../pdf_export/export/schema.json | 36 +++++++++++++++++++ .../pdf_export/export/standard.yml | 9 +++-- .../work_package/pdf_export/export/style.rb | 12 +++++++ .../pdf_export/work_package_list_to_pdf.rb | 16 +++++++++ .../app/workers/cost_query/pdf/export_job.rb | 6 +--- .../cost_query/pdf/timesheet_generator.rb | 32 +++++++++++++---- 7 files changed, 119 insertions(+), 25 deletions(-) diff --git a/app/models/work_package/pdf_export/export/cover.rb b/app/models/work_package/pdf_export/export/cover.rb index e50d0c101c30..15f70188e9be 100644 --- a/app/models/work_package/pdf_export/export/cover.rb +++ b/app/models/work_package/pdf_export/export/cover.rb @@ -40,17 +40,19 @@ def write_cover_page! def write_cover_hero max_width = pdf.bounds.width - styles.cover_hero_padding[:right_padding] float_top = write_background_image - float_top -= write_hero_title(float_top, max_width) if project - float_top -= write_hero_heading(float_top, max_width) - write_hero_subheading(float_top, max_width) unless User.current.nil? + float_top -= write_hero_title(float_top, max_width) if cover_page_title.present? + float_top -= write_hero_heading(float_top, max_width) if cover_page_heading.present? + float_top -= write_hero_dates(float_top, max_width) if cover_page_dates.present? + write_hero_subheading(float_top, max_width) if cover_page_subheading.present? end def available_title_height(current_y) - current_y - - styles.cover_hero_title_max_height - - styles.cover_hero_title_spacing - - styles.cover_hero_heading_spacing - - styles.cover_hero_subheading_max_height + result = current_y + result -= styles.cover_hero_title_max_height + styles.cover_hero_title_spacing if cover_page_title.present? + result -= styles.cover_hero_heading_spacing if cover_page_heading.present? + result -= styles.cover_hero_dates_spacing if cover_page_dates.present? + result -= styles.cover_hero_subheading_max_height if cover_page_subheading.present? + result end def write_cover_hr @@ -83,7 +85,7 @@ def validate_cover_text_color def write_hero_title(top, width) write_hero_text( top:, width:, - text: project.name, + text: cover_page_title, text_style: styles.cover_hero_title, height: styles.cover_hero_title_max_height ) + styles.cover_hero_title_spacing @@ -92,16 +94,25 @@ def write_hero_title(top, width) def write_hero_heading(top, width) write_hero_text( top:, width:, - text: heading, + text: cover_page_heading, text_style: styles.cover_hero_heading, height: available_title_height(top) ) + styles.cover_hero_heading_spacing end + def write_hero_dates(top, width) + write_hero_text( + top:, width:, + text: cover_page_dates, + text_style: styles.cover_hero_dates, + height: styles.cover_hero_dates_max_height + ) + styles.cover_hero_dates_spacing + end + def write_hero_subheading(top, width) write_hero_text( top:, width:, - text: User.current.name, + text: cover_page_subheading, text_style: styles.cover_hero_subheading, height: styles.cover_hero_subheading_max_height ) diff --git a/app/models/work_package/pdf_export/export/schema.json b/app/models/work_package/pdf_export/export/schema.json index 50c64700b44a..316f60469667 100644 --- a/app/models/work_package/pdf_export/export/schema.json +++ b/app/models/work_package/pdf_export/export/schema.json @@ -193,6 +193,42 @@ } ] }, + "dates" : { + "title" : "The dates block in the hero", + "type" : "object", + "x-example" : { + "heading" : { + "spacing" : 10, + "max_height" : 20, + "size" : 32, + "color" : "414d5f", + "styles" : [ + "bold" + ] + } + }, + "properties" : { + "max_height" : { + "title" : "Maximum height of the block", + "examples" : [ + 30 + ], + "$ref" : "#/$defs/measurement" + }, + "spacing" : { + "title" : "Minimum spacing between dates and subheading", + "examples" : [ + 10 + ], + "$ref" : "#/$defs/measurement" + } + }, + "allOf" : [ + { + "$ref" : "#/$defs/font" + } + ] + }, "subheading" : { "title" : "The last block in the hero", "type" : "object", diff --git a/app/models/work_package/pdf_export/export/standard.yml b/app/models/work_package/pdf_export/export/standard.yml index 915d6dd37c2e..ecfc8a0a904b 100644 --- a/app/models/work_package/pdf_export/export/standard.yml +++ b/app/models/work_package/pdf_export/export/standard.yml @@ -275,9 +275,14 @@ cover: styles: - bold size: 16 + dates: + spacing: 4 + max_height: 16 + color: '414d5f' + size: 10 + styles: + - bold subheading: max_height: 30 color: '414d5f' - styles: - - italic size: 10 diff --git a/app/models/work_package/pdf_export/export/style.rb b/app/models/work_package/pdf_export/export/style.rb index 4a1a152adf1f..8e4197138903 100644 --- a/app/models/work_package/pdf_export/export/style.rb +++ b/app/models/work_package/pdf_export/export/style.rb @@ -253,6 +253,18 @@ def cover_hero_heading_spacing resolve_pt(@styles.dig(:cover, :hero, :heading, :spacing), 0) end + def cover_hero_dates + resolve_font(@styles.dig(:cover, :hero, :dates)) + end + + def cover_hero_dates_spacing + resolve_pt(@styles.dig(:cover, :hero, :dates, :spacing), 0) + end + + def cover_hero_dates_max_height + resolve_pt(@styles.dig(:cover, :hero, :dates, :max_height), 0) + end + def cover_hero_subheading resolve_font(@styles.dig(:cover, :hero, :subheading)) end diff --git a/app/models/work_package/pdf_export/work_package_list_to_pdf.rb b/app/models/work_package/pdf_export/work_package_list_to_pdf.rb index 9b399c22d0d5..b62855f71c5b 100644 --- a/app/models/work_package/pdf_export/work_package_list_to_pdf.rb +++ b/app/models/work_package/pdf_export/work_package_list_to_pdf.rb @@ -111,6 +111,22 @@ def with_cover? wants_report? end + def cover_page_title + project&.name + end + + def cover_page_heading + heading + end + + def cover_page_subheading + User.current&.name + end + + def cover_page_dates + nil + end + def render_work_packages_pdfs(work_packages, filename) write_cover_page! if with_cover? if wants_gantt? diff --git a/modules/reporting/app/workers/cost_query/pdf/export_job.rb b/modules/reporting/app/workers/cost_query/pdf/export_job.rb index de22ffc5a575..6aa14f64f8cf 100644 --- a/modules/reporting/app/workers/cost_query/pdf/export_job.rb +++ b/modules/reporting/app/workers/cost_query/pdf/export_job.rb @@ -7,10 +7,6 @@ def project options[:project] end - def cost_types - options[:cost_types] - end - def title I18n.t("export.timesheet.title") end @@ -38,7 +34,7 @@ def pdf_report_result end def generate_timesheet - generator = ::CostQuery::PDF::TimesheetGenerator.new(query, project, cost_types) + generator = ::CostQuery::PDF::TimesheetGenerator.new(query, project) generator.generate! end end diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index 02954c35151f..007a2b5d7290 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -22,10 +22,9 @@ class CostQuery::PDF::TimesheetGenerator attr_accessor :pdf - def initialize(query, project, cost_types) + def initialize(query, project) @query = query @project = project - @cost_types = cost_types setup_page! end @@ -37,6 +36,25 @@ def footer_title heading end + def cover_page_title + "OpenProject" + end + + def cover_page_heading + heading + end + + def cover_page_dates + dates_range = all_entries.group_by(&:spent_on).sort + start_date = dates_range.first&.first + end_date = dates_range.last&.first + "#{format_date(start_date)} - #{format_date(end_date)}" if start_date && end_date + end + + def cover_page_subheading + User.current&.name + end + def project @project end @@ -82,11 +100,11 @@ def write_entries! end def all_entries - query - .each_direct_result - .map(&:itself) - .filter { |r| r.fields["type"] == "TimeEntry" } - .map { |r| TimeEntry.find(r.fields["id"]) } + @all_entries ||= query + .each_direct_result + .map(&:itself) + .filter { |r| r.fields["type"] == "TimeEntry" } + .map { |r| TimeEntry.find(r.fields["id"]) } end def build_table_rows(entries) From 93716ef951fb9ba563d2666e3710511a57d12d5d Mon Sep 17 00:00:00 2001 From: as-op Date: Wed, 4 Dec 2024 12:42:10 +0100 Subject: [PATCH 27/45] obey rubocop --- .../work_package/pdf_export/export/cover.rb | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/app/models/work_package/pdf_export/export/cover.rb b/app/models/work_package/pdf_export/export/cover.rb index 15f70188e9be..726746b6e1b8 100644 --- a/app/models/work_package/pdf_export/export/cover.rb +++ b/app/models/work_package/pdf_export/export/cover.rb @@ -40,19 +40,19 @@ def write_cover_page! def write_cover_hero max_width = pdf.bounds.width - styles.cover_hero_padding[:right_padding] float_top = write_background_image - float_top -= write_hero_title(float_top, max_width) if cover_page_title.present? - float_top -= write_hero_heading(float_top, max_width) if cover_page_heading.present? - float_top -= write_hero_dates(float_top, max_width) if cover_page_dates.present? - write_hero_subheading(float_top, max_width) if cover_page_subheading.present? + float_top -= write_hero_title(float_top, max_width) + float_top -= write_hero_heading(float_top, max_width) + float_top -= write_hero_dates(float_top, max_width) + write_hero_subheading(float_top, max_width) end def available_title_height(current_y) - result = current_y - result -= styles.cover_hero_title_max_height + styles.cover_hero_title_spacing if cover_page_title.present? - result -= styles.cover_hero_heading_spacing if cover_page_heading.present? - result -= styles.cover_hero_dates_spacing if cover_page_dates.present? - result -= styles.cover_hero_subheading_max_height if cover_page_subheading.present? - result + current_y - [ + (styles.cover_hero_title_max_height + styles.cover_hero_title_spacing if cover_page_title.present?), + (styles.cover_hero_heading_spacing if cover_page_heading.present?), + (styles.cover_hero_dates_max_height if cover_page_dates.present?), + (styles.cover_hero_subheading_max_height if cover_page_subheading.present?) + ].compact.sum end def write_cover_hr @@ -83,6 +83,8 @@ def validate_cover_text_color end def write_hero_title(top, width) + return 0 if cover_page_title.blank? + write_hero_text( top:, width:, text: cover_page_title, @@ -92,6 +94,8 @@ def write_hero_title(top, width) end def write_hero_heading(top, width) + return 0 if cover_page_heading.blank? + write_hero_text( top:, width:, text: cover_page_heading, @@ -101,6 +105,8 @@ def write_hero_heading(top, width) end def write_hero_dates(top, width) + return 0 if cover_page_dates.blank? + write_hero_text( top:, width:, text: cover_page_dates, @@ -110,6 +116,8 @@ def write_hero_dates(top, width) end def write_hero_subheading(top, width) + return 0 if cover_page_subheading.blank? + write_hero_text( top:, width:, text: cover_page_subheading, From 230d51ce47fcf3d7c8a74c83b32dd67a5c4b47e2 Mon Sep 17 00:00:00 2001 From: as-op Date: Wed, 4 Dec 2024 12:53:43 +0100 Subject: [PATCH 28/45] use duration converter as used in wireframe (& other PDF exports) --- .../reporting/app/workers/cost_query/pdf/timesheet_generator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index 007a2b5d7290..c58392bab5dd 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -330,7 +330,7 @@ def write_username(user) def format_duration(hours) return "" if hours < 0 - ::OpenProject::Common::DurationComponent.new(hours.to_f, :hours, abbreviated: true).text + DurationConverter.output(hours) end def format_spent_on_time(entry) From cc2641fd863dc76c7a1da318bcd26c62268b868c Mon Sep 17 00:00:00 2001 From: as-op Date: Wed, 4 Dec 2024 13:05:20 +0100 Subject: [PATCH 29/45] obey rubocop --- app/models/work_package/pdf_export/export/cover.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/models/work_package/pdf_export/export/cover.rb b/app/models/work_package/pdf_export/export/cover.rb index 726746b6e1b8..8daf1b1448dc 100644 --- a/app/models/work_package/pdf_export/export/cover.rb +++ b/app/models/work_package/pdf_export/export/cover.rb @@ -48,11 +48,11 @@ def write_cover_hero def available_title_height(current_y) current_y - [ - (styles.cover_hero_title_max_height + styles.cover_hero_title_spacing if cover_page_title.present?), - (styles.cover_hero_heading_spacing if cover_page_heading.present?), - (styles.cover_hero_dates_max_height if cover_page_dates.present?), - (styles.cover_hero_subheading_max_height if cover_page_subheading.present?) - ].compact.sum + cover_page_title&.then { styles.cover_hero_title_max_height + styles.cover_hero_title_spacing } || 0, + cover_page_heading&.then { styles.cover_hero_heading_spacing } || 0, + cover_page_dates&.then { styles.cover_hero_dates_max_height } || 0, + cover_page_subheading&.then { styles.cover_hero_subheading_max_height } || 0 + ].sum end def write_cover_hr From 80149260495c791e196678460aaaa6e4b79d0957 Mon Sep 17 00:00:00 2001 From: as-op Date: Wed, 4 Dec 2024 13:26:29 +0100 Subject: [PATCH 30/45] obey rubocop --- .../work_package/pdf_export/export/cover.rb | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/app/models/work_package/pdf_export/export/cover.rb b/app/models/work_package/pdf_export/export/cover.rb index 8daf1b1448dc..a91eed6ead2f 100644 --- a/app/models/work_package/pdf_export/export/cover.rb +++ b/app/models/work_package/pdf_export/export/cover.rb @@ -47,12 +47,27 @@ def write_cover_hero end def available_title_height(current_y) - current_y - [ - cover_page_title&.then { styles.cover_hero_title_max_height + styles.cover_hero_title_spacing } || 0, - cover_page_heading&.then { styles.cover_hero_heading_spacing } || 0, - cover_page_dates&.then { styles.cover_hero_dates_max_height } || 0, - cover_page_subheading&.then { styles.cover_hero_subheading_max_height } || 0 - ].sum + current_y - + cover_hero_title_max_height - + cover_hero_heading_max_height - + cover_hero_dates_max_height - + cover_hero_subheading_max_height + end + + def cover_hero_title_max_height + cover_page_title&.then { styles.cover_hero_title_max_height + styles.cover_hero_title_spacing } || 0 + end + + def cover_hero_heading_max_height + cover_page_heading&.then { styles.cover_hero_heading_spacing } || 0 + end + + def cover_hero_dates_max_height + cover_page_dates&.then { styles.cover_hero_dates_max_height } || 0 + end + + def cover_hero_subheading_max_height + cover_page_title&.then { styles.cover_hero_title_max_height + styles.cover_hero_title_spacing } || 0 end def write_cover_hr From 5dfe2b378fbce1b78658f2eaf59d7f68826fa05e Mon Sep 17 00:00:00 2001 From: as-op Date: Wed, 4 Dec 2024 15:33:55 +0100 Subject: [PATCH 31/45] add pdf inspector spec for timesheet export --- .../spec/features/export_timesheet_spec.rb | 1 - .../pdf/timesheet_generator_spec.rb | 167 ++++++++++++++++++ 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 modules/reporting/spec/workers/cost_query/pdf/timesheet_generator_spec.rb diff --git a/modules/reporting/spec/features/export_timesheet_spec.rb b/modules/reporting/spec/features/export_timesheet_spec.rb index df3db35ad348..35f552fc9611 100644 --- a/modules/reporting/spec/features/export_timesheet_spec.rb +++ b/modules/reporting/spec/features/export_timesheet_spec.rb @@ -28,7 +28,6 @@ require_relative "../spec_helper" require_relative "support/pages/cost_report_page" -require "pdf/inspector" RSpec.describe "Timesheet PDF export", :js do shared_let(:project) { create(:project) } diff --git a/modules/reporting/spec/workers/cost_query/pdf/timesheet_generator_spec.rb b/modules/reporting/spec/workers/cost_query/pdf/timesheet_generator_spec.rb new file mode 100644 index 000000000000..7d7cf08f2303 --- /dev/null +++ b/modules/reporting/spec/workers/cost_query/pdf/timesheet_generator_spec.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 "pdf/inspector" + +RSpec.describe CostQuery::PDF::TimesheetGenerator do + include Redmine::I18n + let(:query) { create(:cost_query) } + let(:user) { create(:user, firstname: "Export", lastname: "User") } + let(:time_entry_user) { create(:user, firstname: "TimeEntry", lastname: "User") } + let(:project) { create(:project) } + let(:generator) { described_class.new(query, project) } + let(:export_time) { DateTime.new(2024, 12, 04, 23, 59) } + let(:export_time_formatted) { format_time(export_time, include_date: true) } + let(:user_time_entry) { + create(:time_entry, + project:, + user: user, + spent_on: Date.new(2024, 12, 01), + start_time: 8 * 60, + time_zone: "UTC" + ) } + let(:time_entry) { + create(:time_entry, + project:, + user: time_entry_user, + spent_on: Date.new(2024, 12, 01), + start_time: 9 * 60, + time_zone: "UTC" + ) } + let(:other_time_entry) { + create(:time_entry, + project:, + user: time_entry_user, + spent_on: Date.new(2024, 12, 01), + start_time: 10 * 60, + time_zone: "UTC" + ) } + let(:time_entry_with_comment) { + create(:time_entry, + project:, + user: time_entry_user, + comments: "This is a comment", + spent_on: Date.new(2024, 12, 02) + ) } + let(:time_entry_without_time) { + create(:time_entry, + project:, + user: time_entry_user, + spent_on: Date.new(2024, 12, 03) + ) } + let(:time_entries) { [user_time_entry, time_entry, other_time_entry, time_entry_with_comment, time_entry_without_time] } + + before do + User.current = user + allow(generator).to receive(:all_entries).and_return(time_entries) + end + + subject(:pdf) do + content = Timecop.freeze(export_time) do + generator.generate! + end + # If you want to actually see the PDF for debugging, uncomment the following line + # File.binwrite("TimesheetGenerator-test-preview.pdf", content) + PDF::Inspector::Text.analyze(content).strings.join(" ") + end + + def expected_cover_page + ["OpenProject", query.name, + time_entries.empty? ? nil : "#{format_date(time_entries.first.spent_on)} - #{format_date(time_entries.last.spent_on)}", + user.name, export_time_formatted].compact + end + + def expected_first_page_content + [query.name] + end + + def expected_table_header(with_times_column) + [ + I18n.t(:"activerecord.attributes.time_entry.spent_on"), + I18n.t(:"activerecord.models.work_package"), + with_times_column ? I18n.t(:"export.timesheet.time") : nil, + I18n.t(:"activerecord.attributes.time_entry.hours"), + I18n.t(:"activerecord.attributes.time_entry.activity") + ].compact + end + + def expected_page_footer(page_number) + [page_number, export_time_formatted, query.name] + end + + def expected_entry_row(t_entry, with_times_column) + [format_date(t_entry.spent_on)].concat(expected_entry_columns(t_entry, with_times_column)) + end + + def expected_entry_columns(t_entry, with_times_column) + time_column = generator.format_spent_on_time(t_entry) + [ + t_entry.work_package.subject, + with_times_column && time_column.present? ? time_column : nil, + generator.format_duration(t_entry.hours), + t_entry.activity.name, + t_entry.comments + ].compact + end + + def expected_document(with_times_column) + [ + *expected_cover_page, + *expected_first_page_content, + + user.name, + *expected_table_header(with_times_column), + *expected_entry_row(user_time_entry, with_times_column), + + time_entry.user.name, + *expected_table_header(with_times_column), + format_date(time_entry.spent_on), # merged date rows + *expected_entry_columns(time_entry, with_times_column), + *expected_entry_columns(other_time_entry, with_times_column), + *expected_entry_row(time_entry_with_comment, with_times_column), + *expected_entry_row(time_entry_without_time, with_times_column), + + *expected_page_footer("1") + ].join(" ") + end + + context "with allow_tracking_start_and_end_times", with_settings: { allow_tracking_start_and_end_times: true } do + it "renders the expected document" do + expect(subject).to eq expected_document(true) + end + end + + context "without allow_tracking_start_and_end_times", with_settings: { allow_tracking_start_and_end_times: false } do + it "renders the expected document" do + expect(subject).to eq expected_document(false) + end + end +end From 57e99f4af4cd0c1bf42834c5ccab2313f906e203 Mon Sep 17 00:00:00 2001 From: as-op Date: Wed, 4 Dec 2024 15:36:53 +0100 Subject: [PATCH 32/45] obey rubocop --- .../pdf/timesheet_generator_spec.rb | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/modules/reporting/spec/workers/cost_query/pdf/timesheet_generator_spec.rb b/modules/reporting/spec/workers/cost_query/pdf/timesheet_generator_spec.rb index 7d7cf08f2303..846cff38fac8 100644 --- a/modules/reporting/spec/workers/cost_query/pdf/timesheet_generator_spec.rb +++ b/modules/reporting/spec/workers/cost_query/pdf/timesheet_generator_spec.rb @@ -38,45 +38,45 @@ let(:time_entry_user) { create(:user, firstname: "TimeEntry", lastname: "User") } let(:project) { create(:project) } let(:generator) { described_class.new(query, project) } - let(:export_time) { DateTime.new(2024, 12, 04, 23, 59) } + let(:export_time) { DateTime.new(2024, 12, 4, 23, 59) } let(:export_time_formatted) { format_time(export_time, include_date: true) } - let(:user_time_entry) { + let(:user_time_entry) do create(:time_entry, project:, user: user, - spent_on: Date.new(2024, 12, 01), + spent_on: Date.new(2024, 12, 0o1), start_time: 8 * 60, - time_zone: "UTC" - ) } - let(:time_entry) { + time_zone: "UTC") + end + let(:time_entry) do create(:time_entry, project:, user: time_entry_user, - spent_on: Date.new(2024, 12, 01), + spent_on: Date.new(2024, 12, 0o1), start_time: 9 * 60, - time_zone: "UTC" - ) } - let(:other_time_entry) { + time_zone: "UTC") + end + let(:other_time_entry) do create(:time_entry, project:, user: time_entry_user, - spent_on: Date.new(2024, 12, 01), + spent_on: Date.new(2024, 12, 0o1), start_time: 10 * 60, - time_zone: "UTC" - ) } - let(:time_entry_with_comment) { + time_zone: "UTC") + end + let(:time_entry_with_comment) do create(:time_entry, project:, user: time_entry_user, comments: "This is a comment", - spent_on: Date.new(2024, 12, 02) - ) } - let(:time_entry_without_time) { + spent_on: Date.new(2024, 12, 0o2)) + end + let(:time_entry_without_time) do create(:time_entry, project:, user: time_entry_user, - spent_on: Date.new(2024, 12, 03) - ) } + spent_on: Date.new(2024, 12, 0o3)) + end let(:time_entries) { [user_time_entry, time_entry, other_time_entry, time_entry_with_comment, time_entry_without_time] } before do From 32b35fac1954675459f0220a90596a73e8973457 Mon Sep 17 00:00:00 2001 From: as-op Date: Wed, 4 Dec 2024 16:12:14 +0100 Subject: [PATCH 33/45] show different times on (+days), e.g. "23:00 - 04:00 (+1d)" --- .../cost_query/pdf/timesheet_generator.rb | 18 +++++++++++++++--- .../cost_query/pdf/timesheet_generator_spec.rb | 2 +- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index c58392bab5dd..14802af414f2 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -134,7 +134,7 @@ def build_table_row(spent_on, entry) { content: format_date(spent_on), rowspan: entry.comments.present? ? 2 : 1 }, entry.work_package.subject || "", with_times_column? ? format_spent_on_time(entry) : nil, - format_duration(entry.hours), + format_hours(entry.hours), entry.activity&.name || "" ].compact end @@ -327,7 +327,7 @@ def write_username(user) pdf.move_down(H2_MARGIN_BOTTOM) end - def format_duration(hours) + def format_hours(hours) return "" if hours < 0 DurationConverter.output(hours) @@ -341,7 +341,19 @@ def format_spent_on_time(entry) end_timestamp = entry.end_timestamp return result if end_timestamp.nil? - "#{result} - #{format_time(end_timestamp, include_date: false)}" + days_between_suffix = format_days_between(start_timestamp, end_timestamp) + "#{result} - #{format_time(end_timestamp, include_date: false)}#{days_between_suffix}" + end + + def format_days_between(start_timestamp, end_timestamp) + days_between = (end_timestamp.to_date - start_timestamp.to_date).to_i + if days_between.positive? + " (+#{days_formatter.format_value(days_between, nil).delete(' ')})" + end + end + + def days_formatter + @days_formatter ||= WorkPackage::Exports::Formatters::Days.new(nil) end def with_times_column? diff --git a/modules/reporting/spec/workers/cost_query/pdf/timesheet_generator_spec.rb b/modules/reporting/spec/workers/cost_query/pdf/timesheet_generator_spec.rb index 846cff38fac8..7067e42b2275 100644 --- a/modules/reporting/spec/workers/cost_query/pdf/timesheet_generator_spec.rb +++ b/modules/reporting/spec/workers/cost_query/pdf/timesheet_generator_spec.rb @@ -126,7 +126,7 @@ def expected_entry_columns(t_entry, with_times_column) [ t_entry.work_package.subject, with_times_column && time_column.present? ? time_column : nil, - generator.format_duration(t_entry.hours), + generator.format_hours(t_entry.hours), t_entry.activity.name, t_entry.comments ].compact From cf7d21c60d6df82f511a557e6d8de41fd06fa84c Mon Sep 17 00:00:00 2001 From: as-op Date: Wed, 4 Dec 2024 16:32:08 +0100 Subject: [PATCH 34/45] adjust time column with for us time format --- .../app/workers/cost_query/pdf/timesheet_generator.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index 14802af414f2..bec3d2d9748c 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -17,8 +17,8 @@ class CostQuery::PDF::TimesheetGenerator COLUMN_DATE_WIDTH = 66 COLUMN_ACTIVITY_WIDTH = 100 COLUMN_HOURS_WIDTH = 60 - COLUMN_TIME_WIDTH = 100 - COLUMN_WP_WIDTH = 200 + COLUMN_TIME_WIDTH = 110 + COLUMN_WP_WIDTH = 190 attr_accessor :pdf From 638596ed0385620966b542b2df42b695a2d2ff31 Mon Sep 17 00:00:00 2001 From: as-op Date: Wed, 4 Dec 2024 16:34:50 +0100 Subject: [PATCH 35/45] revert changes to cost_query sql --- modules/reporting/app/models/cost_query/sql_statement.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/modules/reporting/app/models/cost_query/sql_statement.rb b/modules/reporting/app/models/cost_query/sql_statement.rb index d43b2dc2920c..d2cf27cb09d4 100644 --- a/modules/reporting/app/models/cost_query/sql_statement.rb +++ b/modules/reporting/app/models/cost_query/sql_statement.rb @@ -74,8 +74,6 @@ def to_s # cost_type_id | -1 | cost_type_id # type | "TimeEntry" | "CostEntry" # count | 1 | 1 - # start_time | start_time | nil - # time_zone | time_zone | nil # # Also: This _should_ handle joining activities and cost_types, as the logic differs for time_entries # and cost_entries. @@ -104,7 +102,7 @@ def self.unified_entry(model) # # @param [CostQuery::SqlStatement] query The statement to adjust def self.unify_time_entries(query) - query.select :activity_id, :logged_by_id, :start_time, :time_zone, units: :hours, cost_type_id: -1 + query.select :activity_id, :logged_by_id, :start_time, cost_type_id: -1 query.select cost_type: quoted_label(:caption_labor) end @@ -113,7 +111,7 @@ def self.unify_time_entries(query) # # @param [CostQuery::SqlStatement] query The statement to adjust def self.unify_cost_entries(query) - query.select :units, :cost_type_id, :logged_by_id, activity_id: -1, start_time: nil, time_zone: nil + query.select :units, :cost_type_id, :logged_by_id, activity_id: -1 query.select cost_type: "cost_types.name" query.join CostType end From a847cb07dde817524971dc6ffd973cb970801acf Mon Sep 17 00:00:00 2001 From: as-op Date: Wed, 4 Dec 2024 16:36:10 +0100 Subject: [PATCH 36/45] revert changes to cost_query sql --- modules/reporting/app/models/cost_query/sql_statement.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/reporting/app/models/cost_query/sql_statement.rb b/modules/reporting/app/models/cost_query/sql_statement.rb index d2cf27cb09d4..973eaf8d14c7 100644 --- a/modules/reporting/app/models/cost_query/sql_statement.rb +++ b/modules/reporting/app/models/cost_query/sql_statement.rb @@ -102,7 +102,7 @@ def self.unified_entry(model) # # @param [CostQuery::SqlStatement] query The statement to adjust def self.unify_time_entries(query) - query.select :activity_id, :logged_by_id, :start_time, cost_type_id: -1 + query.select :activity_id, :logged_by_id, units: :hours, cost_type_id: -1 query.select cost_type: quoted_label(:caption_labor) end From 835ed1933405ac081958f65d53ba104de0c04fb7 Mon Sep 17 00:00:00 2001 From: as-op Date: Thu, 5 Dec 2024 09:17:51 +0100 Subject: [PATCH 37/45] footer values as specified in wireframe --- .../cost_query/pdf/timesheet_generator.rb | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index bec3d2d9748c..c356957dff0b 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -25,6 +25,8 @@ class CostQuery::PDF::TimesheetGenerator def initialize(query, project) @query = query @project = project + @total_page_nr = nil + @page_count = 0 setup_page! end @@ -69,13 +71,17 @@ def options def setup_page! self.pdf = get_pdf - @page_count = 0 configure_page_size!(:portrait) pdf.title = heading end def generate! render_doc + if wants_total_page_nrs? + @total_page_nr = pdf.page_count + setup_page! # clear current pdf + render_doc + end pdf.render rescue StandardError => e Rails.logger.error { "Failed to generate PDF: #{e} #{e.message}}." } @@ -327,6 +333,14 @@ def write_username(user) pdf.move_down(H2_MARGIN_BOTTOM) end + def footer_date + if pdf.page_number == 1 + format_time(Time.zone.now) + else + format_date(Time.zone.now) + end + end + def format_hours(hours) return "" if hours < 0 @@ -363,4 +377,8 @@ def with_times_column? def with_cover? true end + + def wants_total_page_nrs? + true + end end From 605a70922ed101550d0ee60332ff2de142f2a5ad Mon Sep 17 00:00:00 2001 From: as-op Date: Mon, 9 Dec 2024 12:13:15 +0100 Subject: [PATCH 38/45] include cover page in page number footer --- .../app/workers/cost_query/pdf/timesheet_generator.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index c356957dff0b..b3a6c67f52c7 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -26,7 +26,7 @@ def initialize(query, project) @query = query @project = project @total_page_nr = nil - @page_count = 0 + @page_count = 1 setup_page! end @@ -78,7 +78,8 @@ def setup_page! def generate! render_doc if wants_total_page_nrs? - @total_page_nr = pdf.page_count + @total_page_nr = pdf.page_count + @page_count + @page_count = 1 setup_page! # clear current pdf render_doc end From cd99fb8ae4f6d63f4b20d16e1f51fe24669e9a44 Mon Sep 17 00:00:00 2001 From: as-op Date: Mon, 9 Dec 2024 12:15:38 +0100 Subject: [PATCH 39/45] include cover page in page number footer --- .../spec/workers/cost_query/pdf/timesheet_generator_spec.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/reporting/spec/workers/cost_query/pdf/timesheet_generator_spec.rb b/modules/reporting/spec/workers/cost_query/pdf/timesheet_generator_spec.rb index 7067e42b2275..064576698e21 100644 --- a/modules/reporting/spec/workers/cost_query/pdf/timesheet_generator_spec.rb +++ b/modules/reporting/spec/workers/cost_query/pdf/timesheet_generator_spec.rb @@ -40,6 +40,7 @@ let(:generator) { described_class.new(query, project) } let(:export_time) { DateTime.new(2024, 12, 4, 23, 59) } let(:export_time_formatted) { format_time(export_time, include_date: true) } + let(:export_date_formatted) { format_date(export_time) } let(:user_time_entry) do create(:time_entry, project:, @@ -114,7 +115,7 @@ def expected_table_header(with_times_column) end def expected_page_footer(page_number) - [page_number, export_time_formatted, query.name] + [page_number, export_date_formatted, query.name] end def expected_entry_row(t_entry, with_times_column) @@ -149,7 +150,7 @@ def expected_document(with_times_column) *expected_entry_row(time_entry_with_comment, with_times_column), *expected_entry_row(time_entry_without_time, with_times_column), - *expected_page_footer("1") + *expected_page_footer("2/2") ].join(" ") end From 8a1715f01d6d49e21bf6c80bb4f5494b1fd3470c Mon Sep 17 00:00:00 2001 From: Andrej Sandorf <77627197+as-op@users.noreply.github.com> Date: Tue, 10 Dec 2024 15:51:44 +0100 Subject: [PATCH 40/45] Update modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb Co-authored-by: Klaus Zanders --- .../app/workers/cost_query/pdf/timesheet_generator.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index b3a6c67f52c7..23effda5eb69 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -47,9 +47,7 @@ def cover_page_heading end def cover_page_dates - dates_range = all_entries.group_by(&:spent_on).sort - start_date = dates_range.first&.first - end_date = dates_range.last&.first + start_date, end_date = all_entries.map(&:spent_on).minmax "#{format_date(start_date)} - #{format_date(end_date)}" if start_date && end_date end From bb6089694daa526e26caa1b93cd53f73a1bbf206 Mon Sep 17 00:00:00 2001 From: as-op Date: Tue, 10 Dec 2024 15:55:16 +0100 Subject: [PATCH 41/45] better naming; closes https://github.com/opf/openproject/pull/17316#discussion_r1878185170 --- .../app/services/cost_query/schedule_export_service.rb | 2 +- .../cost_query/pdf/{export_job.rb => export_timesheet_job.rb} | 2 +- .../pdf/{export_job_spec.rb => export_timesheet_job_spec.rb} | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename modules/reporting/app/workers/cost_query/pdf/{export_job.rb => export_timesheet_job.rb} (93%) rename modules/reporting/spec/workers/cost_query/pdf/{export_job_spec.rb => export_timesheet_job_spec.rb} (98%) diff --git a/modules/reporting/app/services/cost_query/schedule_export_service.rb b/modules/reporting/app/services/cost_query/schedule_export_service.rb index 2042dcdd7723..7d2f0a336535 100644 --- a/modules/reporting/app/services/cost_query/schedule_export_service.rb +++ b/modules/reporting/app/services/cost_query/schedule_export_service.rb @@ -43,7 +43,7 @@ def call(format:, query_id:, query_name:, filter_params:, project:, cost_types:) private def schedule_export(format, export_storage, query_id, query_name, filter_params, project, cost_types) - job = format == :pdf ? ::CostQuery::PDF::ExportJob : ::CostQuery::XLS::ExportJob + job = format == :pdf ? ::CostQuery::PDF::ExportTimesheetJob : ::CostQuery::XLS::ExportJob job.perform_later(export: export_storage, user:, mime_type: format, diff --git a/modules/reporting/app/workers/cost_query/pdf/export_job.rb b/modules/reporting/app/workers/cost_query/pdf/export_timesheet_job.rb similarity index 93% rename from modules/reporting/app/workers/cost_query/pdf/export_job.rb rename to modules/reporting/app/workers/cost_query/pdf/export_timesheet_job.rb index 6aa14f64f8cf..058cb2061369 100644 --- a/modules/reporting/app/workers/cost_query/pdf/export_job.rb +++ b/modules/reporting/app/workers/cost_query/pdf/export_timesheet_job.rb @@ -1,6 +1,6 @@ require "active_storage/filename" -class CostQuery::PDF::ExportJob < Exports::ExportJob +class CostQuery::PDF::ExportTimesheetJob < Exports::ExportJob self.model = ::CostQuery def project diff --git a/modules/reporting/spec/workers/cost_query/pdf/export_job_spec.rb b/modules/reporting/spec/workers/cost_query/pdf/export_timesheet_job_spec.rb similarity index 98% rename from modules/reporting/spec/workers/cost_query/pdf/export_job_spec.rb rename to modules/reporting/spec/workers/cost_query/pdf/export_timesheet_job_spec.rb index f18a56bf2c85..3bafe2f2f073 100644 --- a/modules/reporting/spec/workers/cost_query/pdf/export_job_spec.rb +++ b/modules/reporting/spec/workers/cost_query/pdf/export_timesheet_job_spec.rb @@ -30,7 +30,7 @@ require "spec_helper" -RSpec.describe CostQuery::PDF::ExportJob do +RSpec.describe CostQuery::PDF::ExportTimesheetJob do let(:user) { build_stubbed(:user) } let(:project) { build_stubbed(:project) } From 3cd924a3325afcafba23d946c2d343e9629f56a9 Mon Sep 17 00:00:00 2001 From: as-op Date: Tue, 10 Dec 2024 16:06:47 +0100 Subject: [PATCH 42/45] better attribute name retrieval; closes https://github.com/opf/openproject/pull/17316#discussion_r1878200777 --- .../app/workers/cost_query/pdf/timesheet_generator.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb index 23effda5eb69..647daa18814b 100644 --- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb +++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb @@ -155,11 +155,11 @@ def build_table_row_comment(entry) def table_header_columns [ - { content: I18n.t(:"activerecord.attributes.time_entry.spent_on"), rowspan: 1 }, + { content: TimeEntry.human_attribute_name(:spent_on), rowspan: 1 }, I18n.t(:"activerecord.models.work_package"), with_times_column? ? I18n.t(:"export.timesheet.time") : nil, - I18n.t(:"activerecord.attributes.time_entry.hours"), - I18n.t(:"activerecord.attributes.time_entry.activity") + TimeEntry.human_attribute_name(:hours), + TimeEntry.human_attribute_name(:activity) ].compact end From 10b42f98d546809409e225a2d3a13583f7d94b16 Mon Sep 17 00:00:00 2001 From: Andrej Sandorf <77627197+as-op@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:07:36 +0100 Subject: [PATCH 43/45] Update frontend/src/stimulus/controllers/dynamic/costs/export.controller.ts Co-authored-by: Klaus Zanders --- .../dynamic/costs/export.controller.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/frontend/src/stimulus/controllers/dynamic/costs/export.controller.ts b/frontend/src/stimulus/controllers/dynamic/costs/export.controller.ts index ba839789d487..428065fe1a9f 100644 --- a/frontend/src/stimulus/controllers/dynamic/costs/export.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/costs/export.controller.ts @@ -1,3 +1,34 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) 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. + * ++ + */ + + import { Controller } from '@hotwired/stimulus'; import * as Turbo from '@hotwired/turbo'; import { HttpErrorResponse } from '@angular/common/http'; From 7ae831decabf5982931b76665e109b3fea8f93e8 Mon Sep 17 00:00:00 2001 From: Andrej Sandorf <77627197+as-op@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:11:30 +0100 Subject: [PATCH 44/45] Update modules/reporting/app/controllers/cost_reports_controller.rb Co-authored-by: Klaus Zanders --- modules/reporting/app/controllers/cost_reports_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/reporting/app/controllers/cost_reports_controller.rb b/modules/reporting/app/controllers/cost_reports_controller.rb index d66a649596ac..80cefdf73c98 100644 --- a/modules/reporting/app/controllers/cost_reports_controller.rb +++ b/modules/reporting/app/controllers/cost_reports_controller.rb @@ -594,6 +594,6 @@ def find_optional_report(query = "1=0") end def session_name - report_engine.name.underscore.to_sym + @session_name ||= report_engine.name.underscore.to_sym end end From 2904bbcda44ca8cfb7ac3febc616b26b0ea09017 Mon Sep 17 00:00:00 2001 From: as-op Date: Tue, 10 Dec 2024 16:46:58 +0100 Subject: [PATCH 45/45] obey eslint --- .../src/stimulus/controllers/dynamic/costs/export.controller.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/stimulus/controllers/dynamic/costs/export.controller.ts b/frontend/src/stimulus/controllers/dynamic/costs/export.controller.ts index 428065fe1a9f..aa744db75469 100644 --- a/frontend/src/stimulus/controllers/dynamic/costs/export.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/costs/export.controller.ts @@ -28,7 +28,6 @@ * ++ */ - import { Controller } from '@hotwired/stimulus'; import * as Turbo from '@hotwired/turbo'; import { HttpErrorResponse } from '@angular/common/http';