Files
iTop/datamodels/2.x/itop-portal-base/portal/src/Helper/BrowseBrickHelper.php
Molkobain 58790bc352 N°8597 - Fix special characters being escaped for BrowseBrick items in "Tree" mode (#845)
* N°8597 - Fix special characters being escaped for BrowseBrick items in "Tree" mode

* N°8597 - Fix forgotten use case for "Tree" mode for intermediate levels
2026-03-17 15:02:20 +01:00

571 lines
20 KiB
PHP

<?php
/**
* Copyright (C) 2013-2024 Combodo SAS
*
* This file is part of iTop.
*
* iTop is free software; you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* iTop 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
*/
namespace Combodo\iTop\Portal\Helper;
use AttributeImage;
use AttributeSet;
use AttributeTagSet;
use Combodo\iTop\Portal\Brick\BrowseBrick;
use DBObject;
use DBSearch;
use Dict;
use MetaModel;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use UserRights;
use utils;
/**
* Class BrowseBrickHelper
*
* @package Combodo\iTop\Portal\Helper
* @since 2.7.0
* @author Guillaume Lajarige <guillaume.lajarige@combodo.com>
*/
class BrowseBrickHelper
{
/** @var string LEVEL_SEPARATOR */
public const LEVEL_SEPARATOR = '-';
/** @var array OPTIONAL_ATTRIBUTES */
public const OPTIONAL_ATTRIBUTES = ['tooltip_att', 'description_att', 'image_att'];
/** @var \Combodo\iTop\Portal\Helper\SecurityHelper */
private $oSecurityHelper;
/** @var \Combodo\iTop\Portal\Helper\ScopeValidatorHelper */
private $oScopeValidator;
/** @var \Combodo\iTop\Portal\Routing\UrlGenerator */
private $oUrlGenerator;
/**
* BrowseBrickHelper constructor.
*
* @param \Combodo\iTop\Portal\Helper\SecurityHelper $oSecurityHelper
* @param \Combodo\iTop\Portal\Helper\ScopeValidatorHelper $oScopeValidator
* @param \Symfony\Component\Routing\Generator\UrlGeneratorInterface $oUrlGenerator
*/
public function __construct(
SecurityHelper $oSecurityHelper,
ScopeValidatorHelper $oScopeValidator,
UrlGeneratorInterface $oUrlGenerator
) {
$this->oSecurityHelper = $oSecurityHelper;
$this->oScopeValidator = $oScopeValidator;
$this->oUrlGenerator = $oUrlGenerator;
}
/**
* Flattens the $aLevels into $aLevelsProperties in order to be able to build an OQL query from multiple single queries related to each
* others. As of now it only keeps search / parent_att / name_att properties.
*
* Note : This is not in the BrowseBrick class because the classes should not rely on DBObjectSearch.
*
* @param array $aLevels Levels from a BrowseBrick class
* @param array $aLevelsProperties Reference to an array that will contain the flattened levels
* @param string $sLevelAliasPrefix String that will be prefixed to the level ID as an unique path identifier
*
* @throws \CoreException
* @throws \DictExceptionMissingString
* @throws \MissingQueryArgument
* @throws \MySQLException
* @throws \MySQLHasGoneAwayException
* @throws \OQLException
* @throws \Exception
*/
public function TreeToFlatLevelsProperties(array $aLevels, array &$aLevelsProperties, $sLevelAliasPrefix = 'L')
{
foreach ($aLevels as $aLevel) {
$sCurrentLevelAlias = $sLevelAliasPrefix.static::LEVEL_SEPARATOR.$aLevel['id'];
$oSearch = DBSearch::CloneWithAlias(DBSearch::FromOQL($aLevel['oql']), $sCurrentLevelAlias);
// Restricting to the allowed scope
$oScopeSearch = $this->oScopeValidator->GetScopeFilterForProfiles(
UserRights::ListProfiles(),
$oSearch->GetClass(),
UR_ACTION_READ
);
$oSearch = ($oScopeSearch !== null) ? $oSearch->Intersect($oScopeSearch) : null;
// - Allowing all data if necessary
if ($oScopeSearch !== null && $oScopeSearch->IsAllDataAllowed()) {
$oSearch->AllowAllData();
}
if ($oSearch !== null) {
$aLevelsProperties[$sCurrentLevelAlias] = [
'alias' => $sCurrentLevelAlias,
'title' => ($aLevel['title'] !== null) ? Dict::S($aLevel['title']) : MetaModel::GetName($oSearch->GetClass()),
'parent_att' => $aLevel['parent_att'],
'name_att' => $aLevel['name_att'],
'tooltip_att' => $aLevel['tooltip_att'],
'description_att' => $aLevel['description_att'],
'image_att' => $aLevel['image_att'],
'search' => $oSearch,
'fields' => [],
'actions' => [],
];
// Adding current level's fields
if (isset($aLevel['fields'])) {
$aLevelsProperties[$sCurrentLevelAlias]['fields'] = [];
foreach ($aLevel['fields'] as $sFieldAttCode => $aFieldProperties) {
$aLevelsProperties[$sCurrentLevelAlias]['fields'][] = [
'code' => $sFieldAttCode,
'label' => MetaModel::GetAttributeDef($oSearch->GetClass(), $sFieldAttCode)->GetLabel(),
'hidden' => $aFieldProperties['hidden'],
];
}
}
// Flattening and adding sub levels
if (isset($aLevel['levels'])) {
foreach ($aLevel['levels'] as $aChildLevel) {
// Checking if the sub level if allowed
$oChildSearch = DBSearch::FromOQL($aChildLevel['oql']);
if ($this->oSecurityHelper->IsActionAllowed(UR_ACTION_READ, $oChildSearch->GetClass())) {
// Adding the sub level to this one
$aLevelsProperties[$sCurrentLevelAlias]['levels'][] = $sCurrentLevelAlias.static::LEVEL_SEPARATOR.$aChildLevel['id'];
// Adding drill down action if necessary
foreach ($aLevel['actions'] as $sId => $aAction) {
if ($aAction['type'] === BrowseBrick::ENUM_ACTION_DRILLDOWN) {
$aLevelsProperties[$sCurrentLevelAlias]['actions'][$sId] = $aAction;
break;
}
}
}
unset($oChildSearch);
}
$this->TreeToFlatLevelsProperties($aLevel['levels'], $aLevelsProperties, $sCurrentLevelAlias);
}
// Adding actions to the level
foreach ($aLevel['actions'] as $sId => $aAction) {
// ... Only if it's not already there (eg. the drilldown added with the sublevels)
if (!array_key_exists($sId, $aLevelsProperties[$sCurrentLevelAlias]['actions'])) {
// Adding action only if allowed
if (($aAction['type'] === BrowseBrick::ENUM_ACTION_VIEW) && !$this->oSecurityHelper->IsActionAllowed(
UR_ACTION_READ,
$oSearch->GetClass()
)) {
continue;
} elseif (($aAction['type'] === BrowseBrick::ENUM_ACTION_EDIT) && !$this->oSecurityHelper->IsActionAllowed(
UR_ACTION_MODIFY,
$oSearch->GetClass()
)) {
continue;
} elseif ($aAction['type'] === BrowseBrick::ENUM_ACTION_DRILLDOWN) {
continue;
}
// Setting action title
if (isset($aAction['title'])) {
// Note : There could be an enhancement here, by checking if the string code has the '%1' needle and use Dict::S or Dict::Format accordingly.
// But it would require to benchmark a potential performance drop as it will be done for all items
$aAction['title'] = Dict::S($aAction['title']);
} else {
switch ($aAction['type']) {
case BrowseBrick::ENUM_ACTION_CREATE_FROM_THIS:
// We can only make translate a dictionary entry with a class placeholder when the action has a class tag. if it has a factory method, we don't know yet what class is going to be created
if ($aAction['factory']['type'] === BrowseBrick::ENUM_FACTORY_TYPE_CLASS) {
$aAction['title'] = Dict::Format(
'Brick:Portal:Browse:Action:CreateObjectFromThis',
MetaModel::GetName($aAction['factory']['value'])
);
$aAction['url'] = $this->oUrlGenerator->generate(
'p_object_create',
['sObjectClass' => $aAction['factory']['value']]
);
} else {
$aAction['title'] = Dict::S('Brick:Portal:Browse:Action:Create');
}
break;
case BrowseBrick::ENUM_ACTION_VIEW:
$aAction['title'] = Dict::S('Brick:Portal:Browse:Action:View');
break;
case BrowseBrick::ENUM_ACTION_EDIT:
$aAction['title'] = Dict::S('Brick:Portal:Browse:Action:Edit');
break;
case BrowseBrick::ENUM_ACTION_DRILLDOWN:
$aAction['title'] = Dict::S('Brick:Portal:Browse:Action:Drilldown');
break;
}
}
// Setting action icon class
if (!isset($aAction['icon_class'])) {
switch ($aAction['type']) {
case BrowseBrick::ENUM_ACTION_CREATE_FROM_THIS:
$aAction['icon_class'] = BrowseBrick::ENUM_ACTION_ICON_CLASS_CREATE_FROM_THIS;
break;
case BrowseBrick::ENUM_ACTION_VIEW:
$aAction['icon_class'] = BrowseBrick::ENUM_ACTION_ICON_CLASS_VIEW;
break;
case BrowseBrick::ENUM_ACTION_EDIT:
$aAction['icon_class'] = BrowseBrick::ENUM_ACTION_ICON_CLASS_EDIT;
break;
case BrowseBrick::ENUM_ACTION_DRILLDOWN:
$aAction['icon_class'] = BrowseBrick::ENUM_ACTION_ICON_CLASS_DRILLDOWN;
break;
}
}
// Setting action url
switch ($aAction['type']) {
case BrowseBrick::ENUM_ACTION_CREATE_FROM_THIS:
if ($aAction['factory']['type'] === BrowseBrick::ENUM_FACTORY_TYPE_CLASS) {
$aAction['url'] = $this->oUrlGenerator->generate(
'p_object_create',
['sObjectClass' => $aAction['factory']['value']]
);
} else {
$aAction['url'] = $this->oUrlGenerator->generate('p_object_create_from_factory', [
'sEncodedMethodName' => base64_encode($aAction['factory']['value']),
'sObjectClass' => '-objectClass-',
'sObjectId' => '-objectId-',
]);
}
break;
}
$aLevelsProperties[$sCurrentLevelAlias]['actions'][$sId] = $aAction;
}
}
}
}
}
/**
* Prepares the action rules for an array of DBObject items.
*
* @param array $aItems
* @param string $sLevelsAlias
* @param array $aLevelsProperties
*
* @return array
*/
public function PrepareActionRulesForItems(array $aItems, $sLevelsAlias, array &$aLevelsProperties)
{
$aActionRules = [];
foreach ($aLevelsProperties[$sLevelsAlias]['actions'] as $sId => $aAction) {
$aActionRules[$sId] = ContextManipulatorHelper::PrepareAndEncodeRulesToken($aAction['rules'], $aItems);
}
return $aActionRules;
}
/**
* Takes $aCurrentRow as a flat array and transform it in another flat array (not objects) with only the necessary information
*
* eg:
* - $aCurrentRow : array('L-1' => ObjectClass1, 'L-1-1' => ObjectClass2, 'L-1-1-1' => ObjectClass3)
* - $aRow will be : array(
* 'L1' => array(
* 'name' => 'Object class 1 name'
* ),
* 'L1-1' => array(
* 'name' => 'Object class 2 name',
* ),
* 'L1-1-1' => array(
* 'name' => 'Object class 3 name',
* ),
* ...
* )
*
* @param array $aCurrentRow
* @param array $aLevelsProperties
*
* @return array
*
* @throws \CoreException
* @throws \OQLException
* @throws \Exception
*/
public function AddToFlatItems(array $aCurrentRow, array &$aLevelsProperties)
{
$aRow = [];
/** @var \DBObject $value */
foreach ($aCurrentRow as $key => $value) {
// Retrieving objects from all levels
$aItems = array_values($aCurrentRow);
$sCurrentObjectClass = get_class($value);
$sCurrentObjectId = $value->GetKey();
$sNameAttCode = $aLevelsProperties[$key]['name_att'];
$sNameAttDef = MetaModel::GetAttributeDef($sCurrentObjectClass, $sNameAttCode);
$sNameAttDefClass = get_class($sNameAttDef);
$aRow[$key] = [
'level_alias' => $key,
'id' => $sCurrentObjectId,
'name' => $value->Get($sNameAttCode),
'class' => $sCurrentObjectClass,
'action_rules_token' => $this->PrepareActionRulesForItems($aItems, $key, $aLevelsProperties),
'metadata' => [
'object_class' => $sCurrentObjectClass,
'object_id' => $sCurrentObjectId,
'attribute_code' => $sNameAttCode,
'attribute_type' => $sNameAttDefClass,
'value_raw' => $value->Get($sNameAttCode),
],
];
// Adding optional attributes if necessary
foreach (static::OPTIONAL_ATTRIBUTES as $sOptionalAttribute) {
if ($aLevelsProperties[$key][$sOptionalAttribute] !== null) {
$sPropertyName = substr($sOptionalAttribute, 0, -4);
$oAttDef = MetaModel::GetAttributeDef($sCurrentObjectClass, $aLevelsProperties[$key][$sOptionalAttribute]);
if ($oAttDef instanceof AttributeImage) {
$tmpAttValue = $value->Get($aLevelsProperties[$key][$sOptionalAttribute]);
if ($sOptionalAttribute === 'image_att') {
if (is_object($tmpAttValue) && !$tmpAttValue->IsEmpty()) {
$oOrmDoc = $tmpAttValue;
$tmpAttValue = $this->oUrlGenerator->generate('p_object_document_display', [
'sObjectClass' => $sCurrentObjectClass,
'sObjectId' => $sCurrentObjectId,
'sObjectField' => $aLevelsProperties[$key][$sOptionalAttribute],
'cache' => 86400,
's' => $oOrmDoc->GetSignature(),
]);
} else {
$tmpAttValue = $oAttDef->Get('default_image');
}
}
} else {
$tmpAttValue = $value->GetAsHTML($aLevelsProperties[$key][$sOptionalAttribute]);
}
$aRow[$key][$sPropertyName] = $tmpAttValue;
}
}
// Adding fields attributes if necessary
if (!empty($aLevelsProperties[$key]['fields'])) {
$aRow[$key]['fields'] = [];
foreach ($aLevelsProperties[$key]['fields'] as $aField) {
$oAttDef = MetaModel::GetAttributeDef($sCurrentObjectClass, $aField['code']);
$sAttDefClass = get_class($oAttDef);
switch (true) {
case $oAttDef instanceof AttributeTagSet:
/** @var \ormTagSet $oSetValues */
$oSetValues = $value->Get($aField['code']);
$aCodes = $oSetValues->GetTags();
/** @var \AttributeTagSet $oAttDef */
$sHtmlForFieldValue = $oAttDef->GenerateViewHtmlForValues($aCodes, '', false);
break;
case $oAttDef instanceof AttributeSet:
$oAttDef->SetDisplayLink(false);
$sHtmlForFieldValue = $value->Get($aField['code']);
break;
case $oAttDef instanceof AttributeImage:
// Todo: This should be refactored, it has been seen multiple times in the portal
$oOrmDoc = $value->Get($aField['code']);
if (is_object($oOrmDoc) && !$oOrmDoc->IsEmpty()) {
$sUrl = $this->oUrlGenerator->generate('p_object_document_display', [
'sObjectClass' => $sCurrentObjectClass,
'sObjectId' => $sCurrentObjectId,
'sObjectField' => $aField['code'],
'cache' => 86400,
's' => $oOrmDoc->GetSignature(),
]);
} else {
$sUrl = $oAttDef->Get('default_image');
}
$sHtmlForFieldValue = '<img src="'.$sUrl.'" />';
break;
default:
$sHtmlForFieldValue = $oAttDef->GetAsHTML($value->Get($aField['code']), $value);
break;
}
// For simple fields, we get the raw (stored) value as well
$bExcludeRawValue = false;
foreach (ApplicationHelper::GetAttDefClassesToExcludeFromMarkupMetadataRawValue() as $sAttDefClassToExclude) {
if (is_a($sAttDefClass, $sAttDefClassToExclude, true)) {
$bExcludeRawValue = true;
break;
}
}
$attValueRaw = ($bExcludeRawValue === false) ? $value->Get($aField['code']) : null;
$aRow[$key]['fields'][$aField['code']] = [
'object_class' => $sCurrentObjectClass,
'object_id' => $sCurrentObjectId,
'attribute_code' => $aField['code'],
'attribute_type' => $sAttDefClass,
'value_raw' => $attValueRaw,
'value_html' => $sHtmlForFieldValue,
];
}
}
}
return $aRow;
}
/**
* Takes $aCurrentRow as a flat array to recursively convert and insert it into a tree array $aItems.
* This is used to build a tree array from a DBObjectSet retrieved with FetchAssoc().
*
* eg:
* - $aCurrentRow : array('L-1' => ObjectClass1, 'L-1-1' => ObjectClass2, 'L-1-1-1' => ObjectClass3)
* - $aItems will be : array(
* 'L1' =>
* 'name' => 'Object class 1 name',
* 'subitems' => array(
* 'L1-1' => array(
* 'name' => 'Object class 2 name',
* 'subitems' => array(
* 'L1-1-1' => array(
* 'name' => 'Object class 3 name',
* 'subitems' => array()
* ),
* ...
* )
* ),
* ...
* )
* ),
* ...
* )
*
* @param array & $aItems Reference to the array to be built
* @param array $aCurrentRow
* @param array $aLevelsProperties
* @param array|null $aCurrentRowObjects
*
* @throws \Exception
*/
public function AddToTreeItems(array &$aItems, array $aCurrentRow, array &$aLevelsProperties, $aCurrentRowObjects = null)
{
$aCurrentRowKeys = array_keys($aCurrentRow);
$aCurrentRowValues = array_values($aCurrentRow);
/** @var \DBObject[] $aCurrentRowValues */
$sCurrentIndex = $aCurrentRowKeys[0].'::'.$aCurrentRowValues[0]->GetKey();
// We make sure to keep all row objects through levels by copying them when processing the first level.
// Otherwise they will be sliced through levels, one by one.
if ($aCurrentRowObjects === null) {
$aCurrentRowObjects = $aCurrentRowValues;
}
if (!isset($aItems[$sCurrentIndex])) {
$aItems[$sCurrentIndex] = [
'level_alias' => $aCurrentRowKeys[0],
'id' => $aCurrentRowValues[0]->GetKey(),
'name' => $aCurrentRowValues[0]->Get($aLevelsProperties[$aCurrentRowKeys[0]]['name_att']),
'class' => get_class($aCurrentRowValues[0]),
'subitems' => [],
'filter_data' => $this->GetFilterData($aLevelsProperties[$aCurrentRowKeys[0]], $aCurrentRowKeys[0], $aCurrentRowValues[0]),
'action_rules_token' => $this->PrepareActionRulesForItems($aCurrentRowObjects, $aCurrentRowKeys[0], $aLevelsProperties),
];
// Adding optional attributes if necessary
foreach (static::OPTIONAL_ATTRIBUTES as $sOptionalAttribute) {
if ($aLevelsProperties[$aCurrentRowKeys[0]][$sOptionalAttribute] !== null) {
$sPropertyName = substr($sOptionalAttribute, 0, -4);
$oAttDef = MetaModel::GetAttributeDef(
get_class($aCurrentRowValues[0]),
$aLevelsProperties[$aCurrentRowKeys[0]][$sOptionalAttribute]
);
if ($oAttDef instanceof AttributeImage) {
$tmpAttValue = $aCurrentRowValues[0]->Get($aLevelsProperties[$aCurrentRowKeys[0]][$sOptionalAttribute]);
if ($sOptionalAttribute === 'image_att') {
if (is_object($tmpAttValue) && !$tmpAttValue->IsEmpty()) {
$oOrmDoc = $tmpAttValue;
$tmpAttValue = $this->oUrlGenerator->generate('p_object_document_display', [
'sObjectClass' => get_class($aCurrentRowValues[0]),
'sObjectId' => $aCurrentRowValues[0]->GetKey(),
'sObjectField' => $aLevelsProperties[$aCurrentRowKeys[0]][$sOptionalAttribute],
'cache' => 86400,
's' => $oOrmDoc->GetSignature(),
]);
} else {
$tmpAttValue = $oAttDef->Get('default_image');
}
}
} else {
$tmpAttValue = $aCurrentRowValues[0]->GetAsHTML($aLevelsProperties[$aCurrentRowKeys[0]][$sOptionalAttribute]);
}
$aItems[$sCurrentIndex][$sPropertyName] = $tmpAttValue;
}
}
}
$aCurrentRowSliced = array_slice($aCurrentRow, 1);
if (!empty($aCurrentRowSliced)) {
$this->AddToTreeItems($aItems[$sCurrentIndex]['subitems'], $aCurrentRowSliced, $aLevelsProperties, $aCurrentRowObjects);
}
}
/**
* Get data to allow filtering tree with invisible fields.
*
* @param array $aLevelProperties tree level properties
* @param string $sRowKey row key
* @param \DBObject $oRowValue row value
*
* @return string[]
* @throws \ArchivedObjectException
* @throws \CoreException
* @throws \Exception
*/
private function GetFilterData(array $aLevelProperties, string $sRowKey, DBObject $oRowValue): array
{
// result
$sValues = "";
$sValuesAndCodes = "";
// iterate throw level properties fields...
foreach ($aLevelProperties['fields'] as $aField) {
// retrieve the object class
$sFieldObjectClass = get_class($oRowValue);
// retrieve the field object attribute definition
$oAttDef = MetaModel::GetAttributeDef($sFieldObjectClass, $aField['code']);
// get field value (HTML representation)
$sValue = $oAttDef->GetAsHTML($oRowValue->Get($aField['code']));
// do not print empty fields
if (!utils::IsNullOrEmptyString($sValue)) {
// append to result
$sValues .= $sValue;
$sValuesAndCodes .= '<span><span class="tree-item-filter-data-label">'.$aField['label'].':</span> '.$sValue.'</span>';
}
}
return [
'values' => $sValues,
'values_and_codes' => $sValuesAndCodes,
];
}
}