diff --git a/app/models/work_package/exports/attributes.rb b/app/models/work_package/exports/attributes.rb new file mode 100644 index 00000000000..dd1791f5b01 --- /dev/null +++ b/app/models/work_package/exports/attributes.rb @@ -0,0 +1,45 @@ +# 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. +#++ + +module WorkPackage::Exports + module Attributes + def allowed_to_view_project_phases?(project) + User.current.allowed_in_project?(:view_project_phases, project) && project.phases.active.any? + end + + def allowed_to_view_attribute?(obj, attribute_name) + if attribute_name.to_sym == :project_phase && obj.is_a?(WorkPackage) + allowed_to_view_project_phases?(obj.project) + else + true + end + end + end +end diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb index 71f7947b3d4..37bbc2fecda 100644 --- a/app/models/work_package/exports/macros/attributes.rb +++ b/app/models/work_package/exports/macros/attributes.rb @@ -45,6 +45,7 @@ module WorkPackage::Exports # 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 + extend WorkPackage::Exports::Attributes DISABLED_PROJECT_RICH_TEXT_FIELDS = %i[description status_explanation status_description].freeze DISABLED_WORK_PACKAGE_RICH_TEXT_FIELDS = %i[description].freeze @@ -101,11 +102,22 @@ module WorkPackage::Exports end def self.resolve_label(model, attribute) - model.human_attribute_name( - ::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: model.new) - ) + model.human_attribute_name(to_ar_name(attribute, model.new)) end + def self.to_ar_name(attribute, context) + ::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context:) + end + + ## + # Resolves a work package or project match based on the type and id. + # Returns the formatted value or an error message if not found. + # + # @param id [String] The ID of the work package or project. + # @param type [String] The type of the match (label or value). + # @param attribute [String] The attribute to resolve. + # @param user [User] The user context for visibility checks. + 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" @@ -145,18 +157,30 @@ module WorkPackage::Exports end def self.resolve_value(obj, attribute, disabled_rich_text_fields) - cf = obj.available_custom_fields.find { |pcf| pcf.name == attribute } + custom_field = find_custom_field(obj, attribute) + return msg_macro_error_rich_text if custom_field&.formattable? - return msg_macro_error_rich_text if cf&.formattable? + attribute_name = convert_to_attribute_name(custom_field, attribute, obj) + return " " unless can_view_attribute?(custom_field, obj, attribute_name) + return msg_macro_error_rich_text if disabled_rich_text_fields.include?(attribute_name.to_sym) - 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(attribute_name, obj.class, obj) + end - format_attribute_value(ar_name, obj.class, obj) + def self.can_view_attribute?(custom_field, obj, attribute_name) + custom_field || allowed_to_view_attribute?(obj, attribute_name) + end + + def self.convert_to_attribute_name(custom_field, attribute, obj) + if custom_field.nil? + to_ar_name(attribute, obj) + else + "cf_#{custom_field.id}" + end + end + + def self.find_custom_field(obj, attribute) + obj.available_custom_fields.find { |pcf| pcf.name == attribute } end def self.format_attribute_value(ar_name, model, obj) diff --git a/app/models/work_package/pdf_export/export/wp/attributes.rb b/app/models/work_package/pdf_export/export/wp/attributes.rb index bb41a95233f..3a3240570f5 100644 --- a/app/models/work_package/pdf_export/export/wp/attributes.rb +++ b/app/models/work_package/pdf_export/export/wp/attributes.rb @@ -29,6 +29,8 @@ #++ module WorkPackage::PDFExport::Export::Wp::Attributes + include WorkPackage::Exports::Attributes + def write_attributes!(work_package) work_package .type.attribute_groups @@ -138,6 +140,8 @@ module WorkPackage::PDFExport::Export::Wp::Attributes current_part = { type: :attribute, list: [] } parts = [current_part] group.attributes.each do |form_key| + next unless show_attribute?(form_key, work_package) + if allowed_long_text_custom_field?(form_key, work_package) cf = form_key_to_custom_field(form_key) if current_part[:type] == :long_text @@ -156,6 +160,10 @@ module WorkPackage::PDFExport::Export::Wp::Attributes parts end + def show_attribute?(form_key, work_package) + CustomField.custom_field_attribute?(form_key) || allowed_to_view_attribute?(work_package, form_key) + end + def allowed_long_text_custom_field?(form_key, work_package) return false unless CustomField.custom_field_attribute? form_key diff --git a/spec/models/work_package/pdf_export/common/macro_spec.rb b/spec/models/work_package/pdf_export/common/macro_spec.rb index 9d32edcad91..45b3a7c1936 100644 --- a/spec/models/work_package/pdf_export/common/macro_spec.rb +++ b/spec/models/work_package/pdf_export/common/macro_spec.rb @@ -31,14 +31,111 @@ require "spec_helper" RSpec.describe WorkPackage::PDFExport::Common::Macro do - let(:work_package) do - create(:work_package, id: 185, subject: "Work package 1") + shared_let(:type_task) { create(:type_task) } + shared_let(:custom_field) do + create( + :work_package_custom_field, + name: "Custom Field 1", + field_format: "string", + types: [type_task] + ) + end + shared_let(:formatted_custom_field) do + create( + :work_package_custom_field, + name: "Custom Formatted Field", + field_format: "text", + is_for_all: true, + types: [type_task] + ) + end + shared_let(:project_custom_field_section) { create(:project_custom_field_section) } + shared_let(:project_custom_field) do + create(:string_project_custom_field, name: "Project Custom Field 1", project_custom_field_section:) + end + shared_let(:project) do + create( + :project, + status_code: "on_track", + work_package_custom_fields: [custom_field, formatted_custom_field], + project_custom_fields: [project_custom_field], + custom_field_values: { project_custom_field.id => "Project custom value 1" } + ) + end + shared_let(:other_project) do + create( + :project, + name: "Other Project", + work_package_custom_fields: [custom_field, formatted_custom_field], + project_custom_fields: [project_custom_field], + custom_field_values: { project_custom_field.id => "Project custom value 2" } + ) + end + shared_let(:work_package) do + create( + :work_package, + subject: "Work package 1", + type: type_task, + status: create(:status, name: "In Progress"), + project: project, + custom_field_values: { + custom_field.id => "Custom value 1", + formatted_custom_field.id => "**Formatted** _text_ content" + } + ) + end + shared_let(:other_work_package) do + create( + :work_package, + subject: "Work package 2", + project: other_project, + type: type_task, + custom_field_values: { + custom_field.id => "Custom value 2" + } + ) + end + shared_let(:restricted_other_project) do + create( + :project, + name: "Other Project", + work_package_custom_fields: [custom_field, formatted_custom_field], + project_custom_fields: [project_custom_field], + custom_field_values: { project_custom_field.id => "Project custom value 3" } + ) + end + shared_let(:restricted_work_package) do + create( + :work_package, + subject: "Work package 3", + project: restricted_other_project, + type: type_task, + custom_field_values: { + custom_field.id => "Custom value 3" + } + ) + end + shared_let(:formatter) { Class.new { extend WorkPackage::PDFExport::Common::Macro } } + let(:additional_permissions) { [] } + let(:user) do + create( + :user, + member_with_permissions: { + project => %i[view_work_packages view_project_attributes view_project] + additional_permissions, + other_project => %i[view_work_packages view_project_attributes view_project] + additional_permissions + } + ) end let(:markdown) { "" } - let(:formatter) { Class.new { extend WorkPackage::PDFExport::Common::Macro } } + + before do + User.current = user + end subject(:formatted) do - formatter.apply_markdown_field_macros(markdown, { work_package: work_package, user: User.current }) + formatter + .apply_markdown_field_macros(markdown, { work_package: work_package, project: project, user: }) + .sub("\n", "") end describe "empty text" do @@ -47,48 +144,542 @@ RSpec.describe WorkPackage::PDFExport::Common::Macro do end end - describe "wp mention tag" do - let(:markdown) { '#185' } + describe "wp mention macro" do + let(:expected_tag) do + "##{ + work_package.id + }" + end - it "ignores the tag" do - expect(formatted).to eq("\\#185\n") + describe "with tag" do + let(:markdown) { expected_tag } + + it "loops the tag through" do + # note: escaped backslash in the tag text for correct markdown rendering + expect(formatted).to eq( + "\\##{work_package.id}" + ) + end + end + + describe "with plain" do + let(:markdown) { "##{work_package.id}" } + + it "contains correct data" do + expect(formatted).to eq(expected_tag) + end + end + + describe "with markdown formating bold" do + let(:markdown) { "**##{work_package.id}**" } + + it "contains correct data" do + expect(formatted).to eq("**#{expected_tag}**") + end + end + + describe "with markdown formating strikethrough" do + let(:markdown) { "~~##{work_package.id}~~" } + + it "contains correct data" do + expect(formatted).to eq("~~#{expected_tag}~~") + end + end + + describe "with strikethrough in table" do + let(:markdown) { "

##{work_package.id}

" } + + it "contains correct data" do + expect(formatted).to eq("

#{expected_tag}

") + end end end - describe "wp mention plain" do - let(:markdown) { "#185" } + describe "workPackageValue macro" do + describe "with current work package attribute" do + let(:markdown) { "workPackageValue:subject" } - it "contains correct data" do - expect(formatted).to eq("#185\n") + it "outputs the attribute value" do + expect(formatted).to eq("Work package 1") + end + end + + describe "with specific work package ID and attribute" do + let(:markdown) { "workPackageValue:#{work_package.id}:subject" } + + it "outputs the attribute value for the specified work package" do + expect(formatted).to eq("Work package 1") + end + end + + describe "withh another work package ID and attribute" do + let(:markdown) { "workPackageValue:#{other_work_package.id}:subject" } + + it "outputs the attribute value for the specified work package" do + expect(formatted).to eq("Work package 2") + end + end + + describe "with restricted work package ID and attribute" do + let(:markdown) { "workPackageValue:#{restricted_work_package.id}:subject" } + + it "outputs an error message" do + expect(formatted).to include("Macro error, resource not found") + end + end + + describe "with restricted work package ID and custom field" do + let(:markdown) { "workPackageValue:#{restricted_work_package.id}:\"Custom Field 1\"" } + + it "outputs an error message" do + expect(formatted).to include("Macro error, resource not found") + end + end + + describe "with restricted work package ID and formatted custom field" do + let(:markdown) { "workPackageValue:#{restricted_work_package.id}:\"Custom Formatted Field\"" } + + it "outputs an error message" do + expect(formatted).to include("Macro error, resource not found") + end + end + + describe "with non-existent work package ID" do + let(:markdown) { "workPackageValue:999:subject" } + + it "outputs an error message" do + expect(formatted).to include("Macro error, resource not found") + end + end + + describe "with status attribute" do + let(:markdown) { "workPackageValue:status" } + + it "outputs the status name" do + expect(formatted).to eq("In Progress") + end + end + + describe "with project_phase attribute" do + let(:project_phase_active) { true } + let!(:project_phase) do + create(:project_phase, project: project, active: project_phase_active) + end + let(:markdown) { "workPackageValue:project_phase" } + + before do + work_package.update!(project_phase_definition_id: project_phase.definition.id) + end + + describe "without the permission" do + it "outputs a single space" do + expect(formatted).to eq(" ") + end + end + + describe "with the permission" do + let(:additional_permissions) { [:view_project_phases] } + + describe "with active phase" do + it "outputs the project phase name" do + expect(formatted).to eq(project_phase.name) + end + end + + describe "without active phase" do + let(:project_phase_active) { false } + + it "outputs the project phase name" do + expect(formatted).to eq(" ") + end + end + end + end + + describe "with custom field by name" do + let(:markdown) { 'workPackageValue:"Custom Field 1"' } + + it "outputs the custom field value" do + expect(formatted).to eq("Custom value 1") + end + end + + describe "with specific work package ID and custom field" do + let(:markdown) { "workPackageValue:#{work_package.id}:\"Custom Field 1\"" } + + it "outputs the custom field value for the specified work package" do + expect(formatted).to eq("Custom value 1") + end + end + + describe "with another work package ID and custom field" do + let(:markdown) { "workPackageValue:#{other_work_package.id}:\"Custom Field 1\"" } + + it "outputs the custom field value for the specified work package" do + expect(formatted).to eq("Custom value 2") + end + end + + describe "with formatted custom field" do + let(:markdown) { 'workPackageValue:"Custom Formatted Field"' } + + it "outputs an error message for rich text" do + expect(formatted).to include(I18n.t("export.macro.rich_text_unsupported")) + end + end + + describe "with specific work package ID and formatted custom field" do + let(:markdown) { "workPackageValue:#{work_package.id}:\"Custom Formatted Field\"" } + + it "outputs an error message for rich text" do + expect(formatted).to include(I18n.t("export.macro.rich_text_unsupported")) + end + end + + describe "with non-existent attribute" do + let(:markdown) { "workPackageValue:nonexistent_attribute" } + + it "outputs an empty value" do + expect(formatted).to eq(" ") + end + end + + describe "with another work package id and a non-existent attribute" do + let(:markdown) { "workPackageValue:#{other_work_package.id}:nonexistent_attribute" } + + it "outputs an empty value" do + expect(formatted).to eq(" ") + end + end + + describe "with markdown formatting" do + let(:markdown) { "**workPackageValue:subject**" } + + it "preserves the markdown formatting" do + expect(formatted).to eq("**Work package 1**") + end + end + + describe "in a table" do + let(:markdown) { "
workPackageValue:subject
" } + + it "processes the macro inside HTML" do + expect(formatted).to eq("
Work package 1
") + end end end - describe "wp mention with markdown formating bold" do - let(:markdown) { "\n**#185**\n" } + describe "workPackageLabel macro" do + let!(:original_setting) { ActiveModel::Translation.raise_on_missing_translations } - it "contains correct data" do - expect(formatted).to eq("**#185**\n") + before do + ActiveModel::Translation.raise_on_missing_translations = false + end + + after do + ActiveModel::Translation.raise_on_missing_translations = original_setting + end + + describe "with current work package attribute" do + let(:markdown) { "workPackageLabel:subject" } + + it "outputs the attribute label" do + expect(formatted).to eq("Subject") + end + end + + describe "with specific work package ID and attribute" do + let(:markdown) { "workPackageLabel:#{work_package.id}:subject" } + + it "outputs the attribute label for the specified work package" do + expect(formatted).to eq("Subject") + end + end + + describe "with non-existent work package ID" do + let(:markdown) { "workPackageLabel:999:subject" } + + it "outputs a humanized form" do + expect(formatted).to eq("Subject") + end + end + + describe "with status attribute" do + let(:markdown) { "workPackageLabel:status" } + + it "outputs the status label" do + expect(formatted).to eq("Status") + end + end + + describe "with custom field by name" do + let(:markdown) { 'workPackageLabel:"Custom Field 1"' } + + it "outputs the custom field name" do + expect(formatted).to eq("Custom field 1") + end + end + + describe "with specific work package ID and custom field" do + let(:markdown) { "workPackageLabel:#{work_package.id}:\"Custom Field 1\"" } + + it "outputs the custom field name for the specified work package" do + expect(formatted).to eq("Custom field 1") + end + end + + describe "with non-existent attribute" do + let(:markdown) { "workPackageLabel:nonexistent_attribute" } + + it "outputs the humanized attribute name" do + expect(formatted).to eq("Nonexistent attribute") + end + end + + describe "with markdown formatting" do + let(:markdown) { "**workPackageLabel:subject**" } + + it "preserves the markdown formatting" do + expect(formatted).to eq("**Subject**") + end + end + + describe "in a table" do + let(:markdown) { "
workPackageLabel:subject
" } + + it "processes the macro inside HTML" do + expect(formatted).to eq("
Subject
") + end end end - describe "wp mention with markdown formating strikethrough" do - let(:markdown) { "~~#185~~" } + describe "projectValue macro" do + describe "with current project attribute" do + let(:markdown) { "projectValue:name" } - it "contains correct data" do - expect(formatted).to eq("~~#185~~\n") + it "outputs the attribute value" do + expect(formatted).to eq(project.name) + end + end + + describe "with specific project ID and attribute" do + let(:markdown) { "projectValue:#{project.id}:name" } + + it "outputs the attribute value for the specified project" do + expect(formatted).to eq(project.name) + end + end + + describe "with other project ID and attribute" do + let(:markdown) { "projectValue:#{other_project.id}:name" } + + it "outputs the attribute value for the specified project" do + expect(formatted).to eq(other_project.name) + end + end + + describe "with specific project identifier and attribute" do + let(:markdown) { "projectValue:\"#{project.identifier}\":name" } + + it "outputs the attribute value for the specified project" do + expect(formatted).to eq(project.name) + end + end + + describe "with other project identifier and attribute" do + let(:markdown) { "projectValue:\"#{other_project.identifier}\":name" } + + it "outputs the attribute value for the specified project" do + expect(formatted).to eq(other_project.name) + end + end + + describe "with non-existent project ID" do + let(:markdown) { "projectValue:999:name" } + + it "outputs an error message" do + expect(formatted).to include("Macro error, resource not found") + end + end + + describe "with restricted project ID" do + let(:markdown) { "projectValue:#{restricted_other_project.id}:name" } + + it "outputs an error message" do + expect(formatted).to include("Macro error, resource not found") + end + end + + describe "with restricted project identifier" do + let(:markdown) { "projectValue:\"#{restricted_other_project.identifier}\":name" } + + it "outputs an error message" do + expect(formatted).to include("Macro error, resource not found") + end + end + + describe "with restricted project ID and custom field" do + let(:markdown) { "projectValue:#{restricted_other_project.id}:\"Project Custom Field 1\"" } + + it "outputs an error message" do + expect(formatted).to include("Macro error, resource not found") + end + end + + describe "with status attribute" do + let(:markdown) { "projectValue:status_code" } + + it "outputs the status code" do + expect(formatted).to eq(project.status_code) + end + end + + describe "with custom field by name" do + let(:markdown) { 'projectValue:"Project Custom Field 1"' } + + it "outputs the custom field value" do + expect(formatted).to eq("Project custom value 1") + end + end + + describe "with specific project ID and custom field" do + let(:markdown) { "projectValue:#{project.id}:\"Project Custom Field 1\"" } + + it "outputs the custom field value for the specified project" do + expect(formatted).to eq("Project custom value 1") + end + end + + describe "with other project ID and custom field" do + let(:markdown) { "projectValue:#{other_project.id}:\"Project Custom Field 1\"" } + + it "outputs the custom field value for the specified project" do + expect(formatted).to eq("Project custom value 2") + end + end + + describe "with non-existent attribute" do + let(:markdown) { "projectValue:nonexistent_attribute" } + + it "outputs an empty value" do + expect(formatted).to eq(" ") + end + end + + describe "with markdown formatting" do + let(:markdown) { "**projectValue:name**" } + + it "preserves the markdown formatting" do + expect(formatted).to eq("**#{project.name}**") + end + end + + describe "in a table" do + let(:markdown) { "
projectValue:name
" } + + it "processes the macro inside HTML" do + expect(formatted).to eq("
#{project.name}
") + end end end - describe "wp mention with strikethrough in table" do - let(:markdown) { "

##185

" } + describe "projectLabel macro" do + let!(:original_setting) { ActiveModel::Translation.raise_on_missing_translations } - it "contains correct data" do - expect(formatted).to eq("

##185

\n") + before do + ActiveModel::Translation.raise_on_missing_translations = false + end + + after do + ActiveModel::Translation.raise_on_missing_translations = original_setting + end + + describe "with current project attribute" do + let(:markdown) { "projectLabel:name" } + + it "outputs the attribute label" do + expect(formatted).to eq("Name") + end + end + + describe "with specific project ID and attribute" do + let(:markdown) { "projectLabel:#{project.id}:name" } + + it "outputs the attribute label for the specified project" do + expect(formatted).to eq("Name") + end + end + + describe "with specific project identifier and attribute" do + let(:markdown) { "projectLabel:\"#{project.identifier}\":name" } + + it "outputs the attribute label for the specified project" do + expect(formatted).to eq("Name") + end + end + + describe "with non-existent project ID" do + let(:markdown) { "projectLabel:999:name" } + + it "outputs the attribute label" do + expect(formatted).to eq("Name") + end + end + + describe "with status attribute" do + let(:markdown) { "projectLabel:status_code" } + + it "outputs the status label" do + expect(formatted).to eq("Project status") + end + end + + describe "with custom field by name" do + let(:markdown) { 'projectLabel:"Project Custom Field 1"' } + + it "outputs the custom field name" do + expect(formatted).to eq("Project custom field 1") + end + end + + describe "with specific project ID and custom field" do + let(:markdown) { "projectLabel:#{project.id}:\"Project Custom Field 1\"" } + + it "outputs the custom field name for the specified project" do + expect(formatted).to eq("Project custom field 1") + end + end + + describe "with non-existent attribute" do + let(:markdown) { "projectLabel:nonexistent_attribute" } + + it "outputs the humanized attribute name" do + expect(formatted).to eq("Nonexistent attribute") + end + end + + describe "with markdown formatting" do + let(:markdown) { "**projectLabel:name**" } + + it "preserves the markdown formatting" do + expect(formatted).to eq("**Name**") + end + end + + describe "in a table" do + let(:markdown) { "
projectLabel:name
" } + + it "processes the macro inside HTML" do + expect(formatted).to eq("
Name
") + end 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 29f211f14b7..2d1b675a313 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 @@ -71,6 +71,10 @@ RSpec.describe WorkPackage::PDFExport::WorkPackageToPdf do # cf_disabled_in_project.id not included == disabled work_package_custom_field_ids: [cf_long_text.id, cf_empty_long_text.id, cf_global_bool.id, cf_link.id]) end + let(:phase_definition) { create(:project_phase_definition, name: "Test Phase") } + let!(:project_phase) do + create(:project_phase, project: project, definition: phase_definition, active: true) + end let(:forbidden_project) do create(:project, name: "Forbidden project", @@ -88,7 +92,9 @@ RSpec.describe WorkPackage::PDFExport::WorkPackageToPdf do end let(:user) do create(:user, - member_with_permissions: { project => %w[view_work_packages export_work_packages view_project_attributes] }) + member_with_permissions: { + project => %w[view_work_packages export_work_packages view_project_attributes view_project_phases] + }) end let(:another_user) do create(:user, firstname: "Secret User")