Merge pull request #19449 from opf/bug/65401-project-phase-field-shows-in-pdf-report-even-when-it-doesn-t-show-on-wp-i-e-when-project-phases-are-not-active

[#65401] Project phase field shows in pdf report even when it doesn't show on wp
This commit is contained in:
Andrej Sandorf
2025-07-10 15:32:53 +02:00
committed by GitHub
5 changed files with 716 additions and 42 deletions
@@ -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
@@ -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)
@@ -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
@@ -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) { '<mention class="mention" data-id="185" data-type="work_package" data-text="#185">#185</mention>' }
describe "wp mention macro" do
let(:expected_tag) do
"<mention class=\"mention\" data-id=\"#{
work_package.id
}\" data-type=\"work_package\" data-text=\"##{
work_package.id
}\">##{
work_package.id
}</mention>"
end
it "ignores the tag" do
expect(formatted).to eq("<mention class=\"mention\" data-id=\"185\" " +
"data-type=\"work_package\" data-text=\"#185\">\\#185</mention>\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(
"<mention class=\"mention\" data-id=\"#{
work_package.id
}\" data-type=\"work_package\" data-text=\"##{
work_package.id
}\">\\##{work_package.id}</mention>"
)
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) { "<table><tr><td><p><s>##{work_package.id}</s></p></td></tr></table>" }
it "contains correct data" do
expect(formatted).to eq("<table><tr><td><p><s>#{expected_tag}</s></p></td></tr></table>")
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("<mention class=\"mention\" data-id=\"185\" " +
"data-type=\"work_package\" data-text=\"#185\">#185</mention>\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) { "<table><tr><td>workPackageValue:subject</td></tr></table>" }
it "processes the macro inside HTML" do
expect(formatted).to eq("<table><tr><td>Work package 1</td></tr></table>")
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("**<mention class=\"mention\" data-id=\"185\" " +
"data-type=\"work_package\" data-text=\"#185\">#185</mention>**\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) { "<table><tr><td>workPackageLabel:subject</td></tr></table>" }
it "processes the macro inside HTML" do
expect(formatted).to eq("<table><tr><td>Subject</td></tr></table>")
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("~~<mention class=\"mention\" data-id=\"185\" " +
"data-type=\"work_package\" data-text=\"#185\">#185</mention>~~\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) { "<table><tr><td>projectValue:name</td></tr></table>" }
it "processes the macro inside HTML" do
expect(formatted).to eq("<table><tr><td>#{project.name}</td></tr></table>")
end
end
end
describe "wp mention with strikethrough in table" do
let(:markdown) { "<table><tr><td><p><s>##185</s></p></td></tr></table>" }
describe "projectLabel macro" do
let!(:original_setting) { ActiveModel::Translation.raise_on_missing_translations }
it "contains correct data" do
expect(formatted).to eq("<table><tr><td><p><s><mention class=\"mention\" data-id=\"185\" " +
"data-type=\"work_package\" data-text=\"##185\">##185</mention></s></p></td></tr></table>\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) { "<table><tr><td>projectLabel:name</td></tr></table>" }
it "processes the macro inside HTML" do
expect(formatted).to eq("<table><tr><td>Name</td></tr></table>")
end
end
end
end
@@ -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")