From 8752302407a10fb2ff6b4326d501e0df8075b5bf Mon Sep 17 00:00:00 2001 From: as-op <a.sandorf@openproject.com> Date: Thu, 30 May 2024 15:45:32 +0200 Subject: [PATCH 01/19] [#54377] Embedded work package attributes in PDF export https://community.openproject.org/work_packages/54377 --- Gemfile | 2 + Gemfile.lock | 1 + app/models/exports/formatters/custom_field.rb | 2 +- .../projects/exports/formatters/active.rb | 42 +++++ .../projects/exports/formatters/public.rb | 42 +++++ .../work_package/exports/macros/attributes.rb | 168 ++++++++++++++++++ .../work_package/pdf_export/markdown_field.rb | 48 ++++- .../pdf_export/work_package_detail.rb | 2 +- config/initializers/export_formats.rb | 2 + .../pdf_export/work_package_to_pdf_spec.rb | 152 ++++++++++++++-- 10 files changed, 442 insertions(+), 19 deletions(-) create mode 100644 app/models/projects/exports/formatters/active.rb create mode 100644 app/models/projects/exports/formatters/public.rb create mode 100644 app/models/work_package/exports/macros/attributes.rb diff --git a/Gemfile b/Gemfile index ca1db68f251a..9882d01a33d2 100644 --- a/Gemfile +++ b/Gemfile @@ -157,9 +157,11 @@ gem "structured_warnings", "~> 0.4.0" # don't require by default, instead load on-demand when actually configured gem "airbrake", "~> 13.0.0", require: false +gem "markly", "~> 0.10" gem "md_to_pdf", git: "https://github.com/opf/md-to-pdf", ref: "8f14736a88ad0064d2a97be108fe7061ffbcee91" gem "prawn", "~> 2.4" gem "ttfunk", "~> 1.7.0" # remove after https://github.com/prawnpdf/prawn/issues/1346 resolved. + # prawn implicitly depends on matrix gem no longer in ruby core with 3.1 gem "matrix", "~> 0.4.2" diff --git a/Gemfile.lock b/Gemfile.lock index fac96af70f9a..d8c3ea81140e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1233,6 +1233,7 @@ DEPENDENCIES lograge (~> 0.14.0) lookbook (~> 2.3.0) mail (= 2.8.1) + markly (~> 0.10) matrix (~> 0.4.2) md_to_pdf! meta-tags (~> 2.21.0) diff --git a/app/models/exports/formatters/custom_field.rb b/app/models/exports/formatters/custom_field.rb index 5815fb2a18ff..045b197bb9f2 100644 --- a/app/models/exports/formatters/custom_field.rb +++ b/app/models/exports/formatters/custom_field.rb @@ -24,7 +24,7 @@ def format_for_export(object, custom_field) case custom_field.field_format when "bool" value = object.typed_custom_value_for(custom_field) - value == nil ? false : value + value ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No) when "text" object.typed_custom_value_for(custom_field) else diff --git a/app/models/projects/exports/formatters/active.rb b/app/models/projects/exports/formatters/active.rb new file mode 100644 index 000000000000..81980c07a751 --- /dev/null +++ b/app/models/projects/exports/formatters/active.rb @@ -0,0 +1,42 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +module Projects::Exports + module Formatters + class Active < ::Exports::Formatters::Default + def self.apply?(attribute, export_format) + export_format == :pdf && %i[active].include?(attribute.to_sym) + end + + ## + # Takes a project and returns the localized status code + def format(project, **) + project.active ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No) + end + end + end +end diff --git a/app/models/projects/exports/formatters/public.rb b/app/models/projects/exports/formatters/public.rb new file mode 100644 index 000000000000..40a6e85d1f0f --- /dev/null +++ b/app/models/projects/exports/formatters/public.rb @@ -0,0 +1,42 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +module Projects::Exports + module Formatters + class Public < ::Exports::Formatters::Default + def self.apply?(attribute, export_format) + export_format == :pdf && %i[public].include?(attribute.to_sym) + end + + ## + # Takes a project and returns the localized status code + def format(project, **) + project.active ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No) + end + end + end +end diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb new file mode 100644 index 000000000000..6ce70aee38a2 --- /dev/null +++ b/app/models/work_package/exports/macros/attributes.rb @@ -0,0 +1,168 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +module WorkPackage::Exports + module Macros + # OpenProject attribute macros syntax + # Examples: + # workPackageLabel:1234:subject # Outputs work package label attribute "Subject" + help text + # workPackageValue:1234:subject # Outputs the actual subject of #1234 + # + # projectLabel:statusExplanation # Outputs current project label attribute "Status description" + help text + # projectValue:statusExplanation # Outputs current project value for "Status description" + class Attributes < OpenProject::TextFormatting::Matchers::RegexMatcher + DISABLED_PROJECT_RICH_TEXT_FIELDS = %i[description status_explanation status_description].freeze + DISABLED_WORK_PACKAGE_RICH_TEXT_FIELDS = %i[description].freeze + + def self.regexp + %r{ + (\w+)(Label|Value) # The model type we try to reference + (?::(?:([^"\s]+)|"([^"]+)"))? # Optional: An ID or subject reference + (?::([^"\s.]+|"([^".]+)")) # The attribute name we're trying to reference + }x + end + + ## + # Faster inclusion check before the regex is being applied + def self.applicable?(content) + content.include?("Label:") || content.include?("Value:") + end + + def self.process_match(match, _matched_string, context) + context => { user:, work_package: } + type = match[2].downcase + model_s = match[1] + id = match[4] || match[3] + attribute = match[6] || match[5] + resolve_match(type, model_s, id, attribute, work_package, user) + end + + def self.resolve_match(type, model_s, id, attribute, work_package, user) + if model_s == "workPackage" + resolve_work_package_match(id || work_package.id, type, attribute, user) + elsif model_s == "project" + resolve_project_match(id || work_package.project.id, type, attribute, user) + else + "[Error: Invalid attribute macro: #{model_s}]" + end + end + + def self.resolve_label_work_package(attribute) + resolve_label(WorkPackage, attribute) + end + + def self.resolve_label_project(attribute) + resolve_label(Project, attribute) + end + + def self.resolve_label(model, attribute) + model.human_attribute_name( + ::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: model.new) + ) + end + + def self.resolve_work_package_match(id, type, attribute, user) + return resolve_label_work_package(attribute) if type == "label" + return "[Error: Invalid attribute macro: #{type}]" unless type == "value" + + work_package = WorkPackage.find_by(id:) + if work_package.nil? || !user.allowed_in_project?(:view_work_packages, work_package.project) + return "[Error: #{WorkPackage.name} #{id} not found}]" + end + + resolve_value_work_package(work_package, attribute) + end + + def self.resolve_project_match(id, type, attribute, user) + return resolve_label_project(attribute) if type == "label" + return "[Error: Invalid attribute macro: #{type}]" unless type == "value" + + project = Project.find_by(id:) + if project.nil? || !user.allowed_in_project?(:view_project, project) + return "[Error: #{Project.name} #{id} not found}]" + end + + resolve_value_project(project, attribute) + end + + def self.escape_tags(value) + # only disable html tags, but do not replace html entities + value.to_s.gsub("<", "<").gsub(">", ">") + end + + def self.resolve_value_project(project, attribute) + cf = CustomField.find_by(name: attribute, type: "ProjectCustomField") + if cf.nil? + ar_name = ::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: project) + else + ar_name = "cf_#{cf.id}" + # currently we do not support embedding rich text fields: long text custom fields + return "[Rich text embedding currently not supported in export]" if cf.formattable? + + # TODO: Is the user allowed to see this custom field/"project attribute"? + end + + # currently we do not support embedding rich text field: e.g. projectValue:1234:description + if DISABLED_PROJECT_RICH_TEXT_FIELDS.include?(ar_name.to_sym) + return "[Rich text embedding currently not supported in export]" + + end + + format_attribute_value(ar_name, Project, project) + end + + def self.resolve_value_work_package(work_package, attribute) + cf = CustomField.find_by(name: attribute, type: "WorkPackageCustomField") + if cf.nil? + ar_name = ::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: work_package) + else + ar_name = "cf_#{cf.id}" + # currently we do not support embedding rich text fields: long text custom fields + return "[Rich text embedding currently not supported in export]" if cf.formattable? + + # TODO: Are there access restrictions on work_package custom fields? + end + + # currently we do not support embedding rich text field: workPackageValue:1234:description + if DISABLED_WORK_PACKAGE_RICH_TEXT_FIELDS.include?(ar_name.to_sym) + return "[Rich text embedding currently not supported in export]" + + end + + format_attribute_value(ar_name, WorkPackage, work_package) + end + + def self.format_attribute_value(ar_name, model, obj) + formatter = Exports::Register.formatter_for(model, ar_name, :pdf) + value = formatter.format(obj) + # important NOT to return empty string as this could change meaning of markdown + # e.g. **to_be_replaced** could be rendered as **** (horizontal line and a *) + value.blank? ? " " : escape_tags(value) + end + end + end +end diff --git a/app/models/work_package/pdf_export/markdown_field.rb b/app/models/work_package/pdf_export/markdown_field.rb index 963396d30067..b3a6447b8a94 100644 --- a/app/models/work_package/pdf_export/markdown_field.rb +++ b/app/models/work_package/pdf_export/markdown_field.rb @@ -28,6 +28,7 @@ module WorkPackage::PDFExport::MarkdownField include WorkPackage::PDFExport::Markdown + PREFORMATTED_BLOCKS = %w(pre code).freeze def write_markdown_field!(work_package, markdown, label) return if markdown.blank? @@ -37,7 +38,52 @@ def write_markdown_field!(work_package, markdown, label) pdf.formatted_text([styles.wp_markdown_label.merge({ text: label })]) end with_margin(styles.wp_markdown_margins) do - write_markdown! work_package, markdown + write_markdown! work_package, apply_markdown_field_macros(markdown) + end + end + + private + + def apply_markdown_field_macros(markdown) + apply_macros(markdown, WorkPackage::Exports::Macros::Attributes) + end + + def apply_macros(markdown, formatter) + return markdown unless formatter.applicable?(markdown) + + document = Markly.parse(markdown) + document.walk do |node| + if node.type == :html + node.string_content = apply_macro_html(node.string_content, work_package, formatter) + elsif node.type == :text + node.string_content = apply_macro_text(node.string_content, work_package, formatter) + end + end + document.to_markdown + end + + def apply_macro_text(text, work_package, formatter) + return text unless formatter.applicable?(text) + + text.gsub!(formatter.regexp) do |matched_string| + matchdata = Regexp.last_match + formatter.process_match(matchdata, matched_string, { user: User.current, work_package: }) + end + end + + def apply_macro_html(html, work_package, formatter) + return html unless formatter.applicable?(html) + + doc = Nokogiri::HTML.fragment(html) + apply_macro_html_node(doc, work_package, formatter) + doc.to_html + end + + def apply_macro_html_node(node, work_package, formatter) + if node.text? + node.content = apply_macro_text(node.content, work_package, formatter) + elsif PREFORMATTED_BLOCKS.exclude?(node.name) + node.children.each { |child| apply_macro_html_node(child, work_package, formatter) } end end end diff --git a/app/models/work_package/pdf_export/work_package_detail.rb b/app/models/work_package/pdf_export/work_package_detail.rb index 2d4aae33e8e7..2f575c12bdc6 100644 --- a/app/models/work_package/pdf_export/work_package_detail.rb +++ b/app/models/work_package/pdf_export/work_package_detail.rb @@ -143,7 +143,7 @@ def form_key_to_column_entries(form_key, work_package) return [] unless cf.is_for_all? || work_package.project.work_package_custom_field_ids.include?(cf.id) - return [{ label: cf.name || form_key, name: form_key }] + return [{ label: cf.name || form_key, name: form_key.to_s.sub("custom_field_", "cf_") }] end if form_key == :date diff --git a/config/initializers/export_formats.rb b/config/initializers/export_formats.rb index f95759cdf163..26f2092b4128 100644 --- a/config/initializers/export_formats.rb +++ b/config/initializers/export_formats.rb @@ -20,6 +20,8 @@ formatter Project, Exports::Formatters::CustomField formatter Project, Projects::Exports::Formatters::Status formatter Project, Projects::Exports::Formatters::Description + formatter Project, Projects::Exports::Formatters::Public + formatter Project, Projects::Exports::Formatters::Active end end end diff --git a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb index f6fd8f60c8db..4491fbbd77e7 100644 --- a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb +++ b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb @@ -36,10 +36,17 @@ t.attribute_groups.first.attributes.push(cf_disabled_in_project.attribute_name, cf_long_text.attribute_name) end end + let(:parent_project) do + create(:project, name: "Parent project") + end let(:project) do create(:project, name: "Foo Bla. Report No. 4/2021 with/for Case 42", types: [type], + public: true, + status_code: "on_track", + active: true, + parent: parent_project, work_package_custom_fields: [cf_long_text, cf_disabled_in_project, cf_global_bool], work_package_custom_field_ids: [cf_long_text.id, cf_global_bool.id]) # cf_disabled_in_project.id is disabled end @@ -47,11 +54,15 @@ create(:user, member_with_permissions: { project => %w[view_work_packages export_work_packages] }) end + let(:category) { create(:category, project:, name: "Demo") } + let(:version) { create(:version, project:) } let(:export_time) { DateTime.new(2023, 6, 30, 23, 59) } let(:export_time_formatted) { format_time(export_time, true) } let(:image_path) { Rails.root.join("spec/fixtures/files/image.png") } + let(:priority) { create(:priority_normal) } let(:image_attachment) { Attachment.new author: user, file: File.open(image_path) } let(:attachments) { [image_attachment] } + let(:cf_long_text_description) { "foo" } let(:cf_long_text) { create(:issue_custom_field, :text, name: "LongText") } let!(:cf_disabled_in_project) do # NOT enabled by project.work_package_custom_field_ids => NOT in PDF @@ -65,8 +76,11 @@ default_value: true ) end - let(:work_package) do - description = <<~DESCRIPTION + let(:status) { create(:status, name: "random", is_default: true) } + let!(:parent_work_package) { create(:work_package, type:, subject: "Parent wp") } + + let(:description) do + <<~DESCRIPTION **Lorem** _ipsum_ ~~dolor~~ `sit` [amet](https://example.com/), consetetur sadipscing elitr. <mention data-text="@OpenProject Admin">@OpenProject Admin</mention> ![](/api/v3/attachments/#{image_attachment.id}/content) @@ -80,14 +94,32 @@ </p> <p><unknown-tag>Foo</unknown-tag></p> DESCRIPTION + end + let(:work_package) do create(:work_package, + id: 1, project:, type:, subject: "Work package 1", + start_date: "2024-05-30", + due_date: "2024-05-30", + created_at: export_time, + updated_at: export_time, + author: user, + assigned_to: user, + responsible: user, story_points: 1, + estimated_hours: 10, + done_ratio: 25, + remaining_hours: 9, + parent: parent_work_package, + priority:, + version:, + status:, + category:, description:, custom_values: { - cf_long_text.id => "foo", + cf_long_text.id => cf_long_text_description, cf_disabled_in_project.id => "6.25", cf_global_bool.id => true }).tap do |wp| @@ -109,6 +141,15 @@ export.export! end end + let(:expected_details) do + exporter.send(:attributes_data_by_wp, work_package) + .flat_map do |item| + value = get_column_value(item[:name]) + result = [item[:label].upcase] + result << value if value.present? + result + end + end def get_column_value(column_name) formatter = Exports::Register.formatter_for(WorkPackage, column_name, :pdf) @@ -126,18 +167,10 @@ def get_column_value(column_name) describe "with a request for a PDF" do it "contains correct data" do - details = exporter.send(:attributes_data_by_wp, work_package) - .flat_map do |item| - value = get_column_value(item[:name]) - result = [item[:label].upcase] - result << value if value.present? - result - end - # Joining the results for comparison since word wrapping leads to a different array for the same content - result = pdf[:strings].join(" ") + result = pdf[:strings] expected_result = [ "#{type.name} ##{work_package.id} - #{work_package.subject}", - *details, + *expected_details, label_title(:description), "Lorem", " ", "ipsum", " ", "dolor", " ", "sit", " ", "amet", ", consetetur sadipscing elitr.", " ", "@OpenProject Admin", @@ -145,10 +178,97 @@ def get_column_value(column_name) "Foo", "LongText", "foo", "1", export_time_formatted, project.name - ].join(" ") - expect(result).to eq(expected_result) - expect(result).not_to include("DisabledCustomField") + ].flatten + # Joining the results for comparison since word wrapping leads to a different array for the same content + expect(result.join(" ")).to eq(expected_result.join(" ")) + expect(result.join(" ")).not_to include("DisabledCustomField") expect(pdf[:images].length).to eq(2) end + + describe "with embedded attributes" do + let(:supported_work_package_embeds) do + [ + ["assignee", user.name], + ["author", user.name], + ["category", category.name], + ["createdAt", export_time_formatted], + ["updatedAt", export_time_formatted], + ["estimatedTime", "10.0 h"], + ["remainingTime", "9.0 h"], + ["version", version.name], + ["responsible", user.name], + ["dueDate", "05/30/2024"], + ["spentTime", "0.0 h"], + ["startDate", "05/30/2024"], + ["parent", "#{type.name} ##{parent_work_package.id}: #{parent_work_package.name}"], + ["priority", priority.name], + ["project", project.name], + ["status", status.name], + ["subject", "Work package 1"], + ["type", type.name], + ["description", "[Rich text embedding currently not supported in export]"] + ] + end + let(:supported_project_embeds) do + [ + ["active", "Yes"], + ["description", "[Rich text embedding currently not supported in export]"], + ["identifier", project.identifier], + ["name", project.name], + ["status", I18n.t("activerecord.attributes.project.status_codes.#{project.status_code}")], + ["statusExplanation", "[Rich text embedding currently not supported in export]"], + ["parent", parent_project.name], + ["public", "Yes"] + ] + end + let(:supported_work_package_embeds_table) do + supported_work_package_embeds.map do |embed| + "<tr><td>workPackageLabel:#{embed[0]}</td><td>workPackageValue:#{embed[0]}</td></tr>" + end + end + let(:description) do + <<~DESCRIPTION + ## Work package attributes and labels + <table><tbody>#{supported_work_package_embeds_table}</tbody></table> + DESCRIPTION + end + let(:supported_project_embeds_table) do + supported_project_embeds.map do |embed| + "<tr><td>projectLabel:#{embed[0]}</td><td>projectValue:#{embed[0]}</td></tr>" + end + end + let(:cf_long_text_description) do + <<~DESCRIPTION + ## Project attributes and labels + <table><tbody>#{supported_project_embeds_table}</tbody></table> + DESCRIPTION + end + + it "contains resolved attributes and labels" do + result = pdf[:strings] + expected_result = [ + "#{type.name} ##{work_package.id} - #{work_package.subject}", + *expected_details, + label_title(:description), + "Work package attributes and labels", + supported_work_package_embeds.map do |embed| + [WorkPackage.human_attribute_name( + API::Utilities::PropertyNameConverter.to_ar_name(embed[0].to_sym, context: work_package) + ), embed[1]] + end, + "1", export_time_formatted, project.name, + "LongText", + "Project attributes and labels", + supported_project_embeds.map do |embed| + [Project.human_attribute_name( + API::Utilities::PropertyNameConverter.to_ar_name(embed[0].to_sym, context: project) + ), embed[1]] + end, + "2", export_time_formatted, project.name + ].flatten + # Joining the results for comparison since word wrapping leads to a different array for the same content + expect(result.join(" ")).to eq(expected_result.join(" ")) + end + end end end From d01ba3be1cc0ee5d373b7da63026e05343bd3a1c Mon Sep 17 00:00:00 2001 From: as-op <a.sandorf@openproject.com> Date: Thu, 30 May 2024 15:49:50 +0200 Subject: [PATCH 02/19] fix(lint): obey rubocop --- .../pdf_export/work_package_detail.rb | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/app/models/work_package/pdf_export/work_package_detail.rb b/app/models/work_package/pdf_export/work_package_detail.rb index 2f575c12bdc6..632ea724c8fc 100644 --- a/app/models/work_package/pdf_export/work_package_detail.rb +++ b/app/models/work_package/pdf_export/work_package_detail.rb @@ -135,15 +135,19 @@ def form_configuration_columns(work_package) end.flatten end - def form_key_to_column_entries(form_key, work_package) - if CustomField.custom_field_attribute? form_key - id = form_key.to_s.sub("custom_field_", "").to_i - cf = CustomField.find_by(id:) - return [] if cf.nil? || cf.formattable? + def form_key_custom_field_to_column_entries(form_key, work_package) + id = form_key.to_s.sub("custom_field_", "").to_i + cf = CustomField.find_by(id:) + return [] if cf.nil? || cf.formattable? + + return [] unless cf.is_for_all? || work_package.project.work_package_custom_field_ids.include?(cf.id) - return [] unless cf.is_for_all? || work_package.project.work_package_custom_field_ids.include?(cf.id) + [{ label: cf.name || form_key, name: form_key.to_s.sub("custom_field_", "cf_") }] + end - return [{ label: cf.name || form_key, name: form_key.to_s.sub("custom_field_", "cf_") }] + def form_key_to_column_entries(form_key, work_package) + if CustomField.custom_field_attribute? form_key + return form_key_custom_field_to_column_entries(form_key, work_package) end if form_key == :date From 9b958f8ce3f96eedc5d7c325262464af8727f272 Mon Sep 17 00:00:00 2001 From: as-op <a.sandorf@openproject.com> Date: Mon, 3 Jun 2024 13:31:07 +0200 Subject: [PATCH 03/19] fix(export): pdf uses Yes/No for boolean custom fields; XLS & CVS do not --- app/models/exports/formatters/custom_field.rb | 6 ++--- .../exports/formatters/custom_field_pdf.rb | 27 +++++++++++++++++++ config/initializers/export_formats.rb | 1 + 3 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 app/models/exports/formatters/custom_field_pdf.rb diff --git a/app/models/exports/formatters/custom_field.rb b/app/models/exports/formatters/custom_field.rb index 045b197bb9f2..66dac9606b14 100644 --- a/app/models/exports/formatters/custom_field.rb +++ b/app/models/exports/formatters/custom_field.rb @@ -1,8 +1,8 @@ module Exports module Formatters class CustomField < Default - def self.apply?(attribute, _export_format) - attribute.start_with?("cf_") + def self.apply?(attribute, export_format) + export_format != :pdf && attribute.start_with?("cf_") end ## @@ -24,7 +24,7 @@ def format_for_export(object, custom_field) case custom_field.field_format when "bool" value = object.typed_custom_value_for(custom_field) - value ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No) + value == nil ? false : value when "text" object.typed_custom_value_for(custom_field) else diff --git a/app/models/exports/formatters/custom_field_pdf.rb b/app/models/exports/formatters/custom_field_pdf.rb new file mode 100644 index 000000000000..a299f785a169 --- /dev/null +++ b/app/models/exports/formatters/custom_field_pdf.rb @@ -0,0 +1,27 @@ +module Exports + module Formatters + class CustomFieldPdf < CustomField + def self.apply?(attribute, export_format) + export_format == :pdf && attribute.start_with?("cf_") + end + + ## + # Print the value meant for export. + # + # - For boolean values, use the Yes/No formatting for the PDF + # treat nil as false + # - For long text values, output the plain value + def format_for_export(object, custom_field) + case custom_field.field_format + when "bool" + value = object.typed_custom_value_for(custom_field) + value ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No) + when "text" + object.typed_custom_value_for(custom_field) + else + object.formatted_custom_value_for(custom_field) + end + end + end + end +end diff --git a/config/initializers/export_formats.rb b/config/initializers/export_formats.rb index 26f2092b4128..3b959de78632 100644 --- a/config/initializers/export_formats.rb +++ b/config/initializers/export_formats.rb @@ -15,6 +15,7 @@ formatter WorkPackage, WorkPackage::Exports::Formatters::Costs formatter WorkPackage, WorkPackage::Exports::Formatters::DoneRatio formatter WorkPackage, Exports::Formatters::CustomField + formatter WorkPackage, Exports::Formatters::CustomFieldPdf list Project, Projects::Exports::CSV formatter Project, Exports::Formatters::CustomField From e40050c19040fd1b1fdb55c9444250f30992a5b7 Mon Sep 17 00:00:00 2001 From: as-op <a.sandorf@openproject.com> Date: Mon, 3 Jun 2024 15:15:27 +0200 Subject: [PATCH 04/19] extract I18n strings, adjust specs, test for excluded replacements e.g. markdown/html code blocks --- .../work_package/exports/macros/attributes.rb | 30 +++++++++++++------ config/locales/en.yml | 6 ++++ .../pdf_export/work_package_to_pdf_spec.rb | 23 +++++++++++--- 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb index 6ce70aee38a2..2fcc46d4be1d 100644 --- a/app/models/work_package/exports/macros/attributes.rb +++ b/app/models/work_package/exports/macros/attributes.rb @@ -67,10 +67,22 @@ def self.resolve_match(type, model_s, id, attribute, work_package, user) elsif model_s == "project" resolve_project_match(id || work_package.project.id, type, attribute, user) else - "[Error: Invalid attribute macro: #{model_s}]" + msg_macro_error I18n.t('export.macro.model_not_found', model: model_s) end end + def self.msg_macro_error(message) + msg_inline I18n.t('export.macro.error', message:) + end + + def self.msg_macro_error_rich_text + msg_inline I18n.t('export.macro.rich_text_unsupported') + end + + def self.msg_inline(message) + "[#{message}]" + end + def self.resolve_label_work_package(attribute) resolve_label(WorkPackage, attribute) end @@ -87,11 +99,11 @@ def self.resolve_label(model, attribute) def self.resolve_work_package_match(id, type, attribute, user) return resolve_label_work_package(attribute) if type == "label" - return "[Error: Invalid attribute macro: #{type}]" unless type == "value" + return msg_macro_error(I18n.t('export.macro.model_not_found', model: type)) unless type == "value" work_package = WorkPackage.find_by(id:) if work_package.nil? || !user.allowed_in_project?(:view_work_packages, work_package.project) - return "[Error: #{WorkPackage.name} #{id} not found}]" + return msg_macro_error(I18n.t('export.macro.resource_not_found', resource: "#{WorkPackage.name} #{id}")) end resolve_value_work_package(work_package, attribute) @@ -99,11 +111,11 @@ def self.resolve_work_package_match(id, type, attribute, user) def self.resolve_project_match(id, type, attribute, user) return resolve_label_project(attribute) if type == "label" - return "[Error: Invalid attribute macro: #{type}]" unless type == "value" + return msg_macro_error(I18n.t("export.macro.model_not_found", model: type)) unless type == "value" project = Project.find_by(id:) if project.nil? || !user.allowed_in_project?(:view_project, project) - return "[Error: #{Project.name} #{id} not found}]" + return msg_macro_error(I18n.t("export.macro.resource_not_found", resource: "#{Project.name} #{id}")) end resolve_value_project(project, attribute) @@ -121,14 +133,14 @@ def self.resolve_value_project(project, attribute) else ar_name = "cf_#{cf.id}" # currently we do not support embedding rich text fields: long text custom fields - return "[Rich text embedding currently not supported in export]" if cf.formattable? + return msg_macro_error_rich_text if cf.formattable? # TODO: Is the user allowed to see this custom field/"project attribute"? end # currently we do not support embedding rich text field: e.g. projectValue:1234:description if DISABLED_PROJECT_RICH_TEXT_FIELDS.include?(ar_name.to_sym) - return "[Rich text embedding currently not supported in export]" + return msg_macro_error_rich_text end @@ -142,14 +154,14 @@ def self.resolve_value_work_package(work_package, attribute) else ar_name = "cf_#{cf.id}" # currently we do not support embedding rich text fields: long text custom fields - return "[Rich text embedding currently not supported in export]" if cf.formattable? + return msg_macro_error_rich_text if cf.formattable? # TODO: Are there access restrictions on work_package custom fields? end # currently we do not support embedding rich text field: workPackageValue:1234:description if DISABLED_WORK_PACKAGE_RICH_TEXT_FIELDS.include?(ar_name.to_sym) - return "[Rich text embedding currently not supported in export]" + return msg_macro_error_rich_text end diff --git a/config/locales/en.yml b/config/locales/en.yml index 5c221a64da4c..5b079c47a579 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1705,6 +1705,12 @@ Project attributes and sections are defined in the <a href=%{admin_settings_url} pdf_gantt: "PDF Gantt" image: omitted: "Image not exported." + macro: + error: "Macro error: %{message}" + attribute_not_found: "Attribute not found: %{attribute}" + model_not_found: "Invalid attribute model: %{model}" + resource_not_found: "Invalid attribute model: %{resource}" + rich_text_unsupported: "Rich text embedding currently not supported in export." units: hours: h days: d diff --git a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb index 4491fbbd77e7..82f89de5e779 100644 --- a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb +++ b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb @@ -206,17 +206,17 @@ def get_column_value(column_name) ["status", status.name], ["subject", "Work package 1"], ["type", type.name], - ["description", "[Rich text embedding currently not supported in export]"] + ["description", "[#{I18n.t('export.macro.rich_text_unsupported')}]"] ] end let(:supported_project_embeds) do [ ["active", "Yes"], - ["description", "[Rich text embedding currently not supported in export]"], + ["description", "[#{I18n.t('export.macro.rich_text_unsupported')}]"], ["identifier", project.identifier], ["name", project.name], ["status", I18n.t("activerecord.attributes.project.status_codes.#{project.status_code}")], - ["statusExplanation", "[Rich text embedding currently not supported in export]"], + ["statusExplanation", "[#{I18n.t('export.macro.rich_text_unsupported')}]"], ["parent", parent_project.name], ["public", "Yes"] ] @@ -229,7 +229,19 @@ def get_column_value(column_name) let(:description) do <<~DESCRIPTION ## Work package attributes and labels - <table><tbody>#{supported_work_package_embeds_table}</tbody></table> + <table><tbody>#{supported_work_package_embeds_table} + <tr><td>No replacement of:</td><td> + <code>workPackageValue:1:assignee</code> + <code>workPackageLabel:assignee</code> + </td></tr> + </tbody></table> + + `workPackageValue:2:assignee workPackageLabel:assignee` + + ``` + workPackageValue:3:assignee + workPackageLabel:assignee + ``` DESCRIPTION end let(:supported_project_embeds_table) do @@ -256,7 +268,10 @@ def get_column_value(column_name) API::Utilities::PropertyNameConverter.to_ar_name(embed[0].to_sym, context: work_package) ), embed[1]] end, + "No replacement of:", "workPackageValue:1:assignee", " ", "workPackageLabel:assignee", + "workPackageValue:2:assignee workPackageLabel:assignee", "1", export_time_formatted, project.name, + "workPackageValue:3:assignee", "workPackageLabel:assignee", "LongText", "Project attributes and labels", supported_project_embeds.map do |embed| From 6c356b111131726e7453c76f7ff2c2a8df4ef0d5 Mon Sep 17 00:00:00 2001 From: as-op <a.sandorf@openproject.com> Date: Tue, 4 Jun 2024 12:28:08 +0200 Subject: [PATCH 05/19] test for permissions on attribute embeds --- .../work_package/exports/macros/attributes.rb | 6 +- config/locales/en.yml | 10 +- .../pdf_export/work_package_to_pdf_spec.rb | 207 +++++++++++++----- 3 files changed, 159 insertions(+), 64 deletions(-) diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb index 2fcc46d4be1d..659e5db19562 100644 --- a/app/models/work_package/exports/macros/attributes.rb +++ b/app/models/work_package/exports/macros/attributes.rb @@ -102,7 +102,8 @@ def self.resolve_work_package_match(id, type, attribute, user) return msg_macro_error(I18n.t('export.macro.model_not_found', model: type)) unless type == "value" work_package = WorkPackage.find_by(id:) - if work_package.nil? || !user.allowed_in_project?(:view_work_packages, work_package.project) + if work_package.nil? || + !user.allowed_in_work_package?(:view_work_packages, work_package) return msg_macro_error(I18n.t('export.macro.resource_not_found', resource: "#{WorkPackage.name} #{id}")) end @@ -114,7 +115,8 @@ def self.resolve_project_match(id, type, attribute, user) return msg_macro_error(I18n.t("export.macro.model_not_found", model: type)) unless type == "value" project = Project.find_by(id:) - if project.nil? || !user.allowed_in_project?(:view_project, project) + if project.nil? || + !user.allowed_in_project?(:view_project, project) return msg_macro_error(I18n.t("export.macro.resource_not_found", resource: "#{Project.name} #{id}")) end diff --git a/config/locales/en.yml b/config/locales/en.yml index 5b079c47a579..802636d26d12 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1706,11 +1706,11 @@ Project attributes and sections are defined in the <a href=%{admin_settings_url} image: omitted: "Image not exported." macro: - error: "Macro error: %{message}" - attribute_not_found: "Attribute not found: %{attribute}" - model_not_found: "Invalid attribute model: %{model}" - resource_not_found: "Invalid attribute model: %{resource}" - rich_text_unsupported: "Rich text embedding currently not supported in export." + error: "Macro error, %{message}" + attribute_not_found: "attribute not found: %{attribute}" + model_not_found: "invalid attribute model: %{model}" + resource_not_found: "resource not found: %{resource}" + rich_text_unsupported: "Rich text embedding currently not supported in export" units: hours: h days: d diff --git a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb index 82f89de5e779..208e33e6cba8 100644 --- a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb +++ b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb @@ -50,10 +50,25 @@ work_package_custom_fields: [cf_long_text, cf_disabled_in_project, cf_global_bool], work_package_custom_field_ids: [cf_long_text.id, cf_global_bool.id]) # cf_disabled_in_project.id is disabled end + let(:forbidden_project) do + create(:project, + name: "Forbidden project", + types: [type], + id: 666, + public: true, + status_code: "on_track", + active: true, + parent: parent_project, + work_package_custom_fields: [cf_long_text, cf_disabled_in_project, cf_global_bool], + work_package_custom_field_ids: [cf_long_text.id, cf_global_bool.id]) # cf_disabled_in_project.id is disabled + end let(:user) do create(:user, member_with_permissions: { project => %w[view_work_packages export_work_packages] }) end + let(:another_user) do + create(:user, firstname: "Secret User") + end let(:category) { create(:category, project:, name: "Demo") } let(:version) { create(:version, project:) } let(:export_time) { DateTime.new(2023, 6, 30, 23, 59) } @@ -62,8 +77,9 @@ let(:priority) { create(:priority_normal) } let(:image_attachment) { Attachment.new author: user, file: File.open(image_path) } let(:attachments) { [image_attachment] } - let(:cf_long_text_description) { "foo" } - let(:cf_long_text) { create(:issue_custom_field, :text, name: "LongText") } + let(:cf_long_text_description) { "" } + let(:cf_long_text) { create(:issue_custom_field, :text, + name: "Work Package Custom Field Long Text") } let!(:cf_disabled_in_project) do # NOT enabled by project.work_package_custom_field_ids => NOT in PDF create(:float_wp_custom_field, name: "DisabledCustomField") @@ -71,6 +87,7 @@ let(:cf_global_bool) do create( :work_package_custom_field, + name: "Work Package Custom Field Boolean", field_format: "bool", is_for_all: true, default_value: true @@ -78,7 +95,6 @@ end let(:status) { create(:status, name: "random", is_default: true) } let!(:parent_work_package) { create(:work_package, type:, subject: "Parent wp") } - let(:description) do <<~DESCRIPTION **Lorem** _ipsum_ ~~dolor~~ `sit` [amet](https://example.com/), consetetur sadipscing elitr. @@ -128,6 +144,24 @@ .and_return attachments end end + let(:forbidden_work_package) do + create(:work_package, + id: 10, + project: forbidden_project, + type:, + subject: "forbidden Work package", + start_date: "2024-05-30", + due_date: "2024-05-30", + created_at: export_time, + updated_at: export_time, + author: another_user, + assigned_to: another_user + ).tap do |wp| + allow(wp) + .to receive(:attachments) + .and_return attachments + end + end let(:options) { {} } let(:exporter) do described_class.new(work_package, options) @@ -142,13 +176,14 @@ end end let(:expected_details) do - exporter.send(:attributes_data_by_wp, work_package) - .flat_map do |item| - value = get_column_value(item[:name]) - result = [item[:label].upcase] - result << value if value.present? - result - end + ["#{type.name} ##{work_package.id} - #{work_package.subject}"] + + exporter.send(:attributes_data_by_wp, work_package) + .flat_map do |item| + value = get_column_value(item[:name]) + result = [item[:label].upcase] + result << value if value.present? + result + end end def get_column_value(column_name) @@ -166,26 +201,28 @@ def get_column_value(column_name) end describe "with a request for a PDF" do - it "contains correct data" do - result = pdf[:strings] - expected_result = [ - "#{type.name} ##{work_package.id} - #{work_package.subject}", - *expected_details, - label_title(:description), - "Lorem", " ", "ipsum", " ", "dolor", " ", "sit", " ", - "amet", ", consetetur sadipscing elitr.", " ", "@OpenProject Admin", - "Image Caption", - "Foo", - "LongText", "foo", - "1", export_time_formatted, project.name - ].flatten - # Joining the results for comparison since word wrapping leads to a different array for the same content - expect(result.join(" ")).to eq(expected_result.join(" ")) - expect(result.join(" ")).not_to include("DisabledCustomField") - expect(pdf[:images].length).to eq(2) + describe "with rich text and images" do + let(:cf_long_text_description) { "foo" } + it "contains correct data" do + result = pdf[:strings] + expected_result = [ + *expected_details, + label_title(:description), + "Lorem", " ", "ipsum", " ", "dolor", " ", "sit", " ", + "amet", ", consetetur sadipscing elitr.", " ", "@OpenProject Admin", + "Image Caption", + "Foo", + cf_long_text.name, "foo", + "1", export_time_formatted, project.name + ].flatten + # Joining the results for comparison since word wrapping leads to a different array for the same content + expect(result.join(" ")).to eq(expected_result.join(" ")) + expect(result.join(" ")).not_to include("DisabledCustomField") + expect(pdf[:images].length).to eq(2) + end end - describe "with embedded attributes" do + describe "with embedded work package attributes" do let(:supported_work_package_embeds) do [ ["assignee", user.name], @@ -209,18 +246,6 @@ def get_column_value(column_name) ["description", "[#{I18n.t('export.macro.rich_text_unsupported')}]"] ] end - let(:supported_project_embeds) do - [ - ["active", "Yes"], - ["description", "[#{I18n.t('export.macro.rich_text_unsupported')}]"], - ["identifier", project.identifier], - ["name", project.name], - ["status", I18n.t("activerecord.attributes.project.status_codes.#{project.status_code}")], - ["statusExplanation", "[#{I18n.t('export.macro.rich_text_unsupported')}]"], - ["parent", parent_project.name], - ["public", "Yes"] - ] - end let(:supported_work_package_embeds_table) do supported_work_package_embeds.map do |embed| "<tr><td>workPackageLabel:#{embed[0]}</td><td>workPackageValue:#{embed[0]}</td></tr>" @@ -230,6 +255,12 @@ def get_column_value(column_name) <<~DESCRIPTION ## Work package attributes and labels <table><tbody>#{supported_work_package_embeds_table} + <tr><td>Custom field boolean</td><td> + workPackageValue:1:"#{cf_global_bool.name}" + </td></tr> + <tr><td>Custom field rich text</td><td> + workPackageValue:1:"#{cf_long_text.name}" + </td></tr> <tr><td>No replacement of:</td><td> <code>workPackageValue:1:assignee</code> <code>workPackageLabel:assignee</code> @@ -242,24 +273,16 @@ def get_column_value(column_name) workPackageValue:3:assignee workPackageLabel:assignee ``` + + Work package not found: + workPackageValue:1234567890:assignee + Access denied: + workPackageValue:#{forbidden_work_package.id}:assignee DESCRIPTION end - let(:supported_project_embeds_table) do - supported_project_embeds.map do |embed| - "<tr><td>projectLabel:#{embed[0]}</td><td>projectValue:#{embed[0]}</td></tr>" - end - end - let(:cf_long_text_description) do - <<~DESCRIPTION - ## Project attributes and labels - <table><tbody>#{supported_project_embeds_table}</tbody></table> - DESCRIPTION - end - it "contains resolved attributes and labels" do result = pdf[:strings] expected_result = [ - "#{type.name} ##{work_package.id} - #{work_package.subject}", *expected_details, label_title(:description), "Work package attributes and labels", @@ -268,20 +291,90 @@ def get_column_value(column_name) API::Utilities::PropertyNameConverter.to_ar_name(embed[0].to_sym, context: work_package) ), embed[1]] end, + "Custom field boolean", I18n.t(:general_text_Yes), + "1", export_time_formatted, project.name, + "Custom field rich text", "[#{I18n.t('export.macro.rich_text_unsupported')}]", "No replacement of:", "workPackageValue:1:assignee", " ", "workPackageLabel:assignee", "workPackageValue:2:assignee workPackageLabel:assignee", - "1", export_time_formatted, project.name, "workPackageValue:3:assignee", "workPackageLabel:assignee", - "LongText", + "Work package not found: ", + "[#{I18n.t('export.macro.error', message: + I18n.t('export.macro.resource_not_found', resource: "WorkPackage 1234567890"))}] ", + "Access denied: ", + "[#{I18n.t('export.macro.error', message: + I18n.t('export.macro.resource_not_found', resource: "WorkPackage #{forbidden_work_package.id}"))}]", + "2", export_time_formatted, project.name + ].flatten + expect(result.join(" ")).to eq(expected_result.join(" ")) + end + end + + describe "with embedded project attributes" do + let(:supported_project_embeds) do + [ + ["active", I18n.t(:general_text_Yes)], + ["description", "[#{I18n.t('export.macro.rich_text_unsupported')}]"], + ["identifier", project.identifier], + ["name", project.name], + ["status", I18n.t("activerecord.attributes.project.status_codes.#{project.status_code}")], + ["statusExplanation", "[#{I18n.t('export.macro.rich_text_unsupported')}]"], + ["parent", parent_project.name], + ["public", I18n.t(:general_text_Yes)] + ] + end + let(:supported_project_embeds_table) do + supported_project_embeds.map do |embed| + "<tr><td>projectLabel:#{embed[0]}</td><td>projectValue:#{embed[0]}</td></tr>" + end + end + let(:description) do + <<~DESCRIPTION + ## Project attributes and labels + <table><tbody>#{supported_project_embeds_table} + <tr><td>No replacement of:</td><td> + <code>projectValue:1:status</code> + <code>projectLabel:status</code> + </td></tr> + </tbody></table> + + `projectValue:2:status projectLabel:status` + + ``` + projectValue:3:status + projectLabel:status + ``` + + Project not found: + projectValue:1234567890:active + Access denied: + projectValue:#{forbidden_project.id}:active + DESCRIPTION + end + it "contains resolved attributes and labels" do + result = pdf[:strings] + expected_result = [ + *expected_details, + label_title(:description), "Project attributes and labels", supported_project_embeds.map do |embed| [Project.human_attribute_name( API::Utilities::PropertyNameConverter.to_ar_name(embed[0].to_sym, context: project) ), embed[1]] end, - "2", export_time_formatted, project.name + + "No replacement of:", "projectValue:1:status", " ", "projectLabel:status", + "projectValue:2:status projectLabel:status", + "projectValue:3:status", "projectLabel:status", + + "Project not found: ", + "[#{I18n.t('export.macro.error', message: + I18n.t('export.macro.resource_not_found', resource: "Project 1234567890"))}] ", + "Access denied: ", + "[#{I18n.t('export.macro.error', message: + I18n.t('export.macro.resource_not_found', resource: "Project #{forbidden_project.id}"))}]", + + "1", export_time_formatted, project.name ].flatten - # Joining the results for comparison since word wrapping leads to a different array for the same content expect(result.join(" ")).to eq(expected_result.join(" ")) end end From 9ff727c505d264fd503337bc5e6f71b79babde64 Mon Sep 17 00:00:00 2001 From: as-op <a.sandorf@openproject.com> Date: Tue, 4 Jun 2024 13:02:04 +0200 Subject: [PATCH 06/19] check for display rights of project attributes --- app/models/exports/formatters/custom_field.rb | 2 +- app/models/work_package/exports/macros/attributes.rb | 5 ++++- config/initializers/export_formats.rb | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/models/exports/formatters/custom_field.rb b/app/models/exports/formatters/custom_field.rb index 66dac9606b14..ef55bd0aff25 100644 --- a/app/models/exports/formatters/custom_field.rb +++ b/app/models/exports/formatters/custom_field.rb @@ -6,7 +6,7 @@ def self.apply?(attribute, export_format) end ## - # Takes a WorkPackage and an attribute and returns the value to be exported. + # Takes a WorkPackage or Project and an attribute and returns the value to be exported. def retrieve_value(object) custom_field = find_custom_field(object) return "" if custom_field.nil? diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb index 659e5db19562..eb48c6be8632 100644 --- a/app/models/work_package/exports/macros/attributes.rb +++ b/app/models/work_package/exports/macros/attributes.rb @@ -137,7 +137,10 @@ def self.resolve_value_project(project, attribute) # currently we do not support embedding rich text fields: long text custom fields return msg_macro_error_rich_text if cf.formattable? - # TODO: Is the user allowed to see this custom field/"project attribute"? + # Is the user allowed to see this custom field/"project attribute"? + if project.available_custom_fields.find { |pcf| pcf.id == cf.id }.nil? + return msg_macro_error(I18n.t("export.macro.resource_not_found", resource: attribute)) + end end # currently we do not support embedding rich text field: e.g. projectValue:1234:description diff --git a/config/initializers/export_formats.rb b/config/initializers/export_formats.rb index 3b959de78632..9d7603cef5e9 100644 --- a/config/initializers/export_formats.rb +++ b/config/initializers/export_formats.rb @@ -19,6 +19,7 @@ list Project, Projects::Exports::CSV formatter Project, Exports::Formatters::CustomField + formatter Project, Exports::Formatters::CustomFieldPdf formatter Project, Projects::Exports::Formatters::Status formatter Project, Projects::Exports::Formatters::Description formatter Project, Projects::Exports::Formatters::Public From ae934bb2aa0ddd0e43f4bf21264a44df4382d8b2 Mon Sep 17 00:00:00 2001 From: as-op <a.sandorf@openproject.com> Date: Tue, 4 Jun 2024 13:38:31 +0200 Subject: [PATCH 07/19] test for display rights of project attributes --- .../pdf_export/work_package_to_pdf_spec.rb | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb index 208e33e6cba8..814f76bc9a18 100644 --- a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb +++ b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb @@ -39,6 +39,19 @@ let(:parent_project) do create(:project, name: "Parent project") end + let(:project_custom_field_bool) { create(:project_custom_field, :boolean, + name: "Boolean project custom field") } + let(:project_custom_field_string) { + create(:project_custom_field, :string, + name: "Secret string", default_value: "admin eyes only", + visible: false) + } + let(:project_custom_field_long_text) { + create(:project_custom_field, :text, + name: "Rich text project custom field", + default_value: "rich text field value" + ) + } let(:project) do create(:project, name: "Foo Bla. Report No. 4/2021 with/for Case 42", @@ -47,6 +60,9 @@ status_code: "on_track", active: true, parent: parent_project, + custom_field_values: { + project_custom_field_bool.id => true + }, work_package_custom_fields: [cf_long_text, cf_disabled_in_project, cf_global_bool], work_package_custom_field_ids: [cf_long_text.id, cf_global_bool.id]) # cf_disabled_in_project.id is disabled end @@ -55,7 +71,7 @@ name: "Forbidden project", types: [type], id: 666, - public: true, + public: false, status_code: "on_track", active: true, parent: parent_project, @@ -331,6 +347,15 @@ def get_column_value(column_name) <<~DESCRIPTION ## Project attributes and labels <table><tbody>#{supported_project_embeds_table} + <tr><td>Custom field boolean</td><td> + projectValue:"#{project_custom_field_bool.name}" + </td></tr> + <tr><td>Custom field rich text</td><td> + projectValue:"#{project_custom_field_long_text.name}" + </td></tr> + <tr><td>Custom field hidden</td><td> + projectValue:"#{project_custom_field_string.name}" + </td></tr> <tr><td>No replacement of:</td><td> <code>projectValue:1:status</code> <code>projectLabel:status</code> @@ -361,6 +386,11 @@ def get_column_value(column_name) API::Utilities::PropertyNameConverter.to_ar_name(embed[0].to_sym, context: project) ), embed[1]] end, + "Custom field boolean", I18n.t(:general_text_Yes), + "Custom field rich text", "[#{I18n.t('export.macro.rich_text_unsupported')}]", + "Custom field hidden", + "[#{I18n.t('export.macro.error', message: + I18n.t('export.macro.resource_not_found', resource: "Secret string"))}]", "No replacement of:", "projectValue:1:status", " ", "projectLabel:status", "projectValue:2:status projectLabel:status", From 96df4f5cd3952bdc0926eccc7a42d4d3c2c5f1f2 Mon Sep 17 00:00:00 2001 From: as-op <a.sandorf@openproject.com> Date: Tue, 4 Jun 2024 15:47:01 +0200 Subject: [PATCH 08/19] fix missing variable for wp list to pdf report --- app/models/work_package/pdf_export/markdown_field.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/models/work_package/pdf_export/markdown_field.rb b/app/models/work_package/pdf_export/markdown_field.rb index b3a6447b8a94..ddb7648c8b0f 100644 --- a/app/models/work_package/pdf_export/markdown_field.rb +++ b/app/models/work_package/pdf_export/markdown_field.rb @@ -38,17 +38,17 @@ def write_markdown_field!(work_package, markdown, label) pdf.formatted_text([styles.wp_markdown_label.merge({ text: label })]) end with_margin(styles.wp_markdown_margins) do - write_markdown! work_package, apply_markdown_field_macros(markdown) + write_markdown! work_package, apply_markdown_field_macros(markdown, work_package) end end private - def apply_markdown_field_macros(markdown) - apply_macros(markdown, WorkPackage::Exports::Macros::Attributes) + def apply_markdown_field_macros(markdown, work_package) + apply_macros(markdown, work_package, WorkPackage::Exports::Macros::Attributes) end - def apply_macros(markdown, formatter) + def apply_macros(markdown, work_package, formatter) return markdown unless formatter.applicable?(markdown) document = Markly.parse(markdown) From bb8634bac90b0f31077abeb9ca855110340910be Mon Sep 17 00:00:00 2001 From: as-op <a.sandorf@openproject.com> Date: Wed, 5 Jun 2024 15:24:32 +0200 Subject: [PATCH 09/19] use ProjectCustomField.find_by https://github.com/opf/openproject/pull/15702#discussion_r1627286353 --- app/models/work_package/exports/macros/attributes.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb index eb48c6be8632..a2867942c59c 100644 --- a/app/models/work_package/exports/macros/attributes.rb +++ b/app/models/work_package/exports/macros/attributes.rb @@ -129,7 +129,7 @@ def self.escape_tags(value) end def self.resolve_value_project(project, attribute) - cf = CustomField.find_by(name: attribute, type: "ProjectCustomField") + cf = ProjectCustomField.find_by(name: attribute) if cf.nil? ar_name = ::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: project) else From 3664727f15ede418e2ac9d0206037e71d356a8b8 Mon Sep 17 00:00:00 2001 From: as-op <a.sandorf@openproject.com> Date: Wed, 5 Jun 2024 15:25:38 +0200 Subject: [PATCH 10/19] add markly comment https://github.com/opf/openproject/pull/15702#discussion_r1627296963 --- Gemfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index 9882d01a33d2..d6bac00979bb 100644 --- a/Gemfile +++ b/Gemfile @@ -157,7 +157,7 @@ gem "structured_warnings", "~> 0.4.0" # don't require by default, instead load on-demand when actually configured gem "airbrake", "~> 13.0.0", require: false -gem "markly", "~> 0.10" +gem "markly", "~> 0.10" # another markdown parser like commonmarker, but with AST support used in PDF export gem "md_to_pdf", git: "https://github.com/opf/md-to-pdf", ref: "8f14736a88ad0064d2a97be108fe7061ffbcee91" gem "prawn", "~> 2.4" gem "ttfunk", "~> 1.7.0" # remove after https://github.com/prawnpdf/prawn/issues/1346 resolved. From 558b80cba09a3e2374090b3747a831794b2bb561 Mon Sep 17 00:00:00 2001 From: as-op <a.sandorf@openproject.com> Date: Wed, 5 Jun 2024 15:25:53 +0200 Subject: [PATCH 11/19] fix typo --- app/models/work_package/pdf_export/work_package_list_to_pdf.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7de6d091f45a..f933b5f6ee7d 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 @@ -73,7 +73,7 @@ def export! rescue Prawn::Errors::CannotFit error(I18n.t(:error_pdf_export_too_many_columns)) rescue StandardError => e - Rails.logger.error { "Failed to generated PDF export: #{e}." } + Rails.logger.error { "Failed to generate PDF export: #{e}." } error(I18n.t(:error_pdf_failed_to_export, error: e.message[0..300])) end From 0f1e67be14fa3cfe6e257d20d1e37798b403a3e0 Mon Sep 17 00:00:00 2001 From: as-op <a.sandorf@openproject.com> Date: Wed, 5 Jun 2024 15:36:34 +0200 Subject: [PATCH 12/19] fix bug and comment in attribute formatter project.public --- app/models/projects/exports/formatters/public.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/projects/exports/formatters/public.rb b/app/models/projects/exports/formatters/public.rb index 40a6e85d1f0f..deb4deb03011 100644 --- a/app/models/projects/exports/formatters/public.rb +++ b/app/models/projects/exports/formatters/public.rb @@ -33,9 +33,9 @@ def self.apply?(attribute, export_format) end ## - # Takes a project and returns the localized status code + # Takes a project and returns yes/no depending on the public attribute def format(project, **) - project.active ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No) + project.public? ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No) end end end From 08602866f2a55917312978d194ee06049dcd5f6c Mon Sep 17 00:00:00 2001 From: as-op <a.sandorf@openproject.com> Date: Wed, 5 Jun 2024 15:36:45 +0200 Subject: [PATCH 13/19] fix comment in attribute formatter project.active --- app/models/projects/exports/formatters/active.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/projects/exports/formatters/active.rb b/app/models/projects/exports/formatters/active.rb index 81980c07a751..93a78add3542 100644 --- a/app/models/projects/exports/formatters/active.rb +++ b/app/models/projects/exports/formatters/active.rb @@ -33,9 +33,9 @@ def self.apply?(attribute, export_format) end ## - # Takes a project and returns the localized status code + # Takes a project and returns yes/no depending on the active attribute def format(project, **) - project.active ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No) + project.active? ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No) end end end From 076b56073bcf06f99139e20870b54b0fa9aafdc8 Mon Sep 17 00:00:00 2001 From: as-op <a.sandorf@openproject.com> Date: Wed, 5 Jun 2024 15:45:08 +0200 Subject: [PATCH 14/19] simplify wp access check & find https://github.com/opf/openproject/pull/15702#discussion_r1627409100 --- app/models/work_package/exports/macros/attributes.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb index a2867942c59c..99e8631ef425 100644 --- a/app/models/work_package/exports/macros/attributes.rb +++ b/app/models/work_package/exports/macros/attributes.rb @@ -101,9 +101,8 @@ def self.resolve_work_package_match(id, type, attribute, user) return resolve_label_work_package(attribute) if type == "label" return msg_macro_error(I18n.t('export.macro.model_not_found', model: type)) unless type == "value" - work_package = WorkPackage.find_by(id:) - if work_package.nil? || - !user.allowed_in_work_package?(:view_work_packages, work_package) + work_package = WorkPackage.visible(user).find_by(id:) + if work_package.nil? return msg_macro_error(I18n.t('export.macro.resource_not_found', resource: "#{WorkPackage.name} #{id}")) end From f578226fba26a3ec70d1c6cb8a8377454da81cfc Mon Sep 17 00:00:00 2001 From: as-op <a.sandorf@openproject.com> Date: Wed, 5 Jun 2024 15:46:05 +0200 Subject: [PATCH 15/19] simplify project access check & find https://github.com/opf/openproject/pull/15702#discussion_r1627409660 --- app/models/work_package/exports/macros/attributes.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb index 99e8631ef425..90a8e9f892ab 100644 --- a/app/models/work_package/exports/macros/attributes.rb +++ b/app/models/work_package/exports/macros/attributes.rb @@ -113,9 +113,8 @@ def self.resolve_project_match(id, type, attribute, user) return resolve_label_project(attribute) if type == "label" return msg_macro_error(I18n.t("export.macro.model_not_found", model: type)) unless type == "value" - project = Project.find_by(id:) - if project.nil? || - !user.allowed_in_project?(:view_project, project) + project = Project.visible(user).find_by(id:) + if project.nil? return msg_macro_error(I18n.t("export.macro.resource_not_found", resource: "#{Project.name} #{id}")) end From 6ab7b50e97c33b171dcf20209a633fb393f4daff Mon Sep 17 00:00:00 2001 From: as-op <a.sandorf@openproject.com> Date: Wed, 5 Jun 2024 16:43:30 +0200 Subject: [PATCH 16/19] simplify & unify attribute value resolving https://github.com/opf/openproject/pull/15702#discussion_r1627436214 https://github.com/opf/openproject/pull/15702#discussion_r1627444854 --- .../work_package/exports/macros/attributes.rb | 48 +++++-------------- .../pdf_export/work_package_to_pdf_spec.rb | 11 +++-- 2 files changed, 19 insertions(+), 40 deletions(-) diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb index 90a8e9f892ab..fa979b5e4d51 100644 --- a/app/models/work_package/exports/macros/attributes.rb +++ b/app/models/work_package/exports/macros/attributes.rb @@ -127,48 +127,26 @@ def self.escape_tags(value) end def self.resolve_value_project(project, attribute) - cf = ProjectCustomField.find_by(name: attribute) - if cf.nil? - ar_name = ::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: project) - else - ar_name = "cf_#{cf.id}" - # currently we do not support embedding rich text fields: long text custom fields - return msg_macro_error_rich_text if cf.formattable? - - # Is the user allowed to see this custom field/"project attribute"? - if project.available_custom_fields.find { |pcf| pcf.id == cf.id }.nil? - return msg_macro_error(I18n.t("export.macro.resource_not_found", resource: attribute)) - end - end - - # currently we do not support embedding rich text field: e.g. projectValue:1234:description - if DISABLED_PROJECT_RICH_TEXT_FIELDS.include?(ar_name.to_sym) - return msg_macro_error_rich_text - - end - - format_attribute_value(ar_name, Project, project) + resolve_value(project, attribute, DISABLED_PROJECT_RICH_TEXT_FIELDS) end def self.resolve_value_work_package(work_package, attribute) - cf = CustomField.find_by(name: attribute, type: "WorkPackageCustomField") - if cf.nil? - ar_name = ::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: work_package) - else - ar_name = "cf_#{cf.id}" - # currently we do not support embedding rich text fields: long text custom fields - return msg_macro_error_rich_text if cf.formattable? + resolve_value(work_package, attribute, DISABLED_WORK_PACKAGE_RICH_TEXT_FIELDS) + end - # TODO: Are there access restrictions on work_package custom fields? - end + def self.resolve_value(obj, attribute, disabled_rich_text_fields) + cf = obj.available_custom_fields.find { |pcf| pcf.name == attribute } - # currently we do not support embedding rich text field: workPackageValue:1234:description - if DISABLED_WORK_PACKAGE_RICH_TEXT_FIELDS.include?(ar_name.to_sym) - return msg_macro_error_rich_text + return msg_macro_error_rich_text if cf&.formattable? - end + ar_name = if cf.nil? + ::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: obj) + else + "cf_#{cf.id}" + end + return msg_macro_error_rich_text if disabled_rich_text_fields.include?(ar_name.to_sym) - format_attribute_value(ar_name, WorkPackage, work_package) + format_attribute_value(ar_name, obj.class, obj) end def self.format_attribute_value(ar_name, model, obj) diff --git a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb index 814f76bc9a18..9a703b1fb544 100644 --- a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb +++ b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb @@ -61,7 +61,8 @@ active: true, parent: parent_project, custom_field_values: { - project_custom_field_bool.id => true + project_custom_field_bool.id => true, + project_custom_field_long_text.id => "foo", }, work_package_custom_fields: [cf_long_text, cf_disabled_in_project, cf_global_bool], work_package_custom_field_ids: [cf_long_text.id, cf_global_bool.id]) # cf_disabled_in_project.id is disabled @@ -389,13 +390,13 @@ def get_column_value(column_name) "Custom field boolean", I18n.t(:general_text_Yes), "Custom field rich text", "[#{I18n.t('export.macro.rich_text_unsupported')}]", "Custom field hidden", - "[#{I18n.t('export.macro.error', message: - I18n.t('export.macro.resource_not_found', resource: "Secret string"))}]", - "No replacement of:", "projectValue:1:status", " ", "projectLabel:status", + "No replacement of:", "projectValue:1:status", "projectLabel:status", "projectValue:2:status projectLabel:status", "projectValue:3:status", "projectLabel:status", + "1", export_time_formatted, project.name, + "Project not found: ", "[#{I18n.t('export.macro.error', message: I18n.t('export.macro.resource_not_found', resource: "Project 1234567890"))}] ", @@ -403,7 +404,7 @@ def get_column_value(column_name) "[#{I18n.t('export.macro.error', message: I18n.t('export.macro.resource_not_found', resource: "Project #{forbidden_project.id}"))}]", - "1", export_time_formatted, project.name + "2", export_time_formatted, project.name, ].flatten expect(result.join(" ")).to eq(expected_result.join(" ")) end From 73aad7c16546d8e51049f63fdfc1efbb61af8917 Mon Sep 17 00:00:00 2001 From: as-op <a.sandorf@openproject.com> Date: Wed, 5 Jun 2024 16:48:49 +0200 Subject: [PATCH 17/19] remove already checked formatter.applicable? https://github.com/opf/openproject/pull/15702#discussion_r1627479843 --- app/models/work_package/pdf_export/markdown_field.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/models/work_package/pdf_export/markdown_field.rb b/app/models/work_package/pdf_export/markdown_field.rb index ddb7648c8b0f..b478d14278ab 100644 --- a/app/models/work_package/pdf_export/markdown_field.rb +++ b/app/models/work_package/pdf_export/markdown_field.rb @@ -72,8 +72,6 @@ def apply_macro_text(text, work_package, formatter) end def apply_macro_html(html, work_package, formatter) - return html unless formatter.applicable?(html) - doc = Nokogiri::HTML.fragment(html) apply_macro_html_node(doc, work_package, formatter) doc.to_html From 53337f54c7857e7f1c222978925ead5188dc9c68 Mon Sep 17 00:00:00 2001 From: as-op <a.sandorf@openproject.com> Date: Wed, 5 Jun 2024 17:38:06 +0200 Subject: [PATCH 18/19] support referencing project by identifier https://github.com/opf/openproject/pull/15702#discussion_r1627668679 --- app/models/work_package/exports/macros/attributes.rb | 1 + app/models/work_package/pdf_export/markdown_field.rb | 6 ++++-- .../pdf_export/work_package_to_pdf_spec.rb | 12 ++++++++++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb index fa979b5e4d51..4885c9ad355d 100644 --- a/app/models/work_package/exports/macros/attributes.rb +++ b/app/models/work_package/exports/macros/attributes.rb @@ -114,6 +114,7 @@ def self.resolve_project_match(id, type, attribute, user) return msg_macro_error(I18n.t("export.macro.model_not_found", model: type)) unless type == "value" project = Project.visible(user).find_by(id:) + project = Project.visible(user).find_by(identifier: id) if project.nil? if project.nil? return msg_macro_error(I18n.t("export.macro.resource_not_found", resource: "#{Project.name} #{id}")) end diff --git a/app/models/work_package/pdf_export/markdown_field.rb b/app/models/work_package/pdf_export/markdown_field.rb index b478d14278ab..f5d45baa1dd5 100644 --- a/app/models/work_package/pdf_export/markdown_field.rb +++ b/app/models/work_package/pdf_export/markdown_field.rb @@ -54,9 +54,9 @@ def apply_macros(markdown, work_package, formatter) document = Markly.parse(markdown) document.walk do |node| if node.type == :html - node.string_content = apply_macro_html(node.string_content, work_package, formatter) + node.string_content = apply_macro_html(node.string_content, work_package, formatter) || node.string_content elsif node.type == :text - node.string_content = apply_macro_text(node.string_content, work_package, formatter) + node.string_content = apply_macro_text(node.string_content, work_package, formatter) || node.string_content end end document.to_markdown @@ -72,6 +72,8 @@ def apply_macro_text(text, work_package, formatter) end def apply_macro_html(html, work_package, formatter) + return html unless formatter.applicable?(html) + doc = Nokogiri::HTML.fragment(html) apply_macro_html_node(doc, work_package, formatter) doc.to_html diff --git a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb index 9a703b1fb544..6078c2c63782 100644 --- a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb +++ b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb @@ -72,6 +72,7 @@ name: "Forbidden project", types: [type], id: 666, + identifier: "forbidden-project", public: false, status_code: "on_track", active: true, @@ -370,10 +371,15 @@ def get_column_value(column_name) projectLabel:status ``` + Project by identifier: + projectValue:"#{project.identifier}":active + Project not found: projectValue:1234567890:active Access denied: projectValue:#{forbidden_project.id}:active + Access denied by identifier: + projectValue:"#{forbidden_project.identifier}":active DESCRIPTION end it "contains resolved attributes and labels" do @@ -397,14 +403,16 @@ def get_column_value(column_name) "1", export_time_formatted, project.name, + "Project by identifier:", " ", I18n.t(:general_text_Yes), "Project not found: ", "[#{I18n.t('export.macro.error', message: I18n.t('export.macro.resource_not_found', resource: "Project 1234567890"))}] ", "Access denied: ", "[#{I18n.t('export.macro.error', message: - I18n.t('export.macro.resource_not_found', resource: "Project #{forbidden_project.id}"))}]", + I18n.t('export.macro.resource_not_found', resource: "Project #{forbidden_project.id}"))}] ", + "Access denied by identifier:", " ", "[Macro error, resource not found: Project", "forbidden-project]", - "2", export_time_formatted, project.name, + "2", export_time_formatted, project.name, ].flatten expect(result.join(" ")).to eq(expected_result.join(" ")) end From 33afc826224f336b3fe4a97f462e410957397b21 Mon Sep 17 00:00:00 2001 From: as-op <a.sandorf@openproject.com> Date: Wed, 5 Jun 2024 17:51:35 +0200 Subject: [PATCH 19/19] list more examples in comment --- .../work_package/exports/macros/attributes.rb | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb index 4885c9ad355d..8b50adfa503e 100644 --- a/app/models/work_package/exports/macros/attributes.rb +++ b/app/models/work_package/exports/macros/attributes.rb @@ -29,11 +29,21 @@ module WorkPackage::Exports module Macros # OpenProject attribute macros syntax # Examples: - # workPackageLabel:1234:subject # Outputs work package label attribute "Subject" + help text - # workPackageValue:1234:subject # Outputs the actual subject of #1234 + # workPackageLabel:subject # Outputs work package label attribute "Subject" + # workPackageLabel:1234:subject # Outputs work package label attribute "Subject" + + # workPackageValue:subject # Outputs the subject of the current work package + # workPackageValue:1234:subject # Outputs the subject of #1234 + # workPackageValue:"custom field name" # Outputs the custom field value of the current work package + # workPackageValue:1234:"custom field name" # Outputs the custom field value of #1234 # - # projectLabel:statusExplanation # Outputs current project label attribute "Status description" + help text - # projectValue:statusExplanation # Outputs current project value for "Status description" + # projectLabel:active # Outputs current project label attribute "active" + # projectLabel:1234:active # Outputs project label attribute "active" + # projectLabel:my-project-identifier:active # Outputs project label attribute "active" + + # projectValue:active # Outputs current project value for "active" + # projectValue:1234:active # Outputs project with id 1234 value for "active" + # projectValue:my-project-identifier:active # Outputs project with identifier my-project-identifier value for "active" class Attributes < OpenProject::TextFormatting::Matchers::RegexMatcher DISABLED_PROJECT_RICH_TEXT_FIELDS = %i[description status_explanation status_description].freeze DISABLED_WORK_PACKAGE_RICH_TEXT_FIELDS = %i[description].freeze