Compare commits

...

37 Commits

Author SHA1 Message Date
Timmy38
e4c3ab005d WIP 2026-08-03 17:38:33 +02:00
Timmy38
0c01be9d32 N°8908 Use it in ext mgmt too 2026-07-31 09:41:25 +02:00
Timmy38
899f87f163 N°8908 Warn user that an extension is part of package 2026-07-30 10:55:01 +02:00
Benjamin Dalsass
e0dc1fa571 N°8886 - Deprecation warning in DashetGroupBy in 3.3-dev (#972) 2026-07-27 08:26:37 +02:00
Stephen Abello
d6466c0bd6 Update setup.css 2026-07-24 14:30:17 +02:00
Stephen Abello
eb16e6cbc8 Merge branch 'support/3.2' into develop 2026-07-24 14:29:24 +02:00
Stephen Abello
42007ffd95 N°9702 - Fix user rights matrix display when an accessibility theme displayed a visual cue 2026-07-24 14:28:59 +02:00
Molkobain
6329c157e8 📝 Update releases history with 3.3.0-beta1 2026-07-24 11:31:07 +02:00
Benjamin Dalsass
9ee0548767 N°9807 - Polishing CKEditor for iTop 3.3.0-beta1 (#976) 2026-07-24 09:22:24 +02:00
Lenaick
620a6b9a51 N°9827 - Unable to launch check compatibility when a module with bad dependencies is in production-modules (#978)
* N°9827 - Unable to launch check compatibility when a module with bad dependencies is in production-modules

* Update unit tests

* N°9827 - Unable to launch check compatibility when a module with bad dependencies is in production-modules
2026-07-23 16:23:06 +02:00
Stephen Abello
968c56745a Merge branch 'support/3.2' into develop
# Conflicts:
#	core/attributedef.class.inc.php
2026-07-23 16:08:45 +02:00
Anne-Catherine
04a5e5463a N°9759 - Truncating AttributeText doesn't work as expected in case of multibytes characters (#964)
* N°9759 - Truncate AttributeText don't work as expected in case of multibytes characters

* WIP

* Move TrimValue() base definition to AttributeDefinition class, assign DBObject::SetTrim old behavior to it

* Fix variable types

* Fix variable types

* Apply code review fixes, add tests

* Define GetSize in AttributeDefinition to easy polymorphic calls instead of obscure AttributeDBFieldVoid

* Update php with right since version and ticket, remove useless method overload

* Apply suggestions from code review

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Update core/attributedef.class.inc.php

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Update core/attributedef.class.inc.php

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Apply suggestion from @Molkobain code review

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Apply suggestion from @Molkobain code review

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Apply suggestion from @Molkobain code review

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Apply suggestion from @Molkobain code review

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Apply suggestion from @Molkobain code review

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Correctly handle null values in new methods

---------

Co-authored-by: Stephen Abello <stephen.abello@combodo.com>
Co-authored-by: Molkobain <lajarige.guillaume@free.fr>
2026-07-23 16:07:42 +02:00
Benjamin Dalsass
006dd3d6c2 N°9829 - Add unit tests for parent ticket changes (#979) 2026-07-23 15:32:53 +02:00
lenaick.moreira
a808e89c30 Merge branch 'support/3.3-beta1' into develop 2026-07-23 11:43:00 +02:00
lenaick.moreira
c6e50a0ed7 N°9830 - Fix setup error when trying to remove class with a TagSet attribute 2026-07-23 11:39:42 +02:00
Molkobain
d88687527e Merge remote-tracking branch 'origin/support/3.2.3' into support/3.2 2026-07-23 10:49:09 +02:00
Molkobain
2cc071b07a Merge remote-tracking branch 'origin/support/3.3-beta1' into develop 2026-07-23 10:47:12 +02:00
Molkobain
76a410e00d Revert "N°9541 itop-virtualization-mgmt cannot currently be uninstalled" now that N°9831 has been fixed
This reverts commit 52fca750b0.
2026-07-23 10:45:44 +02:00
Molkobain
c68887c2bc N°9831 - Fix ext. management deletion plan analysis by avoiding overwriting entity for a previously processed class 2026-07-23 10:21:31 +02:00
Molkobain
837b433622 N°9831 - Fix DataCleanupService so it handles external keys the same way as \Combodo\iTop\DataFeatureRemoval\Service\StaticDeletionPlan::DeletionPlanForReferencingClasses and \DBObject::MakeDeletionPlan
- Check if ext. key is nullable first
- Then check if its a DEL_MANUAL option
2026-07-23 10:21:31 +02:00
Timmy38
52fca750b0 N°9541 itop-virtualization-mgmt cannot currently be uninstalled 2026-07-22 17:37:19 +02:00
Benjamin Dalsass
a5c14e4870 N°9723 - IsActionAllowed should work with non instantiated users objects (#973) 2026-07-21 17:41:40 +02:00
Timmy38
0e72b090ee Merge branch 'support/3.3-beta1' into develop 2026-07-21 17:06:30 +02:00
Timmy38
d1aedee245 N°9692 - Fix flag name 2026-07-21 17:04:37 +02:00
Timmy38
1b70b4bf70 N°9692 - Fix flag name 2026-07-21 12:02:31 +02:00
odain
41f51a2c0c Merge branch 'support/3.2' into develop 2026-07-21 08:03:21 +02:00
odain-cbd
73ede13443 N°9746 - Synchro execution time change fixes (#951)
* N°9746 - Synchro execution can make damage during time change (object deletions)

* N°9746 - handle timechange during synchro execution + cleanup m_bIsImportPhaseDateKnown

* N°9746 - PR fix

* N°9746 - add corner test case
2026-07-21 08:01:31 +02:00
odain-cbd
40dd72ef82 N°9746 - Synchro execution time change fixes (#951)
* N°9746 - Synchro execution can make damage during time change (object deletions)

* N°9746 - handle timechange during synchro execution + cleanup m_bIsImportPhaseDateKnown

* N°9746 - PR fix

* N°9746 - add corner test case
2026-07-21 07:59:50 +02:00
Lenaick
c8aea56171 N°9565 - Extension Mgmt : display progression during analysis (#975)
* N°9565 - Extension Mgmt : display progression during analysis

* N°9565 - Extension Mgmt : display progression during analysis

* N°9565 - Extension Mgmt : display progression during analysis

* N°9565 - Extension Mgmt : display progression during analysis

* N°9565 - Extension Mgmt : display progression during analysis

* N°9565 - Validate transaction id

* N°9565 - Move unset bForceCompilation
2026-07-20 11:38:30 +02:00
lenaick.moreira
041437cbeb Merge branch 'support/3.3-beta1' into develop 2026-07-17 17:05:46 +02:00
lenaick.moreira
85d50f31bd N°9692 - Fix null coalescing for extension installation status and clean up test cases 2026-07-17 17:05:06 +02:00
Anne-Catherine
5715e0484f N°9759 - Truncating AttributeText doesn't work as expected in case of multibytes characters (#964)
* N°9759 - Truncate AttributeText don't work as expected in case of multibytes characters

* WIP

* Move TrimValue() base definition to AttributeDefinition class, assign DBObject::SetTrim old behavior to it

* Fix variable types

* Fix variable types

* Apply code review fixes, add tests

* Define GetSize in AttributeDefinition to easy polymorphic calls instead of obscure AttributeDBFieldVoid

* Update php with right since version and ticket, remove useless method overload

* Apply suggestions from code review

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Update core/attributedef.class.inc.php

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Update core/attributedef.class.inc.php

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Apply suggestion from @Molkobain code review

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Apply suggestion from @Molkobain code review

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Apply suggestion from @Molkobain code review

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Apply suggestion from @Molkobain code review

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Apply suggestion from @Molkobain code review

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Correctly handle null values in new methods

---------

Co-authored-by: Stephen Abello <stephen.abello@combodo.com>
Co-authored-by: Molkobain <lajarige.guillaume@free.fr>
2026-07-17 16:40:09 +02:00
lenaick.moreira
109fdfa06b Merge branch 'support/3.3-beta1' into develop
^ Conflicts:
^	datamodels/2.x/combodo-data-feature-removal/src/Controller/DataFeatureRemovalController.php
2026-07-17 14:45:50 +02:00
lenaick.moreira
3c301affb3 N°9679 - Add remote extensions as uninstallable extension in "Extension management" 2026-07-17 14:32:46 +02:00
Lenaick
147fae7f8c N°9679 - Forced uninstallation is not logged when done by extensions management (#956) 2026-07-17 11:25:36 +02:00
odain-cbd
a5aa8c279f fix broken cache warmup after setup (#974)
* fix broken cache warmup after setup

* clear all caches after setup
2026-07-17 10:40:55 +02:00
lenaick.moreira
04f91ab4a2 Fix error CI 2026-07-16 09:40:33 +02:00
49 changed files with 1274 additions and 384 deletions

View File

@@ -2,13 +2,17 @@
```mermaid
%%{init: { 'logLevel': 'debug', 'theme': 'base', 'themeVariables': {
'git0': 'lawngreen',
'git3': 'dodgerblue',
'git4': 'grey',
'git5': 'grey',
'git6': 'grey',
'git7': 'grey',
'git8': 'grey'
'git0': '#7ccf00',
'git1': '#7ccf00',
'git2': '#99a1af',
'git3': '#99a1af',
'git4': '#99a1af',
'git5': '#99a1af',
'git6': '#99a1af',
'git7': '#99a1af',
'git8': '#99a1af',
'git9': '#99a1af',
'git10': '#99a1af'
}, 'gitGraph': {'showBranches': true,'mainBranchName': 'develop','rotateCommitLabel': true}} }%%
gitGraph
commit id: "2016-07-06" tag: "2.3.0" type: HIGHLIGHT
@@ -106,6 +110,8 @@ gitGraph
commit id: "2025-09-25" tag: "2.7.13"
checkout support/3.2
commit id: "2026-04-27 " tag: "3.2.3"
checkout develop
commit id: "2026-07-23" tag: "3.3.0-beta1"
```
To learn more, check the [iTop community versions history on the official wiki](https://www.itophub.io/wiki/page?id=latest:release:start).

View File

@@ -582,7 +582,9 @@ class UserRightsProfile extends UserRightsAddOnAPI
*/
public function ListProfiles($oUser)
{
if (count($oUser->ListChanges()) === 0) { // backward compatibility
if (!array_key_exists('profile_list', $oUser->ListChanges())) {
// Profiles list is not modified on the $oUser object, we can use DBObjectSearch with `all data` capabilities
// Note: This is the default behavior
$aRet = [];
$oSearch = new DBObjectSearch('URP_UserProfile');
$oSearch->AllowAllData();
@@ -595,6 +597,8 @@ class UserRightsProfile extends UserRightsAddOnAPI
return $aRet;
} else {
// Profiles list must be computed with memory changes.
// Note: this is a bit tricky because the user object may have been modified in memory (e.g. a profile added or removed) and we need to take that into account
$aRet = [];
$oProfilesSet = $oUser->Get('profile_list');
foreach ($oProfilesSet as $oUserProfile) {

View File

@@ -1046,7 +1046,7 @@ HTML
// Add extra data for markup generation
// - Attribute code and AttributeDef. class
$val['attcode'] = $sAttCode;
$val['atttype'] = $oAttDef->GetType();
$val['atttype'] = $oAttDef->GetTypeShortClassName();
$val['attlabel'] = $sAttLabel;
$val['attflags'] = ($bEditMode) ? $this->GetFormAttributeFlags($sAttCode) : OPT_ATT_READONLY;
@@ -4507,7 +4507,7 @@ HTML;
$oDivField = FieldUIBlockFactory::MakeLarge("");
// UIContentBlockUIBlockFactory::MakeStandard(null,["field_container field_large"]);
$oDivField->AddDataAttribute("attribute-type", $oAttDef->GetType());
$oDivField->AddDataAttribute("attribute-type", $oAttDef->GetTypeShortClassName());
$oDivField->AddDataAttribute("attribute-label", $sAttMetaDataLabel);
$oDivField->AddDataAttribute("attribute-flag-hidden", false);
$oDivField->AddDataAttribute("attribute-flag-read-only", false);

View File

@@ -501,7 +501,7 @@ EOF
*/
public function Render($oPage, $bEditMode = false, $aExtraParams = [], $bCanEdit = true)
{
$aExtraParams['dashboard_div_id'] = utils::Sanitize($aExtraParams['dashboard_div_id'] ?? null, $this->GetId(), utils::ENUM_SANITIZATION_FILTER_ELEMENT_IDENTIFIER);
$aExtraParams['dashboard_div_id'] = utils::Sanitize($aExtraParams['dashboard_div_id'] ?? $this->GetId(), $this->GetId(), utils::ENUM_SANITIZATION_FILTER_ELEMENT_IDENTIFIER);
/** @var \DashboardLayoutMultiCol $oLayout */
$oLayout = new $this->sLayoutClass();

View File

@@ -730,13 +730,8 @@ abstract class DBObject implements iDisplay
public function SetTrim($sAttCode, $sValue)
{
$oAttDef = MetaModel::GetAttributeDef(get_class($this), $sAttCode);
$iMaxSize = $oAttDef->GetMaxSize();
$sLength = mb_strlen($sValue);
if ($iMaxSize && ($sLength > $iMaxSize)) {
$sMessage = " -truncated ($sLength chars)";
$sValue = mb_substr($sValue, 0, $iMaxSize - mb_strlen($sMessage)).$sMessage;
}
$this->Set($sAttCode, $sValue);
$this->Set($sAttCode, $oAttDef->TrimValue($sValue));
}
/**
@@ -2038,7 +2033,7 @@ abstract class DBObject implements iDisplay
}
}
if (!is_null($iMaxSize = $oAtt->GetMaxSize())) {
$iLen = mb_strlen($toCheck);
$iLen = $oAtt->GetSize($toCheck);
if ($iLen > $iMaxSize) {
return "String too long (found $iLen, limited to $iMaxSize)";
}

View File

@@ -4,7 +4,7 @@
*/
$ibo-user-rights--padding-x: $ibo-spacing-400 !default;
$ibo-user-rights--padding-y: $ibo-spacing-200 !default;
$ibo-user-rights--padding-y: $ibo-spacing-100 !default;
$ibo-user-rights--border-radius: $ibo-border-radius-400 !default;
$ibo-user-rights--is-success--background-color: $ibo-color-success-100 !default;
@@ -18,6 +18,8 @@ $ibo-user-rights--is-failure--border-color: $ibo-color-danger-500 !default;
$ibo-user-rights--is-failure--border: 1px solid $ibo-user-rights--is-failure--border-color !default;
.ibo-user-rights {
display: inline-flex;
align-items: baseline;
padding: $ibo-user-rights--padding-y $ibo-user-rights--padding-x;
border-radius: $ibo-user-rights--border-radius;
&.ibo-is-success {

View File

@@ -8,70 +8,74 @@
padding-bottom: 14px;
}
.help-text{
padding: 1px 5px;
background-color: #d7e3f8;
border: 1px solid #c6e7f5;
border-radius: 5px;
margin: 5px 0;
font-size: 0.9em;
}
.form-error ul{
padding: 1px 5px;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 5px;
margin: 5px 0;
font-size: 0.9em;
}
.subform{
background-color: #efefef;
border-radius: 5px;
padding: 10px;
}
.form-buttons{
margin: 20px 0;
}
.form select{
padding: 0;
overflow-y: auto;
}
.form select option{
height: 30px;
display: flex;
align-items: center;
}
.turbo-refreshing{
opacity: .5;
}
.ibo-field legend{
margin-top: 24px;
}
collection-entry-element {
margin-top: 8px;
display: block;
padding: 10px 10px;
background-color: #f5f5f5;
border-radius: 5px;
}
.ts-control{
height: auto;
min-height: 30px;
}
.ibo-form-actions > .ibo-button > span{
margin-right: 5px;
}
.ibo-form textarea{
resize: vertical;
}
// WIP SDK Forms
//form[is="itop-form-element"] {
//
// .help-text{
// padding: 1px 5px;
// background-color: #d7e3f8;
// border: 1px solid #c6e7f5;
// border-radius: 5px;
// margin: 5px 0;
// font-size: 0.9em;
// }
//
// .form-error ul{
// padding: 1px 5px;
// background-color: #f8d7da;
// border: 1px solid #f5c6cb;
// border-radius: 5px;
// margin: 5px 0;
// font-size: 0.9em;
// }
//
// .subform{
// background-color: #efefef;
// border-radius: 5px;
// padding: 10px;
// }
//
// .form-buttons{
// margin: 20px 0;
// }
//
// .form select{
// padding: 0;
// overflow-y: auto;
// }
//
// .form select option{
// height: 30px;
// display: flex;
// align-items: center;
// }
//
// .turbo-refreshing{
// opacity: .5;
// }
//
// .ibo-field legend{
// margin-top: 24px;
// }
//
// collection-entry-element {
// margin-top: 8px;
// display: block;
// padding: 10px 10px;
// background-color: #f5f5f5;
// border-radius: 5px;
// }
// .ts-control{
// height: auto;
// min-height: 30px;
// }
//
// .ibo-form-actions > .ibo-button > span{
// margin-right: 5px;
// }
//
// .ibo-form textarea{
// resize: vertical;
// }
//
//}

View File

@@ -70,7 +70,7 @@ $ibo-vendors-ckeditor--ck-mentions--item--padding-y: $ibo-spacing-200 !default;
}
}
.ck-editor__editable_inline:not(.ck-comment__input *) {
.ck-editor__editable_inline:not(.ck-comment__input *), .ck-source-editing-area {
height: 200px;
}
@@ -84,11 +84,6 @@ $ibo-vendors-ckeditor--ck-mentions--item--padding-y: $ibo-spacing-200 !default;
}
}
// N°7552 Allow source editing area to be scrollable in full screen
.ck-maximize_editor_main .ck-source-editing-area textarea{
overflow: auto !important;
}
/* Mentions */
.ck-mentions {
.ck-button {

File diff suppressed because one or more lines are too long

View File

@@ -20,6 +20,7 @@ Dict::Add('EN US', 'English', 'English', [
'DataFeatureRemoval:Features:Title' => 'Extensions',
'DataFeatureRemoval:Result:Title' => 'Modification requested',
'DataFeatureRemoval:NoResult:Title' => 'No modification requested',
'DataFeatureRemoval:Execution:Title' => 'Deletion Executions',
'DataFeatureRemoval:Analysis:Title' => 'Analysis result',
'DataFeatureRemoval:Analysis:Subtitle' => 'Review all elements requiring attention',
@@ -39,6 +40,14 @@ Dict::Add('EN US', 'English', 'English', [
'DataFeatureRemoval:CleanupComplete:Title' => 'All clear.',
'DataFeatureRemoval:CompilComplete' => 'Compilation successful. No Cleanup needed. You can proceed to setup.',
'DataFeatureRemoval:Compile:InProgress' => 'Compilation in progress...',
'DataFeatureRemoval:Compile:Success' => 'Compilation successful',
'DataFeatureRemoval:Compile:Error' => 'Compilation error',
'DataFeatureRemoval:RunAudit:InProgress' => 'Analysis in progress...',
'DataFeatureRemoval:RunAudit:Success' => 'Analysis completed',
'DataFeatureRemoval:RunAudit:Error' => 'Error during analysis',
'UI:Button:Analyze' => 'Analyze',
'UI:Button:ModifyChoices' => 'Change my selection',
'UI:Button:AnalyzeAndSetup' => 'Analyze and go to setup',

View File

@@ -20,6 +20,7 @@ Dict::Add('FR FR', 'French', 'Français', [
'DataFeatureRemoval:Features:Title' => 'Extensions',
'DataFeatureRemoval:Result:Title' => 'Modification demandée',
'DataFeatureRemoval:NoResult:Title' => 'Aucune modification demandée',
'DataFeatureRemoval:Execution:Title' => 'Suppressions',
'DataFeatureRemoval:Analysis:Title' => 'Résultat de lanalyse',
'DataFeatureRemoval:Analysis:Subtitle' => 'Vérifier les éléments à nettoyer',
@@ -39,6 +40,14 @@ Dict::Add('FR FR', 'French', 'Français', [
'DataFeatureRemoval:CleanupComplete:Title' => 'All clear.',
'DataFeatureRemoval:CompilComplete' => 'Compilation successful. No Cleanup needed. You can proceed to setup.',
'DataFeatureRemoval:Compile:InProgress' => 'Compilation en cours...',
'DataFeatureRemoval:Compile:Success' => 'Compilation terminée',
'DataFeatureRemoval:Compile:Error' => 'Erreur lors de la compilation',
'DataFeatureRemoval:RunAudit:InProgress' => 'Analyse en cours...',
'DataFeatureRemoval:RunAudit:Success' => 'Analyse terminée',
'DataFeatureRemoval:RunAudit:Error' => 'Erreur lors de l\'analyse',
'UI:Button:Analyze' => 'Analyser',
'UI:Button:ModifyChoices' => 'Modifier la sélection',
'UI:Button:AnalyzeAndSetup' => 'Analyser et ouvrir lassistant de configuration',

View File

@@ -37,6 +37,7 @@ class DataFeatureRemovalController extends Controller
private array $aCountClassesToCleanup = [];
private array $aAnalysisDataTable = [];
private array $aDeletionExecutionSummary = [];
private ?array $aBasePackageModules = null;
private int $iCount = 0;
private int $iColumnCount = 2;
@@ -47,7 +48,11 @@ class DataFeatureRemovalController extends Controller
$aParams = [];
SetupUtils::EraseSetupToken();
(new SessionParameters(SetupUtils::SESSION_PARAMETERS_NAME))->Erase();
$oParameters = new SessionParameters(SetupUtils::SESSION_PARAMETERS_NAME);
$oParameters->Erase();
Session::Unset('aDeletionExecutionSummary');
Session::Set('bForceCompilation', true);
$oParameters->SetParameter('return_application', 'DataFeatureRemoval');
$this->AddAnalyzeParams();
$aParams['sTransactionId'] = utils::GetNewTransactionId();
@@ -60,7 +65,6 @@ class DataFeatureRemovalController extends Controller
$aParams['sSetupUrl'] = utils::GetAbsoluteUrlAppRoot().'setup';
$aParams['iCount'] = $this->iCount;
Session::Set('bForceCompilation', true);
$this->AddLinkedStylesheet(utils::GetAbsoluteUrlModulesRoot().DataFeatureRemovalHelper::MODULE_NAME.'/assets/css/DataFeatureRemoval.css');
$this->AddLinkedScript(utils::GetAbsoluteUrlModulesRoot().DataFeatureRemovalHelper::MODULE_NAME.'/assets/js/DataFeatureRemoval.js');
$this->DisplayPage($aParams);
@@ -94,7 +98,7 @@ class DataFeatureRemovalController extends Controller
}
// Display changed extensions
$aHiddenInputNames = [
$aSetupParameterNames = [
'selected_extensions' => '[]',
'selected_modules' => '[]',
'display_choices' => '',
@@ -108,95 +112,38 @@ class DataFeatureRemovalController extends Controller
'target_env' => ITOP_DEFAULT_ENV,
];
$aHiddenInputs = [];
foreach ($aHiddenInputNames as $sInputName => $defaultValue) {
$aHiddenInputs[$sInputName] = utils::ReadPostedParam($sInputName, $defaultValue, utils::ENUM_SANITIZATION_FILTER_RAW_DATA);
$oParameters = new SessionParameters(SetupUtils::SESSION_PARAMETERS_NAME);
foreach ($aSetupParameterNames as $sInputName => $defaultValue) {
$oParameters->SetParameter($sInputName, $oParameters->GetParameter($sInputName, $defaultValue));
}
$aParams['aHiddenInputs'] = $aHiddenInputs;
$aAddedExtensions = json_decode($aHiddenInputs['added_extensions'], true);
$aRemovedExtensions = json_decode($aHiddenInputs['removed_extensions'], true);
if ("[]" === $aHiddenInputs['selected_modules']) {
$aAddedExtensions = json_decode($oParameters->GetParameter('added_extensions', '[]'), true);
$aRemovedExtensions = json_decode($oParameters->GetParameter('removed_extensions', '[]'), true);
if ('[]' === $oParameters->GetParameter('selected_modules', '[]')) {
//it does not come from setup
// we get extensions from 1st screen uiblocks
$this->ReadExtensionsDiff();
$aHiddenInputs['force-uninstall'] = $this->bForcedUninstallation ? 'on' : '';
$oParameters->SetParameter('force-uninstall', $this->bForcedUninstallation ? 'on' : '');
$aAddedExtensions = $this->aExtensionsToCheck['to_be_installed'];
$aHiddenInputs['added_extensions'] = $this->ConvertIntoSetupFormat($aAddedExtensions);
$oParameters->SetParameter('added_extensions', $this->ConvertIntoSetupFormat($aAddedExtensions));
$aRemovedExtensions = $this->aExtensionsToCheck['to_be_removed'];
$aHiddenInputs['removed_extensions'] = $this->ConvertIntoSetupFormat($aRemovedExtensions);
$oParameters->SetParameter('removed_extensions', $this->ConvertIntoSetupFormat($aRemovedExtensions));
$aExtensionsNotUninstallable = $this->aExtensionsToCheck['extensions_not_uninstallable'];
$aHiddenInputs['extensions_not_uninstallable'] = $this->ConvertIntoSetupFormat($aExtensionsNotUninstallable);
$oParameters->SetParameter('extensions_not_uninstallable', $this->ConvertIntoSetupFormat($aExtensionsNotUninstallable));
}
$aParams['aAddedExtensions'] = $aAddedExtensions;
$aParams['aRemovedExtensions'] = $aRemovedExtensions;
DataFeatureRemovalLog::Debug(__METHOD__.' Extensions given in parameter', null, [
'added_extensions' => $aAddedExtensions,
'removed_extensions' => $aRemovedExtensions]);
$aParams['sTransactionId'] = utils::GetNewTransactionId();
$aParams['iColumnCount'] = $this->iColumnCount;
$aParams['aAvailableExtensions'] = $this->SplitArrayIntoColumns($this->GetExtensionsDiff($aAddedExtensions, $aRemovedExtensions), $this->iColumnCount);
//to make setup redirection work, we need to pass complex data structures to setup wizards (ie extension/module lists)
$sSourceEnv = MetaModel::GetEnvironment();
$this->oRuntimeEnvironment = new RunTimeEnvironment($sSourceEnv, false);
if ('[]' === $aHiddenInputs['selected_modules']) {
$oConfig = MetaModel::GetConfig();
$aSelectedExtensions = DataFeatureRemoverExtensionService::GetInstance()->GetExtensionMap()->GetSelectedExtensions($oConfig, array_keys($aAddedExtensions), array_keys($aRemovedExtensions));
$aHiddenInputs['selected_extensions'] = $this->ConvertIntoSetupFormat($aSelectedExtensions);
$aSelectedModules = []; // keep it to compile method
} else {
$aSelectedExtensions = json_decode($aHiddenInputs['selected_extensions'], true);
$aSelectedModules = json_decode($aHiddenInputs['selected_modules'], true);
}
try {
$this->Compile($aSelectedExtensions, array_keys($aRemovedExtensions), $aSelectedModules);
$aHiddenInputs['selected_modules'] = $this->ConvertIntoSetupFormat($aSelectedModules);
} catch (CoreException $e) {
$aParams['DataFeatureRemovalErrorMessage'] = $e->getHtmlDesc();
$this->DisplayPage($aParams, 'AnalysisResult');
return;
} catch (Exception $e) {
$aParams['DataFeatureRemovalErrorMessage'] = $e->getMessage();
$this->DisplayPage($aParams, 'AnalysisResult');
return;
}
$oSetupAudit = new SetupAudit($sSourceEnv);
$aGetRemovedClasses = array_keys($oSetupAudit->RunDataAudit());
DataFeatureRemovalLog::Debug(__METHOD__, null, ['aGetRemovedClasses' => $aGetRemovedClasses]);
$aParams['aClasses'] = $aGetRemovedClasses;
new ContextTag(ContextTag::TAG_SETUP);
$aParams['sLaunchSetupUrl'] = utils::GetAbsoluteUrlAppRoot().'setup/wizard.php';
$aParams['aSetupParams'] = [
"_class" => "WizStepLandingBeforeAudit",
"operation" => "next",
];
foreach ($aHiddenInputs as $sInputName => $sInputValue) {
$aParams['aSetupParams'][$sInputName] = $sInputValue;
}
[$aParams['aDeletionPlanSummary'], $aParams['iQueryCount'], $aParams['bDeletionPossible']] = $this->GetDeletionPlanSummaryTable($aGetRemovedClasses);
[$aParams['aDeletionExecutionSummary'], $aParams['bHasDeletionExecution']] = $this->GetExecutionSummaryTable();
$aParams['bDeletionNeeded'] = ($aParams['iQueryCount'] > 0);
Session::Set('aDeletionExecutionSummary', serialize($this->aDeletionExecutionSummary));
if (!$aParams['bDeletionNeeded']) {
// Erase session setup parameters
SetupUtils::CreateSetupToken();
}
$aAvailableExtensions = $this->GetExtensionsDiff($aAddedExtensions, $aRemovedExtensions);
$aParams['aAvailableExtensionsCount'] = count($aAvailableExtensions);
$aParams['aAvailableExtensions'] = $this->SplitArrayIntoColumns($aAvailableExtensions, $this->iColumnCount);
$aParams['sAjaxURL'] = utils::GetAbsoluteUrlModulePage(DataFeatureRemovalHelper::MODULE_NAME, 'index.php');
$this->DisplayPage($aParams, 'AnalysisResult');
}
@@ -204,55 +151,124 @@ class DataFeatureRemovalController extends Controller
private function ConvertIntoSetupFormat(array $aData): string
{
$aNewData = [];
foreach ($aData as $k => $sVal) {
$aNewData[] = sprintf('"%s":"%s"', $k, $sVal);
foreach ($aData as $sCode => $sLabel) {
$aNewData[] = sprintf('"%s":"%s"', $sCode, $sLabel);
}
return "{".implode(',', $aNewData)."}";
}
/**
* @param array $aSelectedExtensions
* @param array $aRemovedExtensions
* @param array $aSelectedModules
*
* @return void
* @throws \ConfigException
* @throws \CoreException
*/
private function Compile(array $aSelectedExtensions, array $aRemovedExtensions, array &$aSelectedModules): void
public function OperationAjaxCompile(): void
{
$aParams = [];
//to make setup redirection work, we need to pass complex data structures to setup wizards (ie extension/module lists)
$sSourceEnv = MetaModel::GetEnvironment();
$sBuildDir = APPROOT."/env-$sSourceEnv-build";
if (! is_dir($sBuildDir)) {
SetupUtils::builddir($sBuildDir);
}
$bIsDirEmpty = count(scandir($sBuildDir)) === 2;
$bForceCompilation = Session::Get('bForceCompilation', false);
$oRuntimeEnvironment = new RunTimeEnvironment($sSourceEnv, false);
$oConfig = MetaModel::GetConfig();
if ($bIsDirEmpty || $bForceCompilation) {
Session::Unset('bForceCompilation');
$this->oRuntimeEnvironment->CopySetupFiles();
$oParameters = new SessionParameters(SetupUtils::SESSION_PARAMETERS_NAME);
$aSelectedModules = json_decode($oParameters->GetParameter('selected_modules', '[]'), true);
$aSelectedExtensions = json_decode($oParameters->GetParameter('selected_extensions', '[]'), true);
$aAddedExtensions = json_decode($oParameters->GetParameter('added_extensions', '[]'), true);
$aRemovedExtensions = json_decode($oParameters->GetParameter('removed_extensions', '[]'), true);
try {
$this->ValidateTransactionId();
$oConfig = MetaModel::GetConfig();
if (count($aSelectedModules) === 0) {
$aSelectedModules = $this->oRuntimeEnvironment->GetModulesToLoadFromChoices($oConfig, $aSelectedExtensions);
$aSelectedExtensions = DataFeatureRemoverExtensionService::GetInstance()->GetExtensionMap()->GetSelectedExtensions($oConfig, array_keys($aAddedExtensions), array_keys($aRemovedExtensions));
$oParameters->SetParameter('selected_extensions', $this->ConvertIntoSetupFormat($aSelectedExtensions));
}
DataFeatureRemovalLog::Debug(
__METHOD__,
null,
['sSourceEnv' => $sSourceEnv, 'sBuildDir' => $sBuildDir, 'bIsDirEmpty' => $bIsDirEmpty, glob("$sBuildDir/*")]
);
$this->oRuntimeEnvironment->DoCompile($aSelectedExtensions, $aRemovedExtensions, $aSelectedModules, MFCompiler::CanUseSymbolicLinks());
} else {
if (count($aSelectedModules) === 0) {
$aSelectedModules = $this->oRuntimeEnvironment->GetModulesToLoadFromChoices($oConfig, $aSelectedExtensions);
$sBuildDir = APPROOT."/env-$sSourceEnv-build";
if (! is_dir($sBuildDir)) {
SetupUtils::builddir($sBuildDir);
}
$bIsDirEmpty = count(scandir($sBuildDir)) === 2;
$bForceCompilation = Session::Get('bForceCompilation', false);
if ($bIsDirEmpty || $bForceCompilation) {
$oRuntimeEnvironment->CopySetupFiles();
if (count($aSelectedModules) === 0) {
$aSelectedModules = $oRuntimeEnvironment->GetModulesToLoadFromChoices($oConfig, $aSelectedExtensions);
}
DataFeatureRemovalLog::Debug(
__METHOD__,
null,
['sSourceEnv' => $sSourceEnv, 'sBuildDir' => $sBuildDir, 'bIsDirEmpty' => $bIsDirEmpty, glob("$sBuildDir/*")]
);
$oRuntimeEnvironment->DoCompile($aSelectedExtensions, $aRemovedExtensions, $aSelectedModules, MFCompiler::CanUseSymbolicLinks());
Session::Unset('bForceCompilation');
} else {
if (count($aSelectedModules) === 0) {
$aSelectedModules = $oRuntimeEnvironment->GetModulesToLoadFromChoices($oConfig, $aSelectedExtensions);
}
}
} catch (CoreException $e) {
$aParams['error_message'] = $e->getHtmlDesc();
} catch (Exception $e) {
$aParams['error_message'] = $e->getMessage();
}
$aParams['success_message'] = Dict::S('DataFeatureRemoval:Compile:Success');
$aParams['transaction_id'] = utils::GetNewTransactionId();
$oParameters->SetParameter('selected_modules', json_encode($aSelectedModules));
$this->DisplayJSONPage($aParams);
}
public function OperationAjaxRunAudit(): void
{
$oParameters = new SessionParameters(SetupUtils::SESSION_PARAMETERS_NAME);
$aParams = [];
$aPageParams = [];
try {
$this->ValidateTransactionId();
$sSourceEnv = MetaModel::GetEnvironment();
$oSetupAudit = new SetupAudit($sSourceEnv);
$aRemovedClasses = array_keys($oSetupAudit->RunDataAudit());
$oParameters->SetParameter('classes', $aRemovedClasses);
new ContextTag(ContextTag::TAG_SETUP);
$aPageParams['sLaunchSetupUrl'] = utils::GetAbsoluteUrlAppRoot().'setup/wizard.php';
$aPageParams['aSetupParams'] = [
"_class" => "WizStepLandingBeforeAudit",
"operation" => "next",
];
$aPageParams['sTransactionId'] = utils::GetNewTransactionId();
$aDeletionPlanSummaryEntities = $this->GetDeletionPlanSummaryEntities($aRemovedClasses);
[$aPageParams['aDeletionPlanSummary'], $aPageParams['iQueryCount'], $aPageParams['bDeletionPossible']] = $this->GetDeletionPlanSummaryTable($aDeletionPlanSummaryEntities);
[$aPageParams['aDeletionExecutionSummary'], $aPageParams['bHasDeletionExecution']] = $this->GetExecutionSummaryTable();
$aPageParams['bDeletionNeeded'] = ($aPageParams['iQueryCount'] > 0);
if (!$aPageParams['bDeletionNeeded']) {
// Erase session setup parameters
SetupUtils::CreateSetupToken();
}
$this->DisplayAjaxPage($aPageParams, 'AjaxRunAudit');
return;
} catch (CoreException $e) {
$aParams['error_message'] = $e->getHtmlDesc();
} catch (Exception $e) {
$aParams['error_message'] = $e->getMessage();
}
$this->DisplayJSONPage($aParams);
}
private function GetExecutionSummaryTable(): array
{
$sName = 'ExcutionSummary';
$sName = 'ExecutionSummary';
$this->aDeletionExecutionSummary = unserialize(Session::Get('aDeletionExecutionSummary') ?? serialize([]));
$aTableData = [];
if (count($this->aDeletionExecutionSummary) === 0) {
@@ -278,11 +294,15 @@ class DataFeatureRemovalController extends Controller
}
private function GetDeletionPlanSummaryTable(array $aRemovedClasses): array
private function GetDeletionPlanSummaryEntities(array $aRemovedClasses): array
{
$oDataCleanupService = new StaticDeletionPlan();
return $oDataCleanupService->GetCleanupSummary($aRemovedClasses);
}
private function GetDeletionPlanSummaryTable(array $aDeletionPlanSummaryEntities): array
{
$sName = 'DeletionPlanSummary';
$oDataCleanupService = new StaticDeletionPlan();
$aDeletionPlanSummaryEntities = $oDataCleanupService->GetCleanupSummary($aRemovedClasses);
$aColumns = ['Class', 'Delete Count' , 'Update Count', 'Issue Count'];
$aRows = [];
$iQueryCount = 0;
@@ -305,9 +325,10 @@ class DataFeatureRemovalController extends Controller
{
$this->ValidateTransactionId();
$this->aDeletionExecutionSummary = unserialize(Session::Get('aDeletionExecutionSummary'));
$this->aDeletionExecutionSummary = unserialize(Session::Get('aDeletionExecutionSummary') ?? serialize([]));
Session::Unset('aDeletionExecutionSummary');
$aClasses = utils::ReadPostedParam('classes', null, utils::ENUM_SANITIZATION_FILTER_CLASS);
$oParameters = new SessionParameters(SetupUtils::SESSION_PARAMETERS_NAME);
$aClasses = $oParameters->GetParameter('classes', []);
$oDataCleanupService = new DataCleanupService();
$aDeletionExecutionSummary = $oDataCleanupService->ExecuteCleanup($aClasses);
@@ -322,32 +343,41 @@ class DataFeatureRemovalController extends Controller
$oSummary->iTotalUpdateCount += $oExecutionSummary->iUpdateCount;
}
Session::Set('aDeletionExecutionSummary', serialize($this->aDeletionExecutionSummary));
$this->OperationAnalysisResult();
}
private function GetAvailableExtensions(bool $bIncludePackageExtensions = false): array
{
$aExtensionsData = [];
$oExtensionMap = DataFeatureRemoverExtensionService::GetInstance()->GetExtensionMap();
$aBasePackageModules = $this->GetBasePackageModules();
if ($bIncludePackageExtensions) {
$aExtensionsRef = DataFeatureRemoverExtensionService::GetInstance()->GetExtensionMap()->GetAllExtensionsWithPreviouslyInstalled();
$aExtensionsRef = $oExtensionMap->GetAllExtensionsWithPreviouslyInstalled();
} else {
$aExtensionsRef = DataFeatureRemoverExtensionService::GetInstance()->ReadItopExtensions();
}
foreach ($aExtensionsRef as $oExtension) {
/** @var \iTopExtension $oExtension */
$aMetaData = [$oExtension->sVersion, $oExtension->GetExtensionSourceLabel()];
if (SetupUtils::IsIncludedInPackage($oExtensionMap->GetFromExtensionCode($oExtension->sCode), $aBasePackageModules)) {
$aMetaData[] = 'Already in package';
}
$aExtensionsData[$oExtension->sCode] = [
'version' => $oExtension->sVersion,
'label' => $oExtension->sLabel,
'code' => $oExtension->sCode,
'description' => $oExtension->sDescription,
'source' => $oExtension->GetExtensionSourceLabel(),
'metadata' => $aMetaData,
'installed' => $oExtension->bInstalled,
'extra_flags' => [
'uninstallable' => $oExtension->CanBeUninstalled(),
'remote' => $oExtension->IsRemote(),
'missing' => $oExtension->bRemovedFromDisk,
'cannot-be-installed' => $oExtension->HasDependencyIssue(),
'dependency_issue' => $oExtension->HasDependencyIssue(),
],
];
@@ -356,6 +386,26 @@ class DataFeatureRemovalController extends Controller
return $aExtensionsData;
}
private function GetBasePackageModules(): array
{
if ($this->aBasePackageModules !== null) {
return $this->aBasePackageModules;
}
try {
$oRuntimeEnvironment = new RunTimeEnvironment(MetaModel::GetEnvironment(), false);
$aAvailableModules = $oRuntimeEnvironment->AnalyzeInstallation(MetaModel::GetConfig(), [APPROOT]);
$this->aBasePackageModules = SetupUtils::GetBasePackageModules($aAvailableModules, APPROOT.'datamodels');
echo implode(', <br/>', $this->aBasePackageModules);
} catch (Exception $e) {
DataFeatureRemovalLog::Warning(__METHOD__, null, ['error' => $e->getMessage()]);
$this->aBasePackageModules = [];
}
return $this->aBasePackageModules;
}
private function GetExtensionsDiff(array $aAddedExtensions, array $aRemovedExtensions): array
{
$aExtensions = [];
@@ -415,12 +465,11 @@ class DataFeatureRemovalController extends Controller
/**
* Read extensions selected from posted parameters
* @return int Number of extensions to be added or removed
*/
public function ReadExtensionsDiff(): int
public function ReadExtensionsDiff(): void
{
if (!is_null($this->aExtensionsToCheck)) {
return count($this->aExtensionsToCheck['to_be_installed']) + count($this->aExtensionsToCheck['to_be_removed']);
return;
}
$aAvailableExtensions = $this->GetAvailableExtensions();
@@ -442,7 +491,7 @@ class DataFeatureRemovalController extends Controller
if (! $this->bForcedUninstallation && $aExtensionData['extra_flags']['uninstallable']) {
$this->bForcedUninstallation = true;
}
if (false === $aExtensionData['extra_flags']['uninstallable']) {
if (false === $aExtensionData['extra_flags']['uninstallable'] || true === $aExtensionData['extra_flags']['remote']) {
$this->aExtensionsToCheck['extensions_not_uninstallable'][] = $sCode;
}
} elseif (!$aExtensionData['installed'] && $aSelectedExtensionsFromUI[$sCode] === 'on') {
@@ -451,7 +500,6 @@ class DataFeatureRemovalController extends Controller
$this->aExtensionsToCheck['to_be_installed'][$sCode] = $sLabel;
}
}
return count($this->aExtensionsToCheck['to_be_installed']) + count($this->aExtensionsToCheck['to_be_removed']);
}
/**

View File

@@ -134,10 +134,6 @@ class DataCleanupService
/** @var DBObject $oDependentObj */
while ($oDependentObj = $oSet->Fetch()) {
$iDeletePropagationOption = $oExtKeyAttDef->GetDeletionPropagationOption();
if ($iDeletePropagationOption == DEL_MANUAL) {
$this->oObjectService->SetIssue(get_class($oDependentObj));
continue;
}
if ($oExtKeyAttDef->IsNullAllowed()) {
// Optional external key, list to reset
@@ -152,6 +148,12 @@ class DataCleanupService
return false;
}
} else {
// Mandatory external key
if ($iDeletePropagationOption == DEL_MANUAL) {
// Cannot be deleted automatically, must be handled manually
$this->oObjectService->SetIssue(get_class($oDependentObj));
continue;
}
// Propagate deletion only if not visited
if ($this->IsVisited($oDependentObj)) {
continue;

View File

@@ -59,9 +59,11 @@ class StaticDeletionPlan
{
foreach ($aClasses as $sClass) {
$oDeletionPlanItem = $this->GetInitialClassDeletionPlan($sClass);
$oDeletionPlanEntity = new DeletionPlanEntity();
$oDeletionPlanEntity->oDelete->Merge($oDeletionPlanItem);
$this->aDeletionPlan[$sClass] = $oDeletionPlanEntity;
// N°9831 Do not overwrite existing entity as it may already exist for this class if a previously processed class references it (issues/updates already accumulated must be kept)
if (false === array_key_exists($sClass, $this->aDeletionPlan)) {
$this->aDeletionPlan[$sClass] = new DeletionPlanEntity();
}
$this->aDeletionPlan[$sClass]->oDelete->Merge($oDeletionPlanItem);
$this->DeletionPlanForReferencingClasses($sClass);
}

View File

@@ -0,0 +1,34 @@
{# @copyright Copyright (C) 2010-2026 Combodo SARL #}
{# @license http://opensource.org/licenses/AGPL-3.0 #}
{% if bDeletionNeeded %}
{% UIFieldSet Standard {sLegend:'DataFeatureRemoval:DeletionPlan:Title'|dict_s} %}
{% UIDataTable ForForm { sRef:'aDeletionPlanSummary', aColumns:aDeletionPlanSummary.Columns, aData:aDeletionPlanSummary.Data} %}{% EndUIDataTable %}
{% EndUIFieldSet %}
{% if bDeletionPossible %}
{% UIForm Standard {} %}
{% UIInput ForHidden { sName:'transaction_id', sValue:sTransactionId} %}
{% UIInput ForHidden { sName:'operation', sValue:'DoDeletion'} %}
{% UIToolbar ForButton {} %}
{% UIButton ForPrimaryAction {sLabel:'UI:Button:DoDeletion'|dict_s, sName:'btn_deletion', sId:'btn_deletion', bIsSubmit:true} %}
{% EndUIToolbar %}
{% EndUIForm %}
{% else %}
{% UIAlert ForFailure { sTitle: '', sContent: 'DataFeatureRemoval:DeletionPlan:Error:Issues'|dict_s } %}{% EndUIAlert %}
{% endif %}
{% else %}
{% UIAlert ForSuccess { sTitle:'DataFeatureRemoval:CleanupComplete:Title'|dict_s, sContent:'DataFeatureRemoval:CompilComplete'|dict_s } %}{% EndUIAlert %}
{% UIForm Standard {sId:'launch-setup-form', Action:sLaunchSetupUrl, EncType: 'application/x-www-form-urlencoded'} %}
{% for sKey, sValue in aSetupParams %}
{% UIInput ForHidden { sName:sKey, sValue:sValue } %}
{% endfor %}
{% UIButton ForPrimaryAction {sLabel:'UI:Button:Setup'|dict_s, sName:'btn_setup', sId:'btn_setup', bIsSubmit:true} %}
{% EndUIForm %}
{% endif %}
{% if bHasDeletionExecution %}
{% UIFieldSet Standard {sLegend:'DataFeatureRemoval:Execution:Title'|dict_s} %}
{% UIDataTable ForForm { sRef:'aDeletionExecutionSummary', aColumns:aDeletionExecutionSummary.Columns, aData:aDeletionExecutionSummary.Data} %}{% EndUIDataTable %}
{% EndUIFieldSet %}
{% endif %}

View File

@@ -1,92 +1,50 @@
{# @copyright Copyright (C) 2010-2026 Combodo SARL #}
{# @license http://opensource.org/licenses/AGPL-3.0 #}
{% UIPanel ForInformation { sTitle:'DataFeatureRemoval:Analysis:Title'|dict_s, sSubTitle: 'DataFeatureRemoval:Analysis:Subtitle'|dict_s} %}
{% if null != DataFeatureRemovalErrorMessage %}
<div id="feature_removal_error_msg_div" style="display:block">
{% UIAlert ForFailure { sTitle:'DataFeatureRemoval:Failure:Title'|dict_s, sId: 'feature_removal_error_msg', sContent:DataFeatureRemovalErrorMessage } %}
{% EndUIAlert %}
</div>
{% endif %}
{% UIAlert ForInformation { sTitle: '', sId: 'ajax_compile_in_progress_msg', AddCSSClasses: ['ibo-is-hidden', 'ibo-is-html-content'] } %}
{{ 'DataFeatureRemoval:Compile:InProgress'|dict_s }}
{% UISpinner Standard { } %}
{% EndUIAlert %}
{% UIAlert ForSuccess { sTitle:'success', sId: 'ajax_compile_success_msg', AddCSSClasses: ['ibo-is-hidden', 'ibo-is-html-content'] } %}{% EndUIAlert %}
{% UIAlert ForFailure { sTitle:'error', sId: 'ajax_compile_error_msg', AddCSSClasses: ['ibo-is-hidden', 'ibo-is-html-content'] } %}{% EndUIAlert %}
{% UIAlert ForInformation { sTitle: '', sId: 'ajax_run_audit_in_progress_msg', AddCSSClasses: ['ibo-is-hidden', 'ibo-is-html-content'] } %}
{{ 'DataFeatureRemoval:RunAudit:InProgress'|dict_s }}
{% UISpinner Standard { } %}
{% EndUIAlert %}
{% UIAlert ForSuccess { sTitle:'DataFeatureRemoval:RunAudit:Success'|dict_s, sId: 'ajax_run_audit_success_msg', AddCSSClasses: ['ibo-is-hidden', 'ibo-is-html-content'] } %}{% EndUIAlert %}
{% UIAlert ForFailure { sTitle:'error', sId: 'ajax_run_audit_error_msg', AddCSSClasses: ['ibo-is-hidden', 'ibo-is-html-content'] } %}{% EndUIAlert %}
{% if aAvailableExtensionsCount == 0 %}
{% UITitle Neutral { sTitle:'DataFeatureRemoval:NoResult:Title'|dict_s, iLevel:2 } %}{% EndUITitle %}
{% else %}
{% UIPanel Neutral { sTitle:'DataFeatureRemoval:Result:Title'|dict_s, sSubTitle: '' } %}
{% UIMultiColumn Standard {} %}
{% for iColumnIndex in 0..iColumnCount-1 %}
{% UIColumn Standard {} %}
{% for aExtension in aAvailableExtensions[iColumnIndex] %}
{% if aExtension['installed'] %}
{% UIExtensionDetails Installed { sCode : aExtension['code'], sLabel : aExtension['label'], sDescription : aExtension['description'], aMetaData : [aExtension['version'], aExtension['source']], aExtraFlags : aExtension['extra_flags']} %}{% EndUIExtensionDetails %}
{% UIExtensionDetails Installed { sCode : aExtension['code'], sLabel : aExtension['label'], sDescription : aExtension['description'], aMetaData : aExtension['metadata'], aExtraFlags : aExtension['extra_flags']} %}{% EndUIExtensionDetails %}
{% else %}
{% UIExtensionDetails NotInstalled { sCode : aExtension['code'], sLabel : aExtension['label'], sDescription : aExtension['description'], aMetaData : [aExtension['version'], aExtension['source']], aExtraFlags : aExtension['extra_flags']} %}{% EndUIExtensionDetails %}
{% UIExtensionDetails NotInstalled { sCode : aExtension['code'], sLabel : aExtension['label'], sDescription : aExtension['description'], aMetaData : aExtension['metadata'], aExtraFlags : aExtension['extra_flags']} %}{% EndUIExtensionDetails %}
{% endif %}
{% endfor %}
{% EndUIColumn %}
{% endfor %}
{% EndUIMultiColumn %}
{% EndUIPanel %}
{% else %}
{% UIPanel Neutral { sTitle:'DataFeatureRemoval:Result:Title'|dict_s, sSubTitle: '' } %}
{% UIMultiColumn Standard {} %}
{% for iColumnIndex in 0..iColumnCount-1 %}
{% UIColumn Standard {} %}
{% for aExtension in aAvailableExtensions[iColumnIndex] %}
{% if aExtension['installed'] %}
{% UIExtensionDetails Installed { sCode : aExtension['code'], sLabel : aExtension['label'], sDescription : aExtension['description'], aMetaData : [aExtension['version'], aExtension['source']], aExtraFlags : aExtension['extra_flags']} %}{% EndUIExtensionDetails %}
{% else %}
{% UIExtensionDetails NotInstalled { sCode : aExtension['code'], sLabel : aExtension['label'], sDescription : aExtension['description'], aMetaData : [aExtension['version'], aExtension['source']], aExtraFlags : aExtension['extra_flags']} %}{% EndUIExtensionDetails %}
{% endif %}
{% endfor %}
{% EndUIColumn %}
{% endfor %}
{% EndUIMultiColumn %}
{% EndUIPanel %}
{% if bDeletionNeeded %}
{% UIFieldSet Standard {sLegend:'DataFeatureRemoval:DeletionPlan:Title'|dict_s} %}
{% UIDataTable ForForm { sRef:'aDeletionPlanSummary', aColumns:aDeletionPlanSummary.Columns, aData:aDeletionPlanSummary.Data} %}{% EndUIDataTable %}
{% EndUIFieldSet %}
{% if bDeletionPossible %}
{% UIForm Standard {} %}
{% UIInput ForHidden { sName:'transaction_id', sValue:sTransactionId} %}
{% UIInput ForHidden { sName:'operation', sValue:'DoDeletion'} %}
{% for sKey, sClass in aClasses %}
{% UIInput ForHidden { sName:"classes[" ~ sKey ~ "]", sValue:sClass } %}
{% endfor %}
{% for sCode, sLabel in aAddedExtensions %}
{% UIInput ForHidden { sName:"aAddedExtensions[" ~ sCode ~ "]", sValue:sLabel } %}
{% endfor %}
{% for sCode, sLabel in aRemovedExtensions %}
{% UIInput ForHidden { sName:"aRemovedExtensions[" ~ sCode ~ "]", sValue:sLabel } %}
{% endfor %}
{% for sInputName, sValue in aHiddenInputs %}
{% UIInput ForHidden { sName:sInputName, sValue:sValue } %}
{% endfor %}
{% UIToolbar ForButton {} %}
{% UIButton ForPrimaryAction {sLabel:'UI:Button:DoDeletion'|dict_s, sName:'btn_deletion', sId:'btn_deletion', bIsSubmit:true} %}
{% EndUIToolbar %}
{% EndUIForm %}
{% else %}
{% UIAlert ForFailure { sContent: 'DataFeatureRemoval:DeletionPlan:Error:Issues'|dict_s } %}{% EndUIAlert %}
{% endif %}
{% else %}
{% UIAlert ForSuccess { sTitle:'DataFeatureRemoval:CleanupComplete:Title'|dict_s, sContent:'DataFeatureRemoval:CompilComplete'|dict_s, sId:value } %}{% EndUIAlert %}
{% UIForm Standard {'sId':'launch-setup-form', Action:sLaunchSetupUrl, 'EncType': 'application/x-www-form-urlencoded'} %}
{% for sKey, sValue in aSetupParams %}
{% UIInput ForHidden { sName:sKey, sValue:sValue } %}
{% endfor %}
{% UIButton ForPrimaryAction {sLabel:'UI:Button:Setup'|dict_s, sName:'btn_setup', sId:'btn_setup', bIsSubmit:true} %}
{% EndUIForm %}
{% endif %}
{% if bHasDeletionExecution %}
{% UIFieldSet Standard {sLegend:'DataFeatureRemoval:Execution:Title'|dict_s} %}
{% UIDataTable ForForm { sRef:'aDeletionExecutionSummary', aColumns:aDeletionExecutionSummary.Columns, aData:aDeletionExecutionSummary.Data} %}{% EndUIDataTable %}
{% EndUIFieldSet %}
{% endif %}
{% endif %}
<div id="ajax_run_audit" class="ibo-block"></div>
{% UIForm Standard {} %}
{% UIInput ForHidden { sName:'transaction_id', sValue:sTransactionId} %}
{% UIInput ForHidden { sName:'operation', sValue:'Main'} %}

View File

@@ -0,0 +1,56 @@
{# @copyright Copyright (C) 2010-2026 Combodo SARL #}
{# @license http://opensource.org/licenses/AGPL-3.0 #}
function ajax_run_audit(sTransactionId) {
$('#ajax_run_audit_in_progress_msg').removeClass('ibo-is-hidden');
$.post(
'{{ sAjaxURL|raw }}',
{ operation: 'ajax_run_audit', transaction_id: sTransactionId },
function (data) {
$('#ajax_run_audit_in_progress_msg').addClass('ibo-is-hidden');
if (data.error_message) {
$('#ajax_run_audit_error_msg .ibo-alert--title').html(data.error_message);
$('#ajax_run_audit_error_msg').removeClass('ibo-is-hidden');
} else {
$('#ajax_run_audit_success_msg').removeClass('ibo-is-hidden');
$('#ajax_run_audit').html(data);
}
}
)
.fail(function() {
$('#ajax_run_audit_in_progress_msg').addClass('ibo-is-hidden');
$('#ajax_run_audit_error_msg .ibo-alert--title').html('{{ 'DataFeatureRemoval:RunAudit:Error'|dict_s }}');
$('#ajax_run_audit_error_msg').removeClass('ibo-is-hidden');
});
}
function ajax_compile() {
$('#ajax_compile_in_progress_msg').removeClass('ibo-is-hidden');
$.post(
'{{ sAjaxURL|raw }}',
{ operation: 'ajax_compile', transaction_id: '{{ sTransactionId }}' },
function (data) {
$('#ajax_compile_in_progress_msg').addClass('ibo-is-hidden');
if (data.error_message) {
$('#ajax_compile_error_msg .ibo-alert--title').html(data.error_message);
$('#ajax_compile_error_msg').removeClass('ibo-is-hidden');
} else {
$('#ajax_compile_success_msg .ibo-alert--title').html(data.success_message);
$('#ajax_compile_success_msg').removeClass('ibo-is-hidden');
ajax_run_audit(data.transaction_id);
}
},
'json'
)
.fail(function() {
$('#ajax_compile_in_progress_msg').addClass('ibo-is-hidden');
$('#ajax_compile_error_msg .ibo-alert--title').html('{{ 'DataFeatureRemoval:Compile:Error'|dict_s }}');
$('#ajax_compile_error_msg').removeClass('ibo-is-hidden');
});
}
ajax_compile();

View File

@@ -5,7 +5,6 @@
{% UIForm Standard {} %}
{% UIInput ForHidden {sName:'operation', sValue:'AnalysisResult'} %}
{% UIInput ForHidden {sName:'transaction_id', sValue:sTransactionId} %}
{% UIInput ForHidden {sName:'return_application', sValue:'itop'} %}
{% UIPanel Neutral { sTitle:'DataFeatureRemoval:Features:Title'|dict_s, sSubTitle: '' } %}
{% UIMultiColumn Standard {} %}
@@ -13,9 +12,9 @@
{% UIColumn Standard {} %}
{% for aExtension in aAvailableExtensions[iColumnIndex] %}
{% if aExtension['installed'] %}
{% UIExtensionDetails Installed { sCode : aExtension['code'], sLabel : aExtension['label'], sDescription : aExtension['description'], aMetaData : [aExtension['version'], aExtension['source']], aExtraFlags : aExtension['extra_flags']} %}{% EndUIExtensionDetails %}
{% UIExtensionDetails Installed { sCode : aExtension['code'], sLabel : aExtension['label'], sDescription : aExtension['description'], aMetaData : aExtension['metadata'], aExtraFlags : aExtension['extra_flags']} %}{% EndUIExtensionDetails %}
{% else %}
{% UIExtensionDetails NotInstalled { sCode : aExtension['code'], sLabel : aExtension['label'], sDescription : aExtension['description'], aMetaData : [aExtension['version'], aExtension['source']], aExtraFlags : aExtension['extra_flags']} %}{% EndUIExtensionDetails %}
{% UIExtensionDetails NotInstalled { sCode : aExtension['code'], sLabel : aExtension['label'], sDescription : aExtension['description'], aMetaData : aExtension['metadata'], aExtraFlags : aExtension['extra_flags']} %}{% EndUIExtensionDetails %}
{% endif %}
{% endfor %}
{% EndUIColumn %}

File diff suppressed because one or more lines are too long

View File

@@ -17,4 +17,8 @@ $ipb-vendors-ckeditor--ck-content--text-color: $ipb-color-grey-900 !default;
.ipb-is-html-content {
@extend .ck-content;
}
.ck-source-editing-area {
height: 180px;
}

View File

@@ -689,7 +689,7 @@ class ManageBrickController extends BrickController
$sValue = $oAttDef->GetAsHTML($oCurrentRow->Get($sItemAttr));
$sSortValue = $oCurrentRow->Get($sItemAttr);
}
$sAttType = $oAttDef->GetType();
$sAttType = $oAttDef->GetTypeShortClassName();
unset($oAttDef);
// For simple fields, we get the raw (stored) value as well

View File

@@ -1385,7 +1385,7 @@ class ObjectController extends BrickController
'object_id' => $oNewLink->GetKey(),
'prefix' => 'lnk__',
'attribute_code' => $sAttCode,
'attribute_type' => $oAttDef->GetType(),
'attribute_type' => $oAttDef->GetTypeShortClassName(),
'value_html' => $sValue,
];
@@ -1442,7 +1442,7 @@ class ObjectController extends BrickController
'object_class' => $sObjectClass,
'object_id' => $oObject->GetKey(),
'attribute_code' => $oAttDef->GetCode(),
'attribute_type' => $oAttDef->GetType(),
'attribute_type' => $oAttDef->GetTypeShortClassName(),
];
// - Value raw

View File

@@ -324,7 +324,7 @@ class BrowseBrickHelper
'object_class' => $sCurrentObjectClass,
'object_id' => $sCurrentObjectId,
'attribute_code' => $sNameAttCode,
'attribute_type' => $sNameAttDef->GetType(),
'attribute_type' => $sNameAttDef->GetTypeShortClassName(),
'value_raw' => $value->Get($sNameAttCode),
],
];
@@ -415,7 +415,7 @@ class BrowseBrickHelper
'object_class' => $sCurrentObjectClass,
'object_id' => $sCurrentObjectId,
'attribute_code' => $aField['code'],
'attribute_type' => $oAttDef->GetType(),
'attribute_type' => $oAttDef->GetTypeShortClassName(),
'value_raw' => $attValueRaw,
'value_html' => $sHtmlForFieldValue,
];

2
node_modules/.package-lock.json generated vendored
View File

@@ -104,7 +104,7 @@
},
"node_modules/ckeditor5-itop-build": {
"version": "3.3.0",
"resolved": "git+ssh://git@github.com/Combodo/ckeditor5-itop-build.git#21b6edc3348d3f1804e3ae8aab1567ac888a2f30",
"resolved": "git+ssh://git@github.com/Combodo/ckeditor5-itop-build.git#8d8e90d315cd8627c85f501835b8d60bc0ee72d5",
"license": "SEE LICENSE IN LICENSE.md"
},
"node_modules/clipboard": {

View File

@@ -7525,9 +7525,14 @@ div.ck.ck-balloon-panel.ck-mention-balloon {
margin-left: 0;
}
.ck-fullscreen__main-wrapper .ck-fullscreen__editable .ck.ck-editor__editable:not(.ck-editor__nested-editable) {
.ck-fullscreen__main-wrapper .ck-fullscreen__editable .ck.ck-editor__editable:not(.ck-editor__nested-editable),
.ck-fullscreen__main-wrapper .ck-fullscreen__editable .ck-source-editing-area{
height: 100%;
width: 100%;
max-width: 100%;
padding: 0;
}
.ck-source-editing-area textarea {
overflow: auto;
}

6
package-lock.json generated
View File

@@ -141,7 +141,7 @@
},
"node_modules/ckeditor5-itop-build": {
"version": "3.3.0",
"resolved": "git+ssh://git@github.com/Combodo/ckeditor5-itop-build.git#21b6edc3348d3f1804e3ae8aab1567ac888a2f30",
"resolved": "git+ssh://git@github.com/Combodo/ckeditor5-itop-build.git#8d8e90d315cd8627c85f501835b8d60bc0ee72d5",
"license": "SEE LICENSE IN LICENSE.md"
},
"node_modules/clipboard": {
@@ -437,8 +437,8 @@
}
},
"ckeditor5-itop-build": {
"version": "git+ssh://git@github.com/Combodo/ckeditor5-itop-build.git#21b6edc3348d3f1804e3ae8aab1567ac888a2f30",
"from": "ckeditor5-itop-build@github:Combodo/ckeditor5-itop-build"
"version": "git+ssh://git@github.com/Combodo/ckeditor5-itop-build.git#8d8e90d315cd8627c85f501835b8d60bc0ee72d5",
"from": "ckeditor5-itop-build@https://github.com/Combodo/ckeditor5-itop-build.git"
},
"clipboard": {
"version": "2.0.11",

View File

@@ -8,6 +8,7 @@ use DBObjectSet;
use IssueLog;
use MetaModel;
use SetupLog;
use TagSetFieldData;
require_once APPROOT.'setup/feature_removal/ModelReflectionSerializer.php';
@@ -68,6 +69,10 @@ abstract class AbstractSetupAudit
continue;
}
if (MetaModel::IsSameFamily($sClass, TagSetFieldData::class)) {
continue;
}
if (!MetaModel::IsStandaloneClass($sClass)) {
$iCount = $this->Count($sClass);
$this->aFinalClassesToCleanup[$sClass] = $iCount;

View File

@@ -176,7 +176,7 @@ class iTopExtension
};
}
public function IsRemote(): string
public function IsRemote(): bool
{
return $this->sSource === self::SOURCE_REMOTE;
}

View File

@@ -933,6 +933,7 @@ class RunTimeEnvironment
@chmod($sFinalConfig, 0440); // Read-only for owner and group, nothing for others
SetupUtils::rrmdir(dirname($sBuildConfig)); // Cleanup the temporary build dir if empty
MetaModel::ResetAllCaches($this->sBuildEnv);
MetaModel::ResetAllCaches($this->sFinalEnv);
if (! isset($_SESSION)) {

View File

@@ -1707,6 +1707,69 @@ JS
return array_key_exists('itsm-designer-connector', $aModules);
}
/**
* Build a map of module IDs that belong to the package base scope.
*
* @param array $aAnalyzeInstallationModules Output from AnalyzeInstallation method
* @param string $sSourceDir Base directory used as package scope
*
* @return array<string, bool>
*/
public static function GetBasePackageModules(array $aAnalyzeInstallationModules, string $sSourceDir): array
{
$aBasePackageModules = [];
$sNormalizedSourceDir = self::NormalizePathForComparison($sSourceDir);
if ($sNormalizedSourceDir === '') {
return $aBasePackageModules;
}
foreach ($aAnalyzeInstallationModules as $sModuleId => $aModuleInfo) {
if ($sModuleId === ROOT_MODULE) {
continue;
}
$sRootDir = $aModuleInfo['root_dir'] ?? '';
if ($sRootDir === '') {
continue;
}
$sModuleRootDir = self::NormalizePathForComparison($sRootDir);
if (utils::StartsWith($sModuleRootDir, $sNormalizedSourceDir)) {
$aBasePackageModules[$sModuleId] = true;
}
}
return $aBasePackageModules;
}
/**
* Returns true when all modules of a non-package extension are already included in base package modules.
*/
public static function IsIncludedInPackage(?iTopExtension $oExtension, array $aBasePackageModules): bool
{
if (($oExtension === null) || ($oExtension->sSource === iTopExtension::SOURCE_WIZARD)) {
return false;
}
$aModules = $oExtension->aModules ?? [];
if (!is_array($aModules) || empty($aModules)) {
return false;
}
foreach ($aModules as $sModuleId) {
if (!array_key_exists($sModuleId, $aBasePackageModules)) {
return false;
}
}
return true;
}
private static function NormalizePathForComparison(string $sPath): string
{
return rtrim(str_replace('\\', '/', $sPath), '/');
}
/**
* @param array $aModules List of available module codes
*
@@ -2226,7 +2289,7 @@ JS
$sButtonLabel = '';
if ($sReturnApplication !== '') {
switch ($sReturnApplication) {
case 'itop':
case 'DataFeatureRemoval':
$sButtonUrl = utils::GetAbsoluteUrlModulePage('combodo-data-feature-removal', 'index.php');
$sButtonLabel = 'Back to application';
break;

View File

@@ -32,9 +32,6 @@ class WizStepLandingBeforeAudit extends WizStepModulesChoice
$oWizard->SetParameter('db_tls_ca', $this->oConfig->Get('db_tls.ca') ?? '');
$oWizard->SetParameter('display_choices', '');
$oWizard->SaveParameter('extensions_not_uninstallable', '[]');
$oWizard->SaveParameter('use_symbolic_links', MFCompiler::UseSymbolicLinks());
$oWizard->SaveParameter('force-uninstall', false);
$oWizard->SaveParameter('skip_wizard', false);
// should be done at the end
@@ -64,16 +61,7 @@ class WizStepLandingBeforeAudit extends WizStepModulesChoice
$adModulesFromDatabase = ModuleInstallationRepository::GetInstance()->ReadComputeInstalledModules($oConfig);
$this->oWizard->SetParameter('selected_modules', json_encode(array_keys($adModulesFromDatabase)));
} else {
$this->oWizard->SavePostedParameter('selected_modules');
$this->oWizard->SavePostedParameter('selected_extensions');
$this->oWizard->SavePostedParameter('added_extensions');
$this->oWizard->SavePostedParameter('removed_extensions');
$this->oWizard->SavePostedParameter('extensions_not_uninstallable');
$this->oWizard->SavePostedParameter('copy_setup_files', '1');
$this->oWizard->SavePostedParameter('force-uninstall');
$this->oWizard->SavePostedParameter('use_symbolic_links');
$this->oWizard->SavePostedParameter('return_application');
$this->oWizard->SavePostedParameter('target_env');
}
$aWizardSteps = $this->oWizard->GetParameter('_steps', null);

View File

@@ -49,6 +49,7 @@ class WizStepModulesChoice extends AbstractWizStepInstall
protected bool $bChoicesFromDatabase;
private array $aAnalyzeInstallationModules = [];
private ?array $aBasePackageModules = null;
private ?MissingDependencyException $oMissingDependencyException = null;
public function __construct(WizardController $oWizard, $sCurrentState, bool $bOverWriteConfig = true)
@@ -167,6 +168,33 @@ class WizStepModulesChoice extends AbstractWizStepInstall
return new WizardState(WizStepModulesChoice::class, (string)($index - 1));
}
public function GetAllSelectedModulesUntilNow(): array
{
$aModules = [];
$aSelectedChoices = json_decode($this->oWizard->GetParameter('selected_components', '{}'), true);
$iNextStep = $this->GetStepIndex();
$index = $iNextStep;
while(isset($aSelectedChoices[$iNextStep])){
//Let's empty the next steps, we only want what has been chosen before
$aSelectedChoices[$iNextStep] = [];
$iNextStep++;
}
for ($i = 0; $i < $index; $i++) {
$aStepInfo = $this->GetStepInfo($i);
$this->GetSelectedModules($aStepInfo, $aSelectedChoices[$i], $aModules );
}
/*
echo "===GetAllSelectedModulesUntilNow : ===<br/>";
echo implode(', <br/>',array_keys($aModules));
echo "<br/>=======<br/><hr/>";
*/
return $aModules;
}
public function GetWizardSteps(): array
{
$aSteps = [
@@ -570,6 +598,7 @@ EOF
foreach ($this->aAnalyzeInstallationModules as $sModuleId => $aModule) {
if (($sModuleId != ROOT_MODULE) && !isset($aModules[$sModuleId])) {
if (($aModule['category'] == 'authentication') || (!$aModule['visible'] && !isset($aModule['auto_select']))) {
//echo "Set module ".$sModuleId." to selected1<br/>";
$aModules[$sModuleId] = true;
$sDisplayChoices .= '<li><i>'.$aModule['label'].' (hidden)</i></li>';
}
@@ -612,6 +641,7 @@ EOF
}
}
if ($bSelected) {
//echo "Set module ".$sModuleId." to selected 2<br/>";
$aModules[$sModuleId] = true; // store the Id of the selected module
SetupInfo::SetSelectedModules($aModules);
}
@@ -742,28 +772,45 @@ EOF
return $this->aSteps[$index] ?? null;
}
public function ComputeChoiceFlags(array $aChoice, string $sChoiceId, array $aSelectedComponents, bool $bAllDisabled, bool $bDisableUninstallCheck, bool $bUpgradeMode)
public function ExtensionIsAlreadyIncludedInPreviousChoices(?iTopExtension $oITopExtension): bool
{
if(is_null($oITopExtension) || empty($oITopExtension->aModules)){
return false;
}
$aAllPreviousChoicesModules = $this->GetAllSelectedModulesUntilNow();
foreach($oITopExtension->aModules as $sModuleId) {
if (!isset($aAllPreviousChoicesModules[$sModuleId])) {
return false;
}
}
return true;
}
public function ComputeChoiceFlags(array $aChoice, string $sChoiceId, array $aSelectedComponents, bool $bAllDisabled, bool $bDisableUninstallCheck, bool $bUpgradeMode) {
$oITopExtension = $this->oExtensionsMap->GetFromExtensionCode($aChoice['extension_code']);
//If the extension is missing from disk, it won't exist in the ExtensionsMap, thus returning null
$bCanBeUninstalled = isset($aChoice['uninstallable']) ? $aChoice['uninstallable'] === true || $aChoice['uninstallable'] === 'yes' : $oITopExtension->CanBeUninstalled();
$bSelected = isset($aSelectedComponents[$sChoiceId]) && ($aSelectedComponents[$sChoiceId] === $sChoiceId);
$bMissingFromDisk = isset($aChoice['missing']) && $aChoice['missing'] === true;
$bMandatory = (isset($aChoice['mandatory']) && $aChoice['mandatory']);
$bInstalled = $bMissingFromDisk || $oITopExtension->bInstalled;
$bDependencyIssue = $oITopExtension->HasDependencyIssue();
$bInstalled = $bMissingFromDisk || $oITopExtension?->bInstalled ?? false;
$bDependencyIssue = $oITopExtension?->HasDependencyIssue() ?? false;
$bAlreadyIncluded = $this->ExtensionIsAlreadyIncludedInPreviousChoices($oITopExtension);
$bChecked = $bSelected;
$bDisabled = false;
if ($bMissingFromDisk) {
$bDisabled = true;
$bChecked = false;
} elseif ($bDependencyIssue) {
$bDisabled = true;
$bChecked = false;
} elseif ($bMandatory) {
}elseif($bAlreadyIncluded){
$bDisabled = true;
$bChecked = true;
}elseif ($bMandatory) {
$bDisabled = true;
$bChecked = true;
} elseif ($bDependencyIssue) {
$bDisabled = !$bDisableUninstallCheck;
} elseif ($bInstalled && !$bCanBeUninstalled && !$bDisableUninstallCheck) {
$bChecked = true;
$bDisabled = true;
@@ -791,6 +838,7 @@ EOF
return [
'uninstallable' => $bCanBeUninstalled,
'already_included' => $bAlreadyIncluded,
'dependency_issue' => $bDependencyIssue,
'mandatory' => $bMandatory,
'missing' => $bMissingFromDisk,
@@ -813,6 +861,9 @@ EOF
if ($aFlags['disabled'] && !$aFlags['checked'] && !$bDisableUninstallCheck && (!$aFlags['uninstallable'] || $aFlags['mandatory'])) {
$this->bCanMoveForward = false;//Disable "Next"
} elseif (!$bDisableUninstallCheck && $aFlags['dependency_issue']) {
//If there is a dependency issue, the user cannot move forward without forced uninstall
$this->bCanMoveForward = false;
}
$this->DisplayChoice($oPage, $aChoice, $aSelectedComponents, $aDefaults, $sChoiceId, $sChoiceId, $aFlags);
@@ -866,7 +917,7 @@ EOF
<a class="setup--wizard-choice--more-info" target="_blank" href="'.$aChoice['more_info'].'">
<i class="setup-extension--icon fas fa-external-link-alt" title="More information"></i>
</a>' : '';
$sDescription = isset($aChoice['description']) ? utils::EscapeHtml($aChoice['description']) : '';
$sDescription = isset($aChoice['description']) ? trim(utils::EscapeHtml($aChoice['description'])) : '';
$sId = utils::EscapeHtml($aChoice['extension_code']);
$sDataId = 'data-id="'.utils::EscapeHtml($aChoice['extension_code']).'"';
$sDisabled = $aFlags['disabled'] ? ' disabled data-disabled="disabled"' : '';
@@ -891,11 +942,18 @@ EOF
if ($aFlags['dependency_issue']) {
$sTooltip .= '<div id="badge--'.$sId.'--cannot-be-installed" class="ibo-badge ibo-block ibo-is-orange" title="This extension cannot be installed because one or more dependencies are not satisfied." >cannot be installed</div>';
}
if ($aFlags['already_included']) {
if(mb_strlen($sDescription) > 0){
$sDescription .= '<br/>';
}
$sDescription .= '<b>All the modules included in this extension are also included in the package. To uninstall this extension, you have to delete its folder.</b>';
}
$sMetadata = '';
if (isset($aChoice['version']) && isset($aChoice['source_label'])) {
$sMetadata = '<span>v'.$aChoice['version'].'</span><span>'.$aChoice['source_label'].'</span>';
}
$sChoiceDisabled = $aFlags['disabled'] && !$aFlags['checked'] ? 'choice-disabled' : '';
$oPage->add('
@@ -935,6 +993,7 @@ EOF
return $sSourceDir.'/installation.xml';
}
public function CanMoveForward()
{
return true;

View File

@@ -555,7 +555,7 @@ class DataTableUIBlockFactory extends AbstractUIBlockFactory
'object_class' => $sClassName,
'class_alias' => $sClassAlias,
'attribute_code' => $sAttCode,
'attribute_type' => $oAttDef->GetType(),
'attribute_type' => $oAttDef->GetTypeShortClassName(),
'attribute_label' => $sAttLabel,
'render' => $oAttDef->GetRenderForDataTable($sClassAlias),
];
@@ -737,7 +737,7 @@ JS;
'object_class' => $sClassName,
'class_alias' => $sClassAlias,
'attribute_code' => $sAttCode,
'attribute_type' => $oAttDef->GetType(),
'attribute_type' => $oAttDef->GetTypeShortClassName(),
'attribute_label' => $sAttLabel,
];
$aColumnDefinition["data"] = $sClassAlias."/".$sAttCode;

View File

@@ -136,6 +136,12 @@ abstract class AttributeDefinition
return Dict::S('Core:'.$oClass->getShortName());
}
public function GetTypeShortClassName()
{
$oClass = new ReflectionClass(get_class($this));
return $oClass->getShortName();
}
public function GetTypeDesc()
{
$oClass = new ReflectionClass(get_class($this));
@@ -894,6 +900,60 @@ abstract class AttributeDefinition
return null;
}
/**
* Return the size of $value, expressed in the same unit as {@see static::GetMaxSize()} for this attribute class.
*
* Default unit is a number of **characters**, matching MySQL VARCHAR(M) semantics for VARCHAR-based attributes.
* Byte-based attributes (e.g. {@see AttributeText}, stored as MySQL TEXT which is limited to 65535 **bytes**,
* not characters) MUST override both this method and {@see TrimValue()} consistently.
*
* @param string|null $sValue
*
* @return int Size of $value in the unit of GetMaxSize() (characters by default)
* @since 3.2.3-2 3.2.4 3.3.0 N°9759
*/
public function GetSize(?string $sValue)
{
// If the value is null, we return 0
if ($sValue === null) {
return 0;
}
return mb_strlen($sValue);
}
/**
* Helper to set a value that fits the attribute max size
*
* Truncation is performed in the same unit as GetMaxSize() / {@see GetSize()}: a number of **characters**
* by default (VARCHAR-based attributes). When truncated, a " -truncated (N chars)" suffix is appended and
* the returned value (suffix included) still fits within GetMaxSize().
*
* Default behavior is what DBObject::SetTrim used to do, now delegated to AttributeDefinition
*
* @param string|null $sValue
*
* @return string $sValue truncated so that it fits within {@see GetMaxSize()}.
* @since 3.2.3-2 3.2.4 3.3.0 N°9759
*/
public function TrimValue(?string $sValue)
{
// If the value is null, we return an empty string
if ($sValue === null) {
return '';
}
$iMaxSize = $this->GetMaxSize();
$iLength = mb_strlen($sValue);
if ($iMaxSize && ($iLength > $iMaxSize)) {
$sMessage = " -truncated ($iLength chars)";
return mb_substr($sValue, 0, $iMaxSize - mb_strlen($sMessage)).$sMessage;
}
return $sValue;
}
abstract public function GetDefaultValue(?DBObject $oHostObject = null);
//
@@ -1095,7 +1155,7 @@ abstract class AttributeDefinition
// Metadata
$oFormField->AddMetadata('attribute-code', $this->GetCode());
$oFormField->AddMetadata('attribute-type', $this->GetType());
$oFormField->AddMetadata('attribute-type', $this->GetTypeShortClassName());
$oFormField->AddMetadata('attribute-label', $this->GetLabel());
// - Attribute flags
$aPossibleAttFlags = MetaModel::EnumPossibleAttributeFlags();

View File

@@ -58,6 +58,51 @@ class AttributeText extends AttributeString
return "TEXT".CMDBSource::GetSqlStringColumnDefinition();
}
/**
* @inheritDoc
*
* Unlike the default implementation, the size is expressed in **bytes**: MySQL TEXT columns are limited
* in bytes (65535), not in characters, and {@see static::GetMaxSize()} for this class returns a number of bytes.
*/
public function GetSize(?string $sValue)
{
// If the value is null, we return 0
if ($sValue === null) {
return 0;
}
return strlen($sValue);
}
/**
* @inheritDoc
*
* Truncation is performed on a **byte** budget (MySQL TEXT limit) without ever cutting through a multibyte
* UTF-8 sequence: the returned value is always valid UTF-8 and never exceeds GetMaxSize() bytes,
* truncation suffix included.
*/
public function TrimValue(?string $sValue)
{
// If the value is null, we return an empty string
if ($sValue === null) {
return '';
}
$iMaxSize = $this->GetMaxSize();
$iLength = strlen($sValue);
$iLengthChar = mb_strlen($sValue);
if ($iMaxSize && ($iLength > $iMaxSize)) {
$sMessage = " -truncated ($iLengthChar chars)";
$iTruncatedValueMaxSize = $iMaxSize - strlen($sMessage);
// mb_strcut cuts on a byte budget but moves the cut point back to a character boundary,
// so it never returns a broken multibyte sequence at the end of the value
$sTruncatedValue = mb_strcut($sValue, 0, $iTruncatedValueMaxSize, 'UTF-8');
return $sTruncatedValue.$sMessage;
}
return $sValue;
}
public function GetSQLColumns($bFullSpec = false)
{
$aColumns = [];

View File

@@ -852,7 +852,7 @@ JS
'object_class' => $sClass,
'object_id' => $oItem->GetKey(),
'attribute_code' => $sAttCode,
'attribute_type' => $oAttDef->GetType(),
'attribute_type' => $oAttDef->GetTypeShortClassName(),
];
// - Value raw
// For simple fields, we get the raw (stored) value as well

View File

@@ -45,8 +45,10 @@ class SessionParameters
*/
public function SetParameter($sParamCode, $value): void
{
$this->aParameters[$sParamCode] = $value;
$this->Save();
if (!array_key_exists($sParamCode, $this->aParameters) || $this->aParameters[$sParamCode] !== $value) {
$this->aParameters[$sParamCode] = $value;
$this->Save();
}
}
/**

View File

@@ -3002,8 +3002,6 @@ class SynchroExecution
* </ul>
*/
protected $m_oLastFullLoadStartDate = null;
/** @var bool true if the caller script gave the datetime before import phase was launched */
protected $m_bIsImportPhaseDateKnown;
/** @var \CMDBChange */
protected $m_oChange = null;
@@ -3028,10 +3026,7 @@ class SynchroExecution
public function __construct($oDataSource, $oImportPhaseStartDate = null)
{
$this->m_oDataSource = $oDataSource;
$this->m_bIsImportPhaseDateKnown = ($oImportPhaseStartDate != null);
$this->m_oImportPhaseStartDate = $oImportPhaseStartDate;
$this->m_oCtx = new ContextTag(ContextTag::TAG_SYNCHRO);
$this->m_oCtx1 = new ContextTag('Synchro:'.$oDataSource->GetRawName()); // More precise context information
}
@@ -3108,7 +3103,7 @@ class SynchroExecution
$this->m_oStatLog->Set('stats_nb_replica_total', $this->m_iCountAllReplicas);
$this->m_oStatLog->DBInsert();
$sLastFullLoad = ($this->m_bIsImportPhaseDateKnown) ? $this->m_oImportPhaseStartDate->format('Y-m-d H:i:s') : 'not specified';
$sLastFullLoad = (is_null($this->m_oImportPhaseStartDate)) ? 'not specified' : $this->m_oImportPhaseStartDate->format('Y-m-d H:i:s');
$this->m_oStatLog->AddTrace("###### STARTING SYNCHRONIZATION ##### Total: {$this->m_iCountAllReplicas} replica(s). Last full load: '$sLastFullLoad' ");
$sSql = 'SELECT NOW();';
$sDBNow = CMDBSource::QueryToScalar($sSql);
@@ -3212,21 +3207,18 @@ class SynchroExecution
// Compute and keep track of the limit date taken into account for obsoleting replicas
//
$iFullLoadInterval = $this->m_oDataSource->Get('full_load_periodicity'); // Duration in seconds
if ($this->m_bIsImportPhaseDateKnown) {
$oLimitDate = clone $this->m_oImportPhaseStartDate;
$sInterval = "-$iFullLoadInterval seconds";
$oLimitDate->Modify($sInterval);
} else {
if (is_null($this->m_oImportPhaseStartDate)) {
if ($iFullLoadInterval <= 0) {
// we are doing exec phase alone, and the full load interval is set to 0 => we should not update/delete replicas !!
// This will prevent actions in DoJob1() method
$oLimitDate = new DateTime('1970-01-01');
} else {
$oLimitDate = self::GetDataBaseCurrentDateTime();
$sInterval = "-$iFullLoadInterval seconds";
$oLimitDate->Modify($sInterval);
}
} else {
$oLimitDate = clone $this->m_oImportPhaseStartDate;
}
$this->ExactlySubtractSeconds($oLimitDate, $iFullLoadInterval);
$this->m_oLastFullLoadStartDate = $oLimitDate;
if ($bFirstPass) {
$this->m_oStatLog->AddTrace('Limit Date: '.$this->m_oLastFullLoadStartDate->Format('Y-m-d H:i:s'));
@@ -3346,10 +3338,10 @@ class SynchroExecution
$aArguments['log'] = $this->m_oStatLog->GetKey();
$aArguments['change'] = $this->m_oChange->GetKey();
$aArguments['chunk'] = $iMaxChunkSize;
if ($this->m_bIsImportPhaseDateKnown) {
$aArguments['last_full_load'] = $this->m_oImportPhaseStartDate->Format('Y-m-d H:i:s');
} else {
if (is_null($this->m_oImportPhaseStartDate)) {
$aArguments['last_full_load'] = '';
} else {
$aArguments['last_full_load'] = $this->m_oImportPhaseStartDate->Format('Y-m-d H:i:s');
}
$this->m_oStatLog->DBUpdate();
@@ -3701,13 +3693,11 @@ class SynchroExecution
// Get all the replicas that are to be deleted
//
$oDeletionDate = $this->m_oLastFullLoadStartDate;
$oDeletionDate = clone $this->m_oLastFullLoadStartDate;
$iDeleteRetention = $this->m_oDataSource->Get('delete_policy_retention'); // Duration in seconds
if ($iDeleteRetention > 0) {
$sInterval = "-$iDeleteRetention seconds";
$oDeletionDate->Modify($sInterval);
}
$this->ExactlySubtractSeconds($oDeletionDate, $iDeleteRetention);
$sDeletionDate = $oDeletionDate->Format('Y-m-d H:i:s');
if ($bFirstPass) {
$this->m_oStatLog->AddTrace("Deletion date: $sDeletionDate");
}
@@ -3757,4 +3747,21 @@ class SynchroExecution
return false;
}
/** Take into account timechange to apply date difference operation
* @param \DateTime $oDate
* @param $iDurationInSeconds
* @return void
* @throws \Exception
*/
public function ExactlySubtractSeconds(DateTime $oDate, $iDurationInSeconds): void
{
if ($iDurationInSeconds > 0) {
$oDate->setTimezone(new DateTimeZone('UTC'));
$sInterval = "-$iDurationInSeconds seconds";
$oDate->Modify($sInterval);
$sTimezone = MetaModel::GetConfig()->Get('timezone');
$oDate->setTimezone(new DateTimeZone($sTimezone));
}
}
}

View File

@@ -1364,19 +1364,21 @@ class DBObjectTest extends ItopDataTestCase
{
return [
// UserRequest.title is an AttributeString (maxsize = 255)
'title 250 chars' => ['title', 250],
'title 254 chars' => ['title', 254],
'title 255 chars' => ['title', 255],
'title 256 chars' => ['title', 256],
'title 300 chars' => ['title', 300],
'title 250 chars' => ['title', 250, 250, true],
'title 254 chars' => ['title', 254, 254, true],
'title 255 chars' => ['title', 255, 255, true],
'title 256 chars' => ['title', 256, 255, false],
'title 300 chars' => ['title', 300, 255, false],
// UserRequest.pending_reason is an AttributeText (maxsize=65535) with format=text
'pending_reason 250 chars' => ['pending_reason', 250],
'pending_reason 60000 chars' => ['pending_reason', 60000],
'pending_reason 65534 chars' => ['pending_reason', 65534],
'pending_reason 65535 chars' => ['pending_reason', 65535],
'pending_reason 65536 chars' => ['pending_reason', 65536],
'pending_reason 70000 chars' => ['pending_reason', 70000],
'pending_reason 250 chars' => ['pending_reason', 250, 250, true],
'pending_reason 65534 chars' => ['pending_reason', 65534, 16403, false],
'pending_reason 65535 chars' => ['pending_reason', 65535, 16403, false],
'pending_reason 65536 chars' => ['pending_reason', 65536, 16403, false],
'pending_reason 16385 chars' => ['pending_reason', 16385, 16403, false],
'pending_reason 16384 chars' => ['pending_reason', 16384, 16384, true],
'pending_reason 16383 chars' => ['pending_reason', 16383, 16383, true],
'pending_reason 16382 chars' => ['pending_reason', 16382, 16382, true],
];
}
@@ -1389,7 +1391,7 @@ class DBObjectTest extends ItopDataTestCase
*
* @since 3.1.2 N°3448 - Framework field size check not correctly implemented for multi-bytes languages/strings
*/
public function testCheckLongValueInAttribute(string $sAttrCode, int $iValueLength)
public function testCheckLongValueInAttribute(string $sAttrCode, int $iValueLength, int $iExpectedLength, bool $bIsValueToSetBelowAttrMaxSize): void
{
$sPrefix = 'a'; // just a small prefix so that the emoji bytes won't have a power of 2 (we want a non even value)
$sEmojiToRepeat = '😎'; // this emoji is 4 bytes long
@@ -1410,17 +1412,18 @@ class DBObjectTest extends ItopDataTestCase
$oAttDef = MetaModel::GetAttributeDef(UserRequest::class, $sAttrCode);
$iAttrMaxSize = $oAttDef->GetMaxSize();
$bIsValueToSetBelowAttrMaxSize = ($iValueLength <= $iAttrMaxSize);
$bExpectedStatus = ($oAttDef->GetSize($sValueToSet) <= $iAttrMaxSize);
$this->assertSame($bExpectedStatus, $bIsValueToSetBelowAttrMaxSize, 'The data provider must stay aligned with the attribute max size logic.');
/** @noinspection PhpUnusedLocalVariableInspection */
[$bCheckStatus, $aCheckIssues, $bSecurityIssue] = $oTicket->CheckToWrite();
$this->assertEquals($bIsValueToSetBelowAttrMaxSize, $bCheckStatus, "CheckResult result:".var_export($aCheckIssues, true));
$oTicket->SetTrim($sAttrCode, $sValueToSet);
$sValueInObject = $oTicket->Get($sAttrCode);
$this->assertEquals($iExpectedLength, mb_strlen($sValueInObject), 'Should match expected resulting value length.');
if ($bIsValueToSetBelowAttrMaxSize) {
$this->assertEquals($sValueToSet, $sValueInObject, 'Should not alter string that is already shorter than attribute max length');
} else {
$this->assertEquals($iAttrMaxSize, mb_strlen($sValueInObject), 'Should truncate at the same length than attribute max length');
$sLastCharsOfValueInObject = mb_substr($sValueInObject, -30);
$this->assertStringContainsString(' -truncated', $sLastCharsOfValueInObject, 'Should end with "truncated" comment');
}
@@ -1453,6 +1456,61 @@ class DBObjectTest extends ItopDataTestCase
$this->assertEquals($sResult, $oOrganisation->Get('name'), 'SetTrim must limit string to 255 characters');
}
/**
* Check that DBObject::SetTrim doesn't cut through multibytes characters
*
* @covers DBObject::SetTrim
* @dataProvider SetTrimAttributeTextProvider
*/
public function testSetTrimOnAttributeTextKeepsUtf8Validity(string $sChar, int $iRepeatCount, bool $bExpectExactByteFill): void
{
$oTicket = MetaModel::NewObject('UserRequest', [
'ref' => 'Test Ticket',
'title' => 'Create OK',
'description' => 'Create OK',
'caller_id' => 15,
'org_id' => 3,
]);
$sValueToSet = str_repeat($sChar, $iRepeatCount);
$oTicket->SetTrim('pending_reason', $sValueToSet);
$sValueInObject = $oTicket->Get('pending_reason');
$oAttDef = MetaModel::GetAttributeDef('UserRequest', 'pending_reason');
$iAttrMaxSize = $oAttDef->GetMaxSize();
$iOriginalCharLength = mb_strlen($sValueToSet);
$sMessage = " -truncated ($iOriginalCharLength chars)";
$this->assertStringEndsWith($sMessage, $sValueInObject, 'Trimmed value should keep the expected truncation suffix.');
$this->assertTrue(mb_check_encoding($sValueInObject, 'UTF-8'), 'Trimmed value should stay valid UTF-8.');
$this->assertLessThanOrEqual($iAttrMaxSize, strlen($sValueInObject), 'Trimmed value should never exceed attribute byte max size.');
if ($bExpectExactByteFill) {
$this->assertSame($iAttrMaxSize, strlen($sValueInObject), 'When byte cut lands on a character boundary, SetTrim should use all available bytes.');
}
}
public function SetTrimAttributeTextProvider()
{
return [
// 2-byte UTF-8 chars: truncation payload size is byte-aligned and should fill the max size exactly.
'pending_reason 2-byte chars on byte boundary' => ["\xC3\xA9", 32768, true],
// 4-byte UTF-8 chars: truncation payload size is not byte-aligned and must backtrack to valid UTF-8.
'pending_reason 4-byte chars with mid-character byte cut' => ['💃', 16385, false],
];
}
/**
* @covers DBObject::SetTrim
*/
public function testSetTrimOnNonStringAttributeDoesNotTrim()
{
$oTicket = MetaModel::NewObject(UserRequest::class);
$oTicket->SetTrim('caller_id', '15');
$this->assertEquals(15, $oTicket->Get('caller_id'), 'SetTrim should keep non-string attributes untouched before regular Set conversion');
}
/**
* @covers DBObject::SetComputedDate
* @return void

View File

@@ -37,6 +37,7 @@ use Dict;
use MetaModel;
use UserLocal;
use UserRights;
use UserRightsProfile;
use utils;
/**
@@ -155,6 +156,129 @@ class UserRightsTest extends ItopDataTestCase
return $oUser;
}
/**
* Ensure profiles are obtained with persisted user when profiles list has NOT been changed.
* @see N°9723 - IsActionAllowed should work with non instantiated users objects
* @covers UserRightsProfile::ListProfiles
*/
public function testListProfilesUsesPersistedProfilesWhenOnlyNonProfileChangesExist(): void
{
// Create a user with a set of profiles and save it
$oUserInDataBase = $this->GivenUserWithProfiles('test1', [
self::$aURP_Profiles['Configuration Manager'],
self::$aURP_Profiles['Support Agent'],
self::$aURP_Profiles['Service Manager'],
]);
$oUserInDataBase->DBInsert();
// User to check profiles (give the persisted user id)
$oUser = new class ($oUserInDataBase) {
private $oUserInDataBase;
public function __construct($oUserInDataBase)
{
$this->oUserInDataBase = $oUserInDataBase;
}
public function ListChanges(): array
{
return ['login' => 'changed'];
}
public function GetKey(): int
{
return $this->oUserInDataBase->GetKey();
}
public function Get(string $sAttCode)
{
if ($sAttCode === 'profile_list') {
throw new \LogicException('profile_list should not be read when it is not part of ListChanges');
}
return null;
}
};
// List the profiles (persisted)
$oUserRights = new UserRightsProfile();
$aProfiles = $oUserRights->ListProfiles($oUser);
// Asserts
$this->assertCount(3, $aProfiles);
$this->assertArrayHasKey(3, $aProfiles);
$this->assertArrayHasKey(5, $aProfiles);
$this->assertArrayHasKey(10, $aProfiles);
}
/**
* Ensure profiles are obtained with object in memory when profiles list has been changed.
* @see N°9723 - IsActionAllowed should work with non instantiated users objects
* @covers UserRightsProfile::ListProfiles
*/
public function testListProfilesUsesInMemoryProfilesWhenProfileListChanged(): void
{
// First profile
$oUserProfile1 = new class () {
public function Get(string $sAttCode)
{
if ($sAttCode === 'profileid') {
return 3;
}
if ($sAttCode === 'profileid_friendlyname') {
return 'Configuration Manager';
}
return null;
}
};
// Second profile
$oUserProfile2 = new class () {
public function Get(string $sAttCode)
{
if ($sAttCode === 'profileid') {
return 12;
}
if ($sAttCode === 'profileid_friendlyname') {
return 'Portal power user';
}
return null;
}
};
// User in memory with first and second profile
$oUser = new class ($oUserProfile1, $oUserProfile2) {
private $aProfileList;
public function __construct($oUserProfile1, $oUserProfile2)
{
$this->aProfileList = [$oUserProfile1, $oUserProfile2];
}
public function ListChanges(): array
{
return ['profile_list' => true, 'login' => 'changed'];
}
public function Get(string $sAttCode)
{
if ($sAttCode === 'profile_list') {
return $this->aProfileList;
}
return null;
}
};
// List the profiles (memory)
$oUserRights = new UserRightsProfile();
$aProfiles = $oUserRights->ListProfiles($oUser);
// Asserts
$this->assertSame([
3 => 'Configuration Manager',
12 => 'Portal power user',
], $aProfiles);
}
public function testIsLoggedIn()
{
$this->assertFalse(UserRights::IsLoggedIn());

View File

@@ -163,6 +163,58 @@ class DataCleanupServiceTest extends \AbstractCleanup
$this->AssertSummaryEquals($aExpected, $aRes);
}
/**
* N°9831
*
* A nullable external key configured as DEL_MANUAL must NOT be treated as an issue: it is simply
* reset (set to null), exactly like core DBObject::MakeDeletionPlan does. DEL_MANUAL only blocks
* for mandatory keys. This mirrors the real "Hypervisor.farm_id -> Farm" case (nullable + DEL_MANUAL).
*
* Regression guard for the ordering of the IsNullAllowed()/DEL_MANUAL checks in RecursiveDeletion:
* reverting that change would make ExecuteCleanup throw here instead of resetting the object.
*/
public function testExecuteCleanup_NullableManualShouldResetAndNotThrow()
{
$this->GivenDFRTreeInDB(<<<EOF
DFRToRemoveLeaf_1 <- DFRManualNullable_1
EOF);
$aClasses = [ 'DFRToRemoveLeaf' ];
$oService = new DataCleanupService();
// Must NOT throw: the nullable external key is reset, not reported as an issue
$aRes = $oService->ExecuteCleanup($aClasses);
$aExpected = [
['DFRManualNullable', 1, 0 ],
['DFRToRemoveLeaf', 0, 1 ],
];
$this->AssertSummaryEquals($aExpected, $aRes);
}
/**
* N°9831
*
* Summary counterpart of the test above: a nullable DEL_MANUAL external key is reported as an
* update (reset), with an issue count of 0. Reverting the RecursiveDeletion change would report
* it as an issue instead.
*/
public function testGetCleanupSummary_NullableManualShouldBeUpdateNotIssue()
{
$this->GivenDFRTreeInDB(<<<EOF
DFRToRemoveLeaf_1 <- DFRManualNullable_1
EOF);
$aClasses = [ 'DFRToRemoveLeaf' ];
$oService = new DataCleanupService();
$aRes = $oService->GetCleanupSummary($aClasses);
$aExpected = [
['DFRManualNullable', 1, 0, 0 ],
['DFRToRemoveLeaf', 0, 1, 0 ],
];
$this->AssertSummaryEquals($aExpected, $aRes);
}
private function AssertSummaryEquals(array $expected, $actual, $sMessage = '')
{
$aExpected = [];

View File

@@ -9,7 +9,6 @@ namespace Combodo\iTop\Test\UnitTest\Module\DataFeatureRemoval;
use Combodo\iTop\DataFeatureRemoval\Entity\DataCleanupSummaryEntity;
use Combodo\iTop\DataFeatureRemoval\Service\StaticDeletionPlan;
use MetaModel;
require_once __DIR__."/AbstractCleanup.php";
class StaticDeletionPlanTest extends \AbstractCleanup
@@ -150,6 +149,66 @@ EOF);
$this->AssertSummaryEquals($aExpected, $aRes);
}
/**
* N°9831
*
* A DEL_MANUAL issue must still be reported when the referencing class (DFRManual) is itself part
* of the classes to remove AND is processed AFTER the class it points to (DFRToRemoveLeaf).
*
* This mirrors the real "VirtualMachine -> VirtualHost" case: the setup audit sorts the removed
* classes alphabetically and drops abstract classes, so a referencing class such as VirtualMachine
* ends up processed after its concrete targets (Farm/Hypervisor/Cloud). Here DFRToRemove is abstract
* and DFRToRemoveLeaf is its concrete child, while DFRManual points to DFRToRemove with a mandatory
* DEL_MANUAL external key.
*
* Regression: the plan entity accumulating the issue used to be overwritten when the referencing
* class was processed as a top-level class, silently dropping the issue (no blocking message on the
* summary page, then a crash at execution time).
*/
public function testGetCleanupSummary_IssueKeptWhenReferencingClassListedAfterTarget(): void
{
$this->GivenDFRObjectsInDB(<<<EOF
create DFRToRemoveLeaf (name = DFRToRemoveLeaf_1)
create DFRManual (name = DFRManual_1, extkey_id = DFRToRemoveLeaf_1)
EOF);
// Target listed BEFORE the referencing class, as produced by the alphabetically sorted audit
$aClasses = ['DFRToRemoveLeaf', 'DFRManual'];
$oService = new StaticDeletionPlan();
$aRes = $oService->GetCleanupSummary($aClasses);
$aExpected = [
'DFRToRemoveLeaf' => ['iDeleteCount' => 1],
'DFRManual' => ['iDeleteCount' => 1, 'iIssueCount' => 1],
];
$this->AssertSummaryEquals($aExpected, $aRes);
}
/**
* N°9831
*
* Control case: the same issue must also be reported when the referencing class is listed BEFORE
* its target (this order already worked, e.g. "Enclosure -> Rack"). Together with the test above,
* this locks in that issue detection is independent of the processing order.
*/
public function testGetCleanupSummary_IssueKeptWhenReferencingClassListedBeforeTarget(): void
{
$this->GivenDFRObjectsInDB(<<<EOF
create DFRToRemoveLeaf (name = DFRToRemoveLeaf_1)
create DFRManual (name = DFRManual_1, extkey_id = DFRToRemoveLeaf_1)
EOF);
$aClasses = ['DFRManual', 'DFRToRemoveLeaf'];
$oService = new StaticDeletionPlan();
$aRes = $oService->GetCleanupSummary($aClasses);
$aExpected = [
'DFRToRemoveLeaf' => ['iDeleteCount' => 1],
'DFRManual' => ['iDeleteCount' => 1, 'iIssueCount' => 1],
];
$this->AssertSummaryEquals($aExpected, $aRes);
}
public function testCircularRefsShouldNotRunInfinitely()
{
$this->GivenDFRObjectsInDB(<<<EOF

View File

@@ -319,6 +319,58 @@
</presentation>
<parent>cmdbAbstractObject</parent>
</class>
<class id="DFRManualNullable" _created_in="itop-structure" _delta="define">
<properties>
<category>bizmodel,searchable</category>
<abstract>false</abstract>
<db_table>dfrmanualnullable</db_table>
<naming>
<attributes/>
</naming>
<reconciliation>
<attributes/>
</reconciliation>
</properties>
<fields>
<field id="name" xsi:type="AttributeString">
<sql>name</sql>
<default_value/>
<is_null_allowed>true</is_null_allowed>
<validation_pattern/>
<dependencies/>
<tracking_level>all</tracking_level>
</field>
<field id="extkey_id" xsi:type="AttributeExternalKey">
<sql>extkey_id</sql>
<filter/>
<dependencies/>
<is_null_allowed>true</is_null_allowed>
<target_class>DFRToRemove</target_class>
<on_target_delete>DEL_MANUAL</on_target_delete>
<tracking_level>all</tracking_level>
</field>
</fields>
<methods/>
<presentation>
<list>
<items/>
</list>
<search>
<items/>
</search>
<details>
<items>
<item id="name">
<rank>10</rank>
</item>
<item id="extkey_id">
<rank>20</rank>
</item>
</items>
</details>
</presentation>
<parent>cmdbAbstractObject</parent>
</class>
<class id="DFRLeafNotToRemove" _created_in="itop-structure" _delta="define">
<properties>
<category>bizmodel,searchable</category>
@@ -718,6 +770,14 @@
<entry id="Class:DFRManual/Attribute:name+" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRManual/Attribute:extkey_id" _delta="define"><![CDATA[Dfrtoremove id]]></entry>
<entry id="Class:DFRManual/Attribute:extkey_id+" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRManualNullable/Name" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRManualNullable/ComplementaryName" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRManualNullable" _delta="define"><![CDATA[DFRManualNullable]]></entry>
<entry id="Class:DFRManualNullable+" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRManualNullable/Attribute:name" _delta="define"><![CDATA[Name]]></entry>
<entry id="Class:DFRManualNullable/Attribute:name+" _delta="define"><![CDATA[]]></entry>
<entry id="Class:DFRManualNullable/Attribute:extkey_id" _delta="define"><![CDATA[Dfrtoremove id]]></entry>
<entry id="Class:DFRManualNullable/Attribute:extkey_id+" _delta="define"><![CDATA[]]></entry>
</entries>
</dictionary>
</dictionaries>

View File

@@ -49,6 +49,7 @@ class WizStepModulesChoiceTest extends ItopTestCase
'installed' => false,
'disabled' => false,
'checked' => false,
'already_included' => false,
'dependency_issue' => false,
'mandatory' => false,
],
@@ -72,15 +73,13 @@ class WizStepModulesChoiceTest extends ItopTestCase
'installed' => false,
'disabled' => false,
'checked' => true,
'already_included' => false,
'dependency_issue' => false,
'mandatory' => false,
],
],
'A missing extension should be disabled and unchecked' => [
'aExtensionsOnDiskOrDb' => [
'itop-ext1' => [
'installed' => false,
],
],
'aWizardStepDefinition' => [
'extension_code' => 'itop-ext1',
@@ -96,15 +95,13 @@ class WizStepModulesChoiceTest extends ItopTestCase
'installed' => true,
'disabled' => true,
'checked' => false,
'already_included' => false,
'dependency_issue' => false,
'mandatory' => false,
],
],
'A missing extension should always be disabled and unchecked, even when mandatory' => [
'aExtensionsOnDiskOrDb' => [
'itop-ext1' => [
'installed' => false,
],
],
'aWizardStepDefinition' => [
'extension_code' => 'itop-ext1',
@@ -120,15 +117,13 @@ class WizStepModulesChoiceTest extends ItopTestCase
'installed' => true,
'disabled' => true,
'checked' => false,
'already_included' => false,
'dependency_issue' => false,
'mandatory' => true,
],
],
'A missing extension should always be disabled and unchecked, even when non-uninstallable' => [
'aExtensionsOnDiskOrDb' => [
'itop-ext1' => [
'installed' => false,
],
],
'aWizardStepDefinition' => [
'extension_code' => 'itop-ext1',
@@ -144,6 +139,7 @@ class WizStepModulesChoiceTest extends ItopTestCase
'installed' => true,
'disabled' => true,
'checked' => false,
'already_included' => false,
'dependency_issue' => false,
'mandatory' => true,
],
@@ -167,6 +163,7 @@ class WizStepModulesChoiceTest extends ItopTestCase
'installed' => true,
'disabled' => false,
'checked' => false,
'already_included' => false,
'dependency_issue' => false,
'mandatory' => false,
],
@@ -190,6 +187,7 @@ class WizStepModulesChoiceTest extends ItopTestCase
'installed' => true,
'disabled' => true,
'checked' => true,
'already_included' => false,
'dependency_issue' => false,
'mandatory' => false,
],
@@ -213,6 +211,7 @@ class WizStepModulesChoiceTest extends ItopTestCase
'installed' => true,
'disabled' => false,
'checked' => true,
'already_included' => false,
'dependency_issue' => false,
'mandatory' => false,
],
@@ -236,6 +235,7 @@ class WizStepModulesChoiceTest extends ItopTestCase
'installed' => false,
'disabled' => true,
'checked' => true,
'already_included' => false,
'dependency_issue' => false,
'mandatory' => true,
],
@@ -259,6 +259,7 @@ class WizStepModulesChoiceTest extends ItopTestCase
'installed' => false,
'disabled' => true,
'checked' => true,
'already_included' => false,
'dependency_issue' => false,
'mandatory' => true,
],
@@ -294,6 +295,7 @@ class WizStepModulesChoiceTest extends ItopTestCase
'installed' => false,
'disabled' => false,
'checked' => false,
'already_included' => false,
'dependency_issue' => false,
'mandatory' => false,
],
@@ -329,6 +331,7 @@ class WizStepModulesChoiceTest extends ItopTestCase
'installed' => false,
'disabled' => true,
'checked' => true,
'already_included' => false,
'dependency_issue' => false,
'mandatory' => false,
],
@@ -364,6 +367,7 @@ class WizStepModulesChoiceTest extends ItopTestCase
'installed' => true,
'disabled' => true,
'checked' => true,
'already_included' => false,
'dependency_issue' => false,
'mandatory' => false,
],
@@ -399,11 +403,12 @@ class WizStepModulesChoiceTest extends ItopTestCase
'installed' => true,
'disabled' => false,
'checked' => false,
'already_included' => false,
'dependency_issue' => false,
'mandatory' => false,
],
],
'A non installed extension with missing dependencies should be not checked and disabled' => [
'A non installed and non mandatory extension with missing dependencies and without force uninstall should be not checked and disabled' => [
'aExtensionsOnDiskOrDb' => [
'itop-ext1' => [
'installed' => false,
@@ -426,11 +431,12 @@ class WizStepModulesChoiceTest extends ItopTestCase
'installed' => false,
'disabled' => true,
'checked' => false,
'already_included' => false,
'dependency_issue' => true,
'mandatory' => false,
],
],
'An installed extension with missing dependencies should be not checked and disabled' => [
'An installed non mandatory extension with missing dependencies and without force uninstall should be not checked and disabled' => [
'aExtensionsOnDiskOrDb' => [
'itop-ext1' => [
'installed' => true,
@@ -453,10 +459,123 @@ class WizStepModulesChoiceTest extends ItopTestCase
'installed' => true,
'disabled' => true,
'checked' => false,
'already_included' => false,
'dependency_issue' => true,
'mandatory' => false,
],
],
'A non installed and non mandatory extension with missing dependencies and force uninstall should be not checked and enabled' => [
'aExtensionsOnDiskOrDb' => [
'itop-ext1' => [
'installed' => false,
'missing_dependencies' => [
'itop-ext1-1',
],
],
],
'aWizardStepDefinition' => [
'extension_code' => 'itop-ext1',
'mandatory' => false,
'uninstallable' => true,
'missing_dependencies' => true,
],
'bCurrentSelected' => false,
'bDisableUninstallChecks' => true,
'aExpectedFlags' => [
'uninstallable' => true,
'missing' => false,
'installed' => false,
'disabled' => false,
'checked' => false,
'already_included' => false,
'dependency_issue' => true,
'mandatory' => false,
],
],
'An installed non mandatory extension with missing dependencies and force uninstall should be not checked and enabled' => [
'aExtensionsOnDiskOrDb' => [
'itop-ext1' => [
'installed' => true,
'missing_dependencies' => [
'itop-ext1-1',
],
],
],
'aWizardStepDefinition' => [
'extension_code' => 'itop-ext1',
'mandatory' => false,
'uninstallable' => true,
'missing_dependencies' => true,
],
'bCurrentSelected' => false,
'bDisableUninstallChecks' => true,
'aExpectedFlags' => [
'uninstallable' => true,
'missing' => false,
'installed' => true,
'disabled' => false,
'checked' => false,
'already_included' => false,
'dependency_issue' => true,
'mandatory' => false,
],
],
'An installed mandatory extension with missing dependencies and without force uninstall should be checked and disabled' => [
'aExtensionsOnDiskOrDb' => [
'itop-ext1' => [
'installed' => true,
'missing_dependencies' => [
'itop-ext1-1',
],
],
],
'aWizardStepDefinition' => [
'extension_code' => 'itop-ext1',
'mandatory' => true,
'uninstallable' => true,
'missing_dependencies' => true,
],
'bCurrentSelected' => false,
'bDisableUninstallChecks' => true,
'aExpectedFlags' => [
'uninstallable' => true,
'missing' => false,
'installed' => true,
'disabled' => true,
'checked' => true,
'already_included' => false,
'dependency_issue' => true,
'mandatory' => true,
],
],
'An non installed mandatory extension with missing dependencies and without force uninstall should be checked and disabled' => [
'aExtensionsOnDiskOrDb' => [
'itop-ext1' => [
'installed' => false,
'missing_dependencies' => [
'itop-ext1-1',
],
],
],
'aWizardStepDefinition' => [
'extension_code' => 'itop-ext1',
'mandatory' => true,
'uninstallable' => true,
'missing_dependencies' => true,
],
'bCurrentSelected' => false,
'bDisableUninstallChecks' => true,
'aExpectedFlags' => [
'uninstallable' => true,
'missing' => false,
'installed' => false,
'disabled' => true,
'checked' => true,
'already_included' => false,
'dependency_issue' => true,
'mandatory' => true,
],
],
];
}

View File

@@ -79,6 +79,7 @@ class SetupAuditTest extends ItopCustomDatamodelTestCase
"Feature1Module1MyClass",
"FinalClassFeature2Module1MyClass",
"FinalClassFeature2Module1MyFinalClassFromLocation",
"TagSetFieldDataFor_FinalClassFeature2Module1MyFinalClassFromLocation__domains",
];
$this->assertEqualsCanonicalizing($expected, $oSetupAudit->GetRemovedClasses());

View File

@@ -64,6 +64,12 @@
<is_null_allowed>false</is_null_allowed>
<validation_pattern/>
</field>
<field id="domains" xsi:type="AttributeTagSet">
<sql>domains</sql>
<is_null_allowed>true</is_null_allowed>
<tracking_level>all</tracking_level>
<max_items>12</max_items>
</field>
</fields>
<methods/>
<presentation/>

View File

@@ -31,6 +31,12 @@
<default_value/>
<is_null_allowed>false</is_null_allowed>
</field>
<field id="domains" xsi:type="AttributeTagSet">
<sql>domains</sql>
<is_null_allowed>true</is_null_allowed>
<tracking_level>all</tracking_level>
<max_items>12</max_items>
</field>
</fields>
<methods/>
<presentation/>

View File

@@ -0,0 +1,55 @@
<?php
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
class SynchroExecutionTest extends ItopDataTestCase
{
private function InitAndGetTZ($sTZ = null): string
{
$sTZ = $sTZ ?? 'Europe/Paris';
MetaModel::GetConfig()->Set('timezone', $sTZ);
return $sTZ;
}
public function testGetDateMinusRetentionWithTimeChange()
{
$sTZ = $this->InitAndGetTZ();
$oObj = new SynchroExecution($this->createMock(SynchroDataSource::class));
$oDate = DateTime::createFromFormat('Y-m-d H:i:s', "2026-03-29 03:30:01", new DateTimeZone($sTZ));
$oObj->ExactlySubtractSeconds($oDate, 3600);
//03h30 minus 1hours => 03h minus 30mn => 03h
// => 03h with timechange => 2h
// => 02 minus 30mn => 01h30
$this->assertEquals("2026-03-29 01:30:01", $oDate->Format('Y-m-d H:i:s'));
}
public function testGetDateMinusRetentioLinuxTimeZero()
{
$oObj = new SynchroExecution($this->createMock(SynchroDataSource::class));
$oDate = new DateTime('1970-01-01');
$oObj->ExactlySubtractSeconds($oDate, 3600);
$this->assertEquals("1969-12-31 23:00:00", $oDate->Format('Y-m-d H:i:s'));
}
public function testGetDateMinusRetentionWithTimeChangeAndUTC()
{
$sTZ = $this->InitAndGetTZ('UTC');
$oObj = new SynchroExecution($this->createMock(SynchroDataSource::class));
$oDate = DateTime::createFromFormat('Y-m-d H:i:s', "2026-03-29 03:30:01", new DateTimeZone($sTZ));
$oObj->ExactlySubtractSeconds($oDate, 3600);
$this->assertEquals("2026-03-29 02:30:01", $oDate->Format('Y-m-d H:i:s'));
}
public function testGetDateMinusRetention()
{
$sTZ = $this->InitAndGetTZ();
$oObj = new SynchroExecution($this->createMock(SynchroDataSource::class));
$oDate = DateTime::createFromFormat('Y-m-d H:i:s', "2026-04-01 03:30:01", new DateTimeZone($sTZ));
$oObj->ExactlySubtractSeconds($oDate, 3600);
$this->assertEquals("2026-04-01 02:30:01", $oDate->Format('Y-m-d H:i:s'));
}
}

View File

@@ -277,25 +277,13 @@ abstract class WebServicesBase
$oLog->Set('userinfo', UserRights::GetUser());
$oLog->Set('verb', $sVerb);
$oLog->Set('result', $oRes->IsOk());
$this->TrimAndSetValue($oLog, 'log_info', (string)$oRes->GetInfoAsText());
$this->TrimAndSetValue($oLog, 'log_warning', (string)$oRes->GetWarningsAsText());
$this->TrimAndSetValue($oLog, 'log_error', (string)$oRes->GetErrorsAsText());
$this->TrimAndSetValue($oLog, 'data', (string)$oRes->GetReturnedDataAsText());
$oLog->SetTrim('log_info', (string)$oRes->GetInfoAsText());
$oLog->SetTrim('log_warning', (string)$oRes->GetWarningsAsText());
$oLog->SetTrim('log_error', (string)$oRes->GetErrorsAsText());
$oLog->SetTrim('data', (string)$oRes->GetReturnedDataAsText());
$oLog->DBInsertNoReload();
}
protected function TrimAndSetValue($oLog, $sAttCode, $sValue)
{
$oAttDef = MetaModel::GetAttributeDef(get_class($oLog), $sAttCode);
if (is_object($oAttDef)) {
$iMaxSize = $oAttDef->GetMaxSize();
if ($iMaxSize && (mb_strlen($sValue) > $iMaxSize)) {
$sValue = mb_substr($sValue, 0, $iMaxSize);
}
$oLog->Set($sAttCode, $sValue);
}
}
/**
* Helper to set a scalar attribute
*