From 8e0d6d1f00a233b1ca46b3010ba69d0250173ba6 Mon Sep 17 00:00:00 2001 From: Eric Espie Date: Mon, 20 Nov 2023 09:22:41 +0100 Subject: [PATCH] =?UTF-8?q?N=C2=B06228=20-=20Refactor=20after=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/cmdbabstract.class.inc.php | 28 +- core/attributedef.class.inc.php | 4 +- core/dbobject.class.php | 216 +++++----- core/metamodel.class.php | 4 +- core/trigger.class.inc.php | 32 ++ core/userrights.class.inc.php | 36 -- .../src/BaseTestCase/ItopDataTestCase.php | 16 + .../core/AttributeDefinitionTest.php | 7 +- .../DBObject/CheckToWritePropagationTest.php | 388 +++--------------- .../core/DBObject/CustomCheckToWriteTest.php | 64 +++ .../unitary-tests/core/MetaModelTest.php | 23 ++ 11 files changed, 302 insertions(+), 516 deletions(-) create mode 100644 tests/php-unit-tests/unitary-tests/core/DBObject/CustomCheckToWriteTest.php diff --git a/application/cmdbabstract.class.inc.php b/application/cmdbabstract.class.inc.php index eb52af98e..362b71d23 100644 --- a/application/cmdbabstract.class.inc.php +++ b/application/cmdbabstract.class.inc.php @@ -4544,7 +4544,7 @@ HTML; return $res; } - public function PostInsertActions(): void + protected function PostInsertActions(): void { parent::PostInsertActions(); @@ -4610,7 +4610,7 @@ HTML; return $res; } - public function PostUpdateActions(array $aChanges): void + protected function PostUpdateActions(array $aChanges): void { parent::PostUpdateActions($aChanges); @@ -5981,33 +5981,27 @@ JS /** @var AttributeExternalKey $oAttDef */ $oAttDef = MetaModel::GetAttributeDef($sClass, $sExternalKeyAttCode); - if (false === $this->DoesRemoteObjectHavePhpComputation($oAttDef)) { + if (false === $this->DoesTargetObjectHavePhpComputation($oAttDef)) { continue; } - $sRemoteObjectId = $this->Get($sExternalKeyAttCode); - if ($sRemoteObjectId > 0) { - self::RegisterObjectAwaitingEventDbLinksChanged($oAttDef->GetTargetClass(), $sRemoteObjectId); + $sTargetObjectId = $this->Get($sExternalKeyAttCode); + if ($sTargetObjectId > 0) { + self::RegisterObjectAwaitingEventDbLinksChanged($oAttDef->GetTargetClass(), $sTargetObjectId); } - $sPreviousRemoteObjectId = $aPreviousValues[$sExternalKeyAttCode] ?? 0; - if ($sPreviousRemoteObjectId > 0) { - self::RegisterObjectAwaitingEventDbLinksChanged($oAttDef->GetTargetClass(), $sPreviousRemoteObjectId); + $sPreviousTargetObjectId = $aPreviousValues[$sExternalKeyAttCode] ?? 0; + if ($sPreviousTargetObjectId > 0) { + self::RegisterObjectAwaitingEventDbLinksChanged($oAttDef->GetTargetClass(), $sPreviousTargetObjectId); } } } - private function DoesRemoteObjectHavePhpComputation(AttributeExternalKey $oAttDef): bool + private function DoesTargetObjectHavePhpComputation(AttributeExternalKey $oAttDef): bool { - $sRemoteObjectClass = $oAttDef->GetTargetClass(); - - if (utils::IsNullOrEmptyString($sRemoteObjectClass)) { - return false; - } - /** @var AttributeLinkedSet $oAttDefMirrorLink */ $oAttDefMirrorLink = $oAttDef->GetMirrorLinkAttribute(); - if (is_null($oAttDefMirrorLink) || false === $oAttDefMirrorLink->GetHasComputation()){ + if (is_null($oAttDefMirrorLink) || false === $oAttDefMirrorLink->HasPHPComputation()){ return false; } diff --git a/core/attributedef.class.inc.php b/core/attributedef.class.inc.php index ed3495d78..c30a8389b 100644 --- a/core/attributedef.class.inc.php +++ b/core/attributedef.class.inc.php @@ -1739,7 +1739,7 @@ class AttributeLinkedSet extends AttributeDefinition * @return bool true if Attribute has constraints * @since 3.1.0 N°6228 */ - public function GetHasConstraint() + public function HasPHPConstraint(): bool { return $this->GetOptional('with_php_constraint', false); } @@ -1748,7 +1748,7 @@ class AttributeLinkedSet extends AttributeDefinition * @return bool true if Attribute has computation (DB_LINKS_CHANGED event propagation, `with_php_computation` attribute xml property), false otherwise * @since 3.1.1 3.2.0 N°6228 */ - public function GetHasComputation() + public function HasPHPComputation(): bool { return $this->GetOptional('with_php_computation', false); } diff --git a/core/dbobject.class.php b/core/dbobject.class.php index 412d7b88d..8bb13c0fb 100644 --- a/core/dbobject.class.php +++ b/core/dbobject.class.php @@ -2463,11 +2463,11 @@ abstract class DBObject implements iDisplay } /** - * Trigger onObjectUpdate on the remote object when an object pointed by a LinkSet is modified, added or removed + * Trigger onObjectUpdate on the target object when an object pointed by a LinkSet is modified, added or removed * * @since 3.1.1 3.2.0 N°6531 method creation */ - final protected function TriggerOnRemoteObjectUpdate(): void + final protected function ActivateOnObjectUpdateTriggersForTargetObjects(): void { $aPreviousValues = $this->ListPreviousValuesForUpdatedAttributes(); @@ -2476,72 +2476,42 @@ abstract class DBObject implements iDisplay /** @var AttributeExternalKey $oExtKeyWithMirrorLinkAttDef */ $oExtKeyWithMirrorLinkAttDef = MetaModel::GetAttributeDef(get_class($this), $sExtKeyWithMirrorLinkAttCode); - $oRemoteObject = $this->GetRemoteObject($oExtKeyWithMirrorLinkAttDef, $this->Get($sExtKeyWithMirrorLinkAttCode)); - if (is_null($oRemoteObject)) { - continue; - } - /** @var AttributeLinkedSet $oAttDefMirrorLink */ $oAttDefMirrorLink = $oExtKeyWithMirrorLinkAttDef->GetMirrorLinkAttribute(); if (is_null($oAttDefMirrorLink)) { + // No LinkSet pointing to me continue; } $sAttCodeMirrorLink = $oAttDefMirrorLink->GetCode(); + $sTargetObjectClass = $oExtKeyWithMirrorLinkAttDef->GetTargetClass(); if (array_key_exists($sExtKeyWithMirrorLinkAttCode, $aPreviousValues)) { - // need to update remote old + new - $sPreviousRemoteObjectKey = $aPreviousValues[$sExtKeyWithMirrorLinkAttCode]; - $oPreviousRemoteObject = $this->GetRemoteObject($oExtKeyWithMirrorLinkAttDef, $sPreviousRemoteObjectKey); - if (false === is_null($oPreviousRemoteObject)) { - $this->TriggerRemoteObject($oPreviousRemoteObject, $sAttCodeMirrorLink); - } + // need to update old target also + $sPreviousTargetObjectKey = $aPreviousValues[$sExtKeyWithMirrorLinkAttCode]; + $oPreviousTargetObject = static::GetObjectIfNotInCRUDStack($sTargetObjectClass, $sPreviousTargetObjectKey); + $this->ActivateOnObjectUpdateTriggers($oPreviousTargetObject, [$sAttCodeMirrorLink]); } + // we need to update remote with current lnk instance - $this->TriggerRemoteObject($oRemoteObject, $sAttCodeMirrorLink); + $oTargetObject = static::GetObjectIfNotInCRUDStack($sTargetObjectClass, $this->Get($sExtKeyWithMirrorLinkAttCode)); + $this->ActivateOnObjectUpdateTriggers($oTargetObject, [$sAttCodeMirrorLink]); } } - private function TriggerRemoteObject(DBObject $oRemoteObject, string $sAttCodeMirrorLink): void + final static protected function GetObjectIfNotInCRUDStack($sClass, $sKey) { - // indicates that $sAttCodeMirrorLink has been modified for the trigger - $oRemoteObject->m_aPreviousValuesForUpdatedAttributes[$sAttCodeMirrorLink] = $oRemoteObject->Get($sAttCodeMirrorLink); - $this->TriggerOnObjectUpdate($oRemoteObject); - } - - private function GetRemoteObject(AttributeExternalKey $oAttDef, $sRemoteObjectKey, bool $bWithConstraintProperty = false) - { - $sRemoteObjectClass = $oAttDef->GetTargetClass(); - - /** @noinspection NotOptimalIfConditionsInspection */ - /** @noinspection TypeUnsafeComparisonInspection */ - if (utils::IsNullOrEmptyString($sRemoteObjectClass) - || utils::IsNullOrEmptyString($sRemoteObjectKey) - || ($sRemoteObjectKey == 0) // non-strict comparison as we might have bad surprises - ) { + if (DBObject::IsObjectCurrentlyInCrud($sClass, $sKey)) { return null; } - /** @var AttributeLinkedSet $oAttDefMirrorLink */ - $oAttDefMirrorLink = $oAttDef->GetMirrorLinkAttribute(); - if (is_null($oAttDefMirrorLink)) { - return null; - } - - if ($bWithConstraintProperty && (false === $oAttDefMirrorLink->GetHasConstraint())) { - return null; - } - - if (DBObject::IsObjectCurrentlyInCrud($sRemoteObjectClass, $sRemoteObjectKey)) { - return null; - } - - return MetaModel::GetObject($sRemoteObjectClass, $sRemoteObjectKey, false); + return MetaModel::GetObject($sClass, $sKey, false); } /** + * Cascade CheckToWrite to Target Objects With LinkSet Pointing To Me * @since 3.1.1 3.2.0 N°6228 method creation */ - final protected function CheckPhpConstraint(bool $bIsCheckToDelete = false): void + final protected function CheckToWriteForTargetObjects(bool $bIsCheckToDelete = false): void { $aChanges = $this->ListChanges(); @@ -2550,58 +2520,58 @@ abstract class DBObject implements iDisplay /** @var AttributeExternalKey $oExtKeyWithMirrorLinkAttDef */ $oExtKeyWithMirrorLinkAttDef = MetaModel::GetAttributeDef(get_class($this), $sExtKeyWithMirrorLinkAttCode); - $oRemoteObject = $this->GetRemoteObject($oExtKeyWithMirrorLinkAttDef, $this->Get($sExtKeyWithMirrorLinkAttCode), true); - if (is_null($oRemoteObject)) { - continue; - } - /** @var AttributeLinkedSet $oAttDefMirrorLink */ $oAttDefMirrorLink = $oExtKeyWithMirrorLinkAttDef->GetMirrorLinkAttribute(); - if (is_null($oAttDefMirrorLink)) { + if (is_null($oAttDefMirrorLink) || (false === $oAttDefMirrorLink->HasPHPConstraint())) { continue; } $sAttCodeMirrorLink = $oAttDefMirrorLink->GetCode(); + $sTargetObjectClass = $oExtKeyWithMirrorLinkAttDef->GetTargetClass(); + + $oTargetObject = static::GetObjectIfNotInCRUDStack($sTargetObjectClass, $this->Get($sExtKeyWithMirrorLinkAttCode)); if ($this->IsNew()) { - $this->CheckRemotePhpConstraintOnObject('add', $oRemoteObject, $sAttCodeMirrorLink, false); + $this->CheckToWriteForSingleTargetObject_Internal('add', $oTargetObject, $sAttCodeMirrorLink, false); } else if ($bIsCheckToDelete) { - $this->CheckRemotePhpConstraintOnObject('remove', $oRemoteObject, $sAttCodeMirrorLink, true); + $this->CheckToWriteForSingleTargetObject_Internal('remove', $oTargetObject, $sAttCodeMirrorLink, true); } else { if (array_key_exists($sExtKeyWithMirrorLinkAttCode, $aChanges)) { // need to update remote old + new $aPreviousValues = $this->ListPreviousValuesForUpdatedAttributes(); - $sPreviousRemoteObjectKey = $aPreviousValues[$sExtKeyWithMirrorLinkAttCode]; - $oPreviousRemoteObject = $this->GetRemoteObject($oExtKeyWithMirrorLinkAttDef, $sPreviousRemoteObjectKey, true); - if (false === is_null($oPreviousRemoteObject)) { - $this->CheckRemotePhpConstraintOnObject('remove', $oPreviousRemoteObject, $sAttCodeMirrorLink, false); - } - $this->CheckRemotePhpConstraintOnObject('add', $oRemoteObject, $sAttCodeMirrorLink, false); + $sPreviousTargetObjectKey = $aPreviousValues[$sExtKeyWithMirrorLinkAttCode]; + $oPreviousTargetObject = static::GetObjectIfNotInCRUDStack($sTargetObjectClass, $sPreviousTargetObjectKey); + $this->CheckToWriteForSingleTargetObject_Internal('remove', $oPreviousTargetObject, $sAttCodeMirrorLink, false); + $this->CheckToWriteForSingleTargetObject_Internal('add', $oTargetObject, $sAttCodeMirrorLink, false); } else { - $this->CheckRemotePhpConstraintOnObject('modify', $oRemoteObject, $sAttCodeMirrorLink, false); // we need to update remote with current lnk instance + $this->CheckToWriteForSingleTargetObject_Internal('modify', $oTargetObject, $sAttCodeMirrorLink, false); // we need to update remote with current lnk instance } } } } - private function CheckRemotePhpConstraintOnObject(string $sAction, DBObject $oRemoteObject, string $sAttCodeMirrorLink, bool $bIsCheckToDelete): void + private function CheckToWriteForSingleTargetObject_Internal(string $sAction, ?DBObject $oTargetObject, string $sAttCodeMirrorLink, bool $bIsCheckToDelete): void { - $this->LogCRUDDebug(__METHOD__, "action: $sAction ".get_class($oRemoteObject).'::'.$oRemoteObject->GetKey()." ($sAttCodeMirrorLink)"); + if (is_null($oTargetObject)) { + return; + } - /** @var \ormLinkSet $oRemoteValue */ - $oRemoteValue = $oRemoteObject->Get($sAttCodeMirrorLink); + $this->LogCRUDDebug(__METHOD__, "action: $sAction ".get_class($oTargetObject).'::'.$oTargetObject->GetKey()." ($sAttCodeMirrorLink)"); + + /** @var \ormLinkSet $oTargetValue */ + $oTargetValue = $oTargetObject->Get($sAttCodeMirrorLink); switch ($sAction) { case 'add': - $oRemoteValue->AddItem($this); + $oTargetValue->AddItem($this); break; case 'remove': - $oRemoteValue->RemoveItem($this->GetKey()); + $oTargetValue->RemoveItem($this->GetKey()); break; case 'modify': - $oRemoteValue->ModifyItem($this); + $oTargetValue->ModifyItem($this); break; } - $oRemoteObject->Set($sAttCodeMirrorLink, $oRemoteValue); - [$bCheckStatus, $aCheckIssues, $bSecurityIssue] = $oRemoteObject->CheckToWrite(); + $oTargetObject->Set($sAttCodeMirrorLink, $oTargetValue); + [$bCheckStatus, $aCheckIssues, $bSecurityIssue] = $oTargetObject->CheckToWrite(); if (false === $bCheckStatus) { if ($bIsCheckToDelete) { $this->m_aDeleteIssues = array_merge($this->m_aDeleteIssues ?? [], $aCheckIssues); @@ -2610,9 +2580,9 @@ abstract class DBObject implements iDisplay } $this->m_bSecurityIssue = $this->m_bSecurityIssue || $bSecurityIssue; } - $aRemoteCheckWarnings = $oRemoteObject->GetCheckWarnings(); - if (is_array($aRemoteCheckWarnings)) { - $this->m_aCheckWarnings = array_merge($this->m_aCheckWarnings ?? [], $aRemoteCheckWarnings); + $aTargetCheckWarnings = $oTargetObject->GetCheckWarnings(); + if (is_array($aTargetCheckWarnings)) { + $this->m_aCheckWarnings = array_merge($this->m_aCheckWarnings ?? [], $aTargetCheckWarnings); } } @@ -2657,7 +2627,7 @@ abstract class DBObject implements iDisplay $this->DoCheckToWrite(); $oKPI->ComputeStatsForExtension($this, 'DoCheckToWrite'); - $this->CheckPhpConstraint(); + $this->CheckToWriteForTargetObjects(); if (count($this->m_aCheckIssues) == 0) { @@ -2754,7 +2724,7 @@ abstract class DBObject implements iDisplay * * an array of displayable error is added in {@see DBObject::$m_aDeleteIssues} * - * @internal + * @internal * * @param \DeletionPlan $oDeletionPlan * @@ -2819,8 +2789,14 @@ abstract class DBObject implements iDisplay */ public function CheckToDelete(&$oDeletionPlan) { - $this->MakeDeletionPlan($oDeletionPlan); - $oDeletionPlan->ComputeResults(); + $this->AddCurrentObjectInCrudStack('DELETE'); + try { + $this->MakeDeletionPlan($oDeletionPlan); + $oDeletionPlan->ComputeResults(); + } + finally { + $this->RemoveCurrentObjectInCrudStack(); + } return (!$oDeletionPlan->FoundStopper()); } @@ -2872,7 +2848,7 @@ abstract class DBObject implements iDisplay { // The value is a scalar, the comparison must be 100% strict if($this->m_aOrigValues[$sAtt] !== $proposedValue) - { + { //echo "$sAtt:
\n";
 					//var_dump($this->m_aOrigValues[$sAtt]);
 					//var_dump($proposedValue);
@@ -2994,7 +2970,7 @@ abstract class DBObject implements iDisplay
 
 	/**
 	 * Used only by insert, Meant to be overloaded
-     * 
+     *
      * @overwritable-hook You can extend this method in order to provide your own logic.
 	 */
 	protected function OnObjectKeyReady()
@@ -3102,7 +3078,7 @@ abstract class DBObject implements iDisplay
 		// fields in first array, values in the second
 		$aFieldsToWrite = array();
 		$aValuesToWrite = array();
-		
+
 		if (!empty($this->m_iKey) && ($this->m_iKey >= 0))
 		{
 			// Add it to the list of fields to write
@@ -3111,7 +3087,7 @@ abstract class DBObject implements iDisplay
 		}
 
 		$aHierarchicalKeys = array();
-		
+
 		foreach(MetaModel::ListAttributeDefs($sTableClass) as $sAttCode=>$oAttDef) {
 			// Skip this attribute if not defined in this table
 			if ((!MetaModel::IsAttributeOrigin($sTableClass, $sAttCode) && !$oAttDef->CopyOnAllTables())
@@ -3121,7 +3097,7 @@ abstract class DBObject implements iDisplay
 			$aAttColumns = $oAttDef->GetSQLValues($this->m_aCurrValues[$sAttCode]);
 			foreach($aAttColumns as $sColumn => $sValue)
 			{
-				$aFieldsToWrite[] = "`$sColumn`"; 
+				$aFieldsToWrite[] = "`$sColumn`";
 				$aValuesToWrite[] = CMDBSource::Quote($sValue);
 			}
 			if ($oAttDef->IsHierarchicalKey())
@@ -3145,7 +3121,7 @@ abstract class DBObject implements iDisplay
 					self::$m_aBulkInsertCols[$sClass][$sTable] = implode(', ', $aFieldsToWrite);
 				}
 				self::$m_aBulkInsertItems[$sClass][$sTable][] = '('.implode (', ', $aValuesToWrite).')';
-				
+
 				$iNewKey = 999999; // TODO - compute next id....
 			}
 			else
@@ -3230,7 +3206,7 @@ abstract class DBObject implements iDisplay
 		// fields in first array, values in the second
 		$aFieldsToWrite = array();
 		$aValuesToWrite = array();
-		
+
 		if (!empty($this->m_iKey) && ($this->m_iKey >= 0))
 		{
 			// Add it to the list of fields to write
@@ -3265,7 +3241,7 @@ abstract class DBObject implements iDisplay
 			$aAttColumns = $oAttDef->GetSQLValues($value);
 			foreach($aAttColumns as $sColumn => $sValue)
 			{
-				$aFieldsToWrite[] = "`$sColumn`"; 
+				$aFieldsToWrite[] = "`$sColumn`";
 				$aValuesToWrite[] = CMDBSource::Quote($sValue);
 			}
 			if ($oAttDef->IsHierarchicalKey())
@@ -3496,7 +3472,7 @@ abstract class DBObject implements iDisplay
 	 * @throws \MySQLException
 	 * @throws \OQLException
 	 */
-	public function PostInsertActions(): void
+	protected function PostInsertActions(): void
 	{
 		$this->FireEventAfterWrite([], true);
 		$oKPI = new ExecutionKPI();
@@ -3522,7 +3498,7 @@ abstract class DBObject implements iDisplay
 		$this->ActivateOnMentionTriggers(true);
 
 		// - Trigger for object pointing to the current object
-		$this->TriggerOnRemoteObjectUpdate();
+		$this->ActivateOnObjectUpdateTriggersForTargetObjects();
 	}
 
     /**
@@ -3550,7 +3526,7 @@ abstract class DBObject implements iDisplay
 		$this->RecordObjCreation();
 		return $ret;
 	}
-	
+
 	/**
 	 * This function is automatically called after cloning an object with the "clone" PHP language construct
 	 * The purpose of this method is to reset the appropriate attributes of the object in
@@ -3815,7 +3791,7 @@ abstract class DBObject implements iDisplay
 	 * @throws \MySQLException
 	 * @throws \OQLException
 	 */
-	public function PostUpdateActions(array $aChanges): void
+	protected function PostUpdateActions(array $aChanges): void
 	{
 		$this->FireEventAfterWrite($aChanges, false);
 		$oKPI = new ExecutionKPI();
@@ -3823,10 +3799,10 @@ abstract class DBObject implements iDisplay
 		$oKPI->ComputeStatsForExtension($this, 'AfterUpdate');
 
 		// - TriggerOnObjectUpdate
-		$this->TriggerOnObjectUpdate($this);
+		$this->ActivateOnObjectUpdateTriggers($this);
 
 		// - Trigger for object pointing to the current object
-		$this->TriggerOnRemoteObjectUpdate();
+		$this->ActivateOnObjectUpdateTriggersForTargetObjects();
 
 		$sClass = get_class($this);
 		if (MetaModel::HasLifecycle($sClass))
@@ -3874,15 +3850,19 @@ abstract class DBObject implements iDisplay
 
 	/**
 	 * @param \DBObject $oObject
+	 * @param array|null $aAttributes
 	 *
-	 * @return void
 	 * @throws \CoreException
 	 * @throws \CoreUnexpectedValue
 	 * @throws \MySQLException
 	 * @throws \OQLException
 	 */
-	private function TriggerOnObjectUpdate(DBObject $oObject): void
+	private function ActivateOnObjectUpdateTriggers(?DBObject $oObject, array $aAttributes = null): void
 	{
+		if (is_null($oObject)) {
+			return;
+		}
+
 		// - TriggerOnObjectUpdate
 		$aClassList = MetaModel::EnumParentClasses(get_class($oObject), ENUM_PARENT_CLASSES_ALL);
 		$aParams = array('class_list' => $aClassList);
@@ -3891,7 +3871,7 @@ abstract class DBObject implements iDisplay
 		while ($oTrigger = $oSet->Fetch()) {
 			/** @var \TriggerOnObjectUpdate $oTrigger */
 			try {
-				$oTrigger->DoActivate($oObject->ToArgs());
+				$oTrigger->DoActivateForSpecificAttributes($oObject->ToArgs(), $aAttributes);
 			}
 			catch (Exception $e) {
 				$oTrigger->LogException($e, $oObject);
@@ -4225,7 +4205,7 @@ abstract class DBObject implements iDisplay
 		$oKPI->ComputeStatsForExtension($this, 'AfterDelete');
 
 		// - Trigger for object pointing to the current object
-		$this->TriggerOnRemoteObjectUpdate();
+		$this->ActivateOnObjectUpdateTriggersForTargetObjects();
 
 		$this->m_bIsInDB = false;
 		$this->LogCRUDExit(__METHOD__);
@@ -4240,7 +4220,7 @@ abstract class DBObject implements iDisplay
      * First, checks if the object can be deleted regarding database integrity.
      * If the answer is yes, it performs any required cleanup (delete other objects or reset external keys) in addition to the object
      * deletion.
-     * 
+     *
      * @api
      *
      * @param \DeletionPlan $oDeletionPlan Do not use: aims at dealing with recursion
@@ -4259,9 +4239,7 @@ abstract class DBObject implements iDisplay
 	public function DBDelete(&$oDeletionPlan = null)
 	{
 		$this->LogCRUDEnter(__METHOD__);
-		$this->AddCurrentObjectInCrudStack('DELETE');
 		try {
-
 			static $iLoopTimeLimit = null;
 			if ($iLoopTimeLimit == null) {
 				$iLoopTimeLimit = MetaModel::GetConfig()->Get('max_execution_time_per_loop');
@@ -4269,17 +4247,15 @@ abstract class DBObject implements iDisplay
 			if (is_null($oDeletionPlan)) {
 				$oDeletionPlan = new DeletionPlan();
 			}
-			$this->MakeDeletionPlan($oDeletionPlan);
-			$oDeletionPlan->ComputeResults();
 
-			if ($oDeletionPlan->FoundStopper()) {
+			if (false === $this->CheckToDelete($oDeletionPlan)) {
 				$aIssues = $oDeletionPlan->GetIssues();
 				$this->LogCRUDError(__METHOD__, ' Errors: '.implode(', ', $aIssues));
 				throw new DeleteException('Found issue(s)', array('target_class' => get_class($this), 'target_id' => $this->GetKey(), 'issues' => implode(', ', $aIssues)));
 			}
 
 
-			// Getting and setting time limit are not symetric:
+			// Getting and setting time limit are not symmetric:
 			// www.php.net/manual/fr/function.set-time-limit.php#72305
 			$iPreviousTimeLimit = ini_get('max_execution_time');
 
@@ -4320,7 +4296,6 @@ abstract class DBObject implements iDisplay
 			set_time_limit(intval($iPreviousTimeLimit));
 		} finally {
 			$this->LogCRUDExit(__METHOD__);
-			$this->RemoveCurrentObjectInCrudStack();
 		}
 
 		return $oDeletionPlan;
@@ -4629,7 +4604,7 @@ abstract class DBObject implements iDisplay
      *
      * @api
      *
-	 */	 	
+	 */
 	public function Reset($sAttCode)
 	{
 		$this->Set($sAttCode, $this->GetDefaultValue($sAttCode));
@@ -4641,7 +4616,7 @@ abstract class DBObject implements iDisplay
      * Suitable for use as a lifecycle action
      *
      * @api
-	 */	 	
+	 */
 	public function Copy($sDestAttCode, $sSourceAttCode)
 	{
 		$oTypeValueToCopy = MetaModel::GetAttributeDef(get_class($this), $sSourceAttCode);
@@ -4971,7 +4946,7 @@ abstract class DBObject implements iDisplay
 			{
 				throw new CoreException("Unknown attribute '$sExtKeyAttCode' for the class ".get_class($this));
 			}
-			
+
 			$oKeyAttDef = MetaModel::GetAttributeDef(get_class($this), $sExtKeyAttCode);
 			if (!$oKeyAttDef instanceof AttributeExternalKey)
 			{
@@ -4989,14 +4964,14 @@ abstract class DBObject implements iDisplay
 				$ret  = $oRemoteObj->GetForTemplate($sRemoteAttCode);
 			}
 		}
-		else 
+		else
 		{
 			switch($sPlaceholderAttCode)
 			{
 				case 'id':
 				$ret = $this->GetKey();
 				break;
-				
+
 				case 'name()':
 				$ret = $this->GetName();
 				break;
@@ -5183,7 +5158,7 @@ abstract class DBObject implements iDisplay
 		if ($oOwner)
 		{
 			$sLinkSetOwnerClass = get_class($oOwner);
-			
+
 			$oMyChangeOp = MetaModel::NewObject($sChangeOpClass);
 			$oMyChangeOp->Set("objclass", $sLinkSetOwnerClass);
 			$oMyChangeOp->Set("objkey", $iLinkSetOwnerId);
@@ -5210,7 +5185,7 @@ abstract class DBObject implements iDisplay
 		{
 			/** @var \AttributeLinkedSet $oLinkSet */
 			if (($oLinkSet->GetTrackingLevel() & LINKSET_TRACKING_LIST) == 0) continue;
-			
+
 			$iLinkSetOwnerId  = $this->Get($sExtKeyAttCode);
 			$oMyChangeOp = $this->PrepareChangeOpLinkSet($iLinkSetOwnerId, $oLinkSet, 'CMDBChangeOpSetAttributeLinksAddRemove');
 			if ($oMyChangeOp)
@@ -5428,9 +5403,9 @@ abstract class DBObject implements iDisplay
 		$this->m_aDeleteIssues = array(); // Ok
 		$this->FireEventCheckToDelete($oDeletionPlan);
 		$this->DoCheckToDelete($oDeletionPlan);
-		$this->CheckPhpConstraint(true);
+		$this->CheckToWriteForTargetObjects(true);
 		$oDeletionPlan->SetDeletionIssues($this, $this->m_aDeleteIssues, $this->m_bSecurityIssue);
-	
+
 		$aDependentObjects = $this->GetReferencingObjects(true /* allow all data */);
 
 		// Getting and setting time limit are not symmetric:
@@ -5612,7 +5587,7 @@ abstract class DBObject implements iDisplay
 				$aSynchroClasses[] = $sTarget;
 			}
 		}
-		
+
 		foreach($aSynchroClasses as $sClass)
 		{
 			if ($this instanceof $sClass)
@@ -6678,14 +6653,10 @@ abstract class DBObject implements iDisplay
 		// during insert key is reset from -1 to null
 		// so we need to handle null values (will give empty string after conversion)
 		$sConvertedId = (string)$sId;
-
-		if (((int)$sId) > 0) {
-			// When having a class hierarchy, we are saving the leaf class in the stack
-			$sClass = MetaModel::GetFinalClassName($sClass, $sId);
-		}
+		$oRootClass = MetaModel::GetRootClass($sClass);
 
 		foreach (self::$m_aCrudStack as $aCrudStackEntry) {
-			if (($sClass === $aCrudStackEntry['class'])
+			if (($oRootClass === $aCrudStackEntry['class'])
 				&& ($sConvertedId === $aCrudStackEntry['id'])) {
 				return true;
 			}
@@ -6700,12 +6671,14 @@ abstract class DBObject implements iDisplay
 	 * @param string $sClass
 	 *
 	 * @return bool
+	 * @throws \CoreException
 	 * @since 3.1.0 N°5609
 	 */
 	final public static function IsClassCurrentlyInCrud(string $sClass): bool
 	{
+		$sRootClass = MetaModel::GetRootClass($sClass);
 		foreach (self::$m_aCrudStack as $aCrudStackEntry) {
-			if ($sClass === $aCrudStackEntry['class']) {
+			if ($sRootClass === $aCrudStackEntry['class']) {
 				return true;
 			}
 		}
@@ -6724,9 +6697,10 @@ abstract class DBObject implements iDisplay
 	private function AddCurrentObjectInCrudStack(string $sCrudType): void
 	{
 		$this->LogCRUDDebug(__METHOD__);
+		$sRootClass = MetaModel::GetRootClass(get_class($this));
 		self::$m_aCrudStack[] = [
 			'type'  => $sCrudType,
-			'class' => get_class($this),
+			'class' => $sRootClass,
 			'id'    => (string)$this->GetKey(), // GetKey() doesn't have type hinting, so forcing type to avoid getting an int
 		];
 	}
diff --git a/core/metamodel.class.php b/core/metamodel.class.php
index e725c31d5..82de32529 100644
--- a/core/metamodel.class.php
+++ b/core/metamodel.class.php
@@ -6928,8 +6928,7 @@ abstract class MetaModel
 
 	public static function GetFinalClassName(string $sClass, int $iKey): string
 	{
-		$sFinalClassField = Metamodel::DBGetClassField($sClass);
-		if (utils::IsNullOrEmptyString($sFinalClassField)) {
+		if (MetaModel::IsStandaloneClass($sClass)) {
 			return $sClass;
 		}
 
@@ -6937,6 +6936,7 @@ abstract class MetaModel
 		$sTable = MetaModel::DBGetTable($sRootClass);
 		$sKeyCol = MetaModel::DBGetKey($sRootClass);
 		$sEscapedKey = CMDBSource::Quote($iKey);
+		$sFinalClassField = Metamodel::DBGetClassField($sRootClass);
 
 		$sQuery = "SELECT `{$sFinalClassField}` FROM `{$sTable}` WHERE `{$sKeyCol}` = {$sEscapedKey}";
 		return  CMDBSource::QueryToScalar($sQuery);
diff --git a/core/trigger.class.inc.php b/core/trigger.class.inc.php
index b0222184c..7cc3a1d80 100644
--- a/core/trigger.class.inc.php
+++ b/core/trigger.class.inc.php
@@ -546,6 +546,38 @@ class TriggerOnObjectUpdate extends TriggerOnObject
 		MetaModel::Init_SetZListItems('standard_search', array('description', 'target_class')); // Criteria of the std search form
 	}
 
+	/**
+	 * Activate trigger based on attribute list given instead of changed attributes
+	 *
+	 * @param array $aContextArgs
+	 * @param array|null $aAttributes if null default to changed attributes
+	 *
+	 * @throws \ArchivedObjectException
+	 * @throws \CoreException
+	 * @throws \MissingQueryArgument
+	 * @throws \MySQLException
+	 * @throws \MySQLHasGoneAwayException
+	 * @throws \OQLException
+	 * @since 3.1.1 3.2.0 N°6228
+	 */
+	public function DoActivateForSpecificAttributes(array $aContextArgs, ?array $aAttributes)
+	{
+		if (isset($aContextArgs['this->object()']))
+		{
+			/** @var \DBObject $oObject */
+			$oObject = $aContextArgs['this->object()'];
+			if (is_null($aAttributes)) {
+				$aChanges = $oObject->ListPreviousValuesForUpdatedAttributes();
+			} else {
+				$aChanges = array_fill_keys($aAttributes, true);
+			}
+			if (false === $this->IsTargetObject($oObject->GetKey(), $aChanges)) {
+				return;
+			}
+		}
+		parent::DoActivate($aContextArgs);
+	}
+
 	public function IsTargetObject($iObjectId, $aChanges = array())
 	{
 		if (!parent::IsTargetObject($iObjectId, $aChanges))
diff --git a/core/userrights.class.inc.php b/core/userrights.class.inc.php
index a099e96fd..aea8658e4 100644
--- a/core/userrights.class.inc.php
+++ b/core/userrights.class.inc.php
@@ -1,8 +1,6 @@
 ListChanges())) {
-			return;
-		}
-
-		$oProfileLinkSet = $this->Get('profile_list');
-		if ($oProfileLinkSet->Count() > 1) {
-			return;
-		}
-		$oProfileLinkSet->Rewind();
-		$iPowerPortalCount = 0;
-		$iTotalCount = 0;
-		while ($oUserProfile = $oProfileLinkSet->Fetch()) {
-			$sProfile = $oUserProfile->Get('profile');
-			if ($sProfile === 'Portal power user') {
-				$iPowerPortalCount = 1;
-			}
-			$iTotalCount++;
-		}
-		if ($iTotalCount === $iPowerPortalCount) {
-			$this->AddCheckIssue(Dict::S('Class:User/Error:PortalPowerUserHasInsufficientRights'));
-		}
-	}
-
 	/**
 	 * @inheritDoc
 	 * @since 3.0.0
diff --git a/tests/php-unit-tests/src/BaseTestCase/ItopDataTestCase.php b/tests/php-unit-tests/src/BaseTestCase/ItopDataTestCase.php
index c14340cbc..29429f0fe 100644
--- a/tests/php-unit-tests/src/BaseTestCase/ItopDataTestCase.php
+++ b/tests/php-unit-tests/src/BaseTestCase/ItopDataTestCase.php
@@ -79,6 +79,22 @@ abstract class ItopDataTestCase extends ItopTestCase
 	const USE_TRANSACTION = true;
 	const CREATE_TEST_ORG = false;
 
+	protected static $aURP_Profiles = [
+		'Administrator'         => 1,
+		'Portal user'           => 2,
+		'Configuration Manager' => 3,
+		'Service Desk Agent'    => 4,
+		'Support Agent'         => 5,
+		'Problem Manager'       => 6,
+		'Change Implementor'    => 7,
+		'Change Supervisor'     => 8,
+		'Change Approver'       => 9,
+		'Service Manager'       => 10,
+		'Document author'       => 11,
+		'Portal power user'     => 12,
+		'REST Services User'    => 1024,
+	];
+
 	/**
 	 * This method is called before the first test of this test class is run (in the current process).
 	 */
diff --git a/tests/php-unit-tests/unitary-tests/core/AttributeDefinitionTest.php b/tests/php-unit-tests/unitary-tests/core/AttributeDefinitionTest.php
index 418b5fef1..254c9789e 100644
--- a/tests/php-unit-tests/unitary-tests/core/AttributeDefinitionTest.php
+++ b/tests/php-unit-tests/unitary-tests/core/AttributeDefinitionTest.php
@@ -223,9 +223,10 @@ PHP
 	public function testWithConstraintAndComputationParameters(string $sClass, string $sAttCode, bool $bConstraintExpected, bool $bComputationExpected)
 	{
 		$oAttDef = \MetaModel::GetAttributeDef($sClass, $sAttCode);
-		$this->assertTrue(method_exists($oAttDef, 'GetHasConstraint'));
-		$this->assertEquals($bConstraintExpected, $oAttDef->GetHasConstraint());
-		$this->assertEquals($bComputationExpected, $oAttDef->GetHasComputation());
+		$sConstraintExpected = $bConstraintExpected ? 'true' : 'false';
+		$sComputationExpected = $bComputationExpected ? 'true' : 'false';
+		$this->assertEquals($bConstraintExpected, $oAttDef->HasPHPConstraint(), "Standard DataModel should be configured with property 'has_php_constraint'=$sConstraintExpected for $sClass:$sAttCode");
+		$this->assertEquals($bComputationExpected, $oAttDef->HasPHPComputation(), "Standard DataModel should be configured with property 'has_php_computation'=$sComputationExpected for $sClass:$sAttCode");
 	}
 
 	public function WithConstraintParameterProvider()
diff --git a/tests/php-unit-tests/unitary-tests/core/DBObject/CheckToWritePropagationTest.php b/tests/php-unit-tests/unitary-tests/core/DBObject/CheckToWritePropagationTest.php
index f4e025983..8ab191336 100644
--- a/tests/php-unit-tests/unitary-tests/core/DBObject/CheckToWritePropagationTest.php
+++ b/tests/php-unit-tests/unitary-tests/core/DBObject/CheckToWritePropagationTest.php
@@ -4,356 +4,74 @@
  * @license     http://opensource.org/licenses/AGPL-3.0
  */
 
-/**
- * Created by PhpStorm.
- * Date: 25/01/2018
- * Time: 11:12
- */
-
 namespace Combodo\iTop\Test\UnitTest\Core\DBObject;
 
-use Combodo\iTop\Application\UI\Base\Layout\NavigationMenu\NavigationMenuFactory;
+use Combodo\iTop\Service\Events\EventData;
 use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
-use CoreCannotSaveObjectException;
-use DBObjectSet;
-use DBSearch;
-use DeleteException;
-use MetaModel;
 use URP_UserProfile;
-use User;
-use UserExternal;
 use UserLocal;
-use UserRights;
 
-/**
- * @group itopRequestMgmt
- * @group userRights
- * @group defaultProfiles
- *
- * @runTestsInSeparateProcesses
- * @preserveGlobalState disabled
- * @backupGlobals disabled
- */
 class CheckToWritePropagationTest extends ItopDataTestCase
 {
-	public function PortaPowerUserProvider()
+	private static array $aEventCalls;
+
+	public function testCascadeCheckToWrite()
 	{
-		return [
-			'No profile' => [
-				'aProfilesBeforeUserCreation'        => [
-				],
-				'bWaitForException'                         => 'CoreCannotSaveObjectException',
-			],
-			'Portal power user'                 => [
-				'aProfilesBeforeUserCreation'        => [
-					'Portal power user',
-				],
-				'bWaitForException'                         => 'CoreCannotSaveObjectException',
-			],
-			'Portal power user + Configuration Manager'                 => [
-				'aProfilesBeforeUserCreation'        => [
-					'Portal power user',
-					'Configuration Manager',
-				],
-				'bWaitForException'                         => false,
-			],
-			'Portal power user + Configuration Manager + Admin'                 => [
-				'aProfilesBeforeUserCreation'        => [
-					'Portal power user',
-					'Configuration Manager',
-					'Administrator',
-				],
-				'bWaitForException'                         => false,
-			],
-		];
+		$sLogin = 'testCascadeCheckToWrite-'.uniqid('', true);
+		$oUser1 = $this->CreateUser($sLogin, self::$aURP_Profiles['Administrator'], 'ABCD1234@gabuzomeu');
+		$sUserId1 = $oUser1->GetKey();
+		$sLogin = 'testCascadeCheckToWrite-'.uniqid('', true);
+		$oUser2 = $this->CreateUser($sLogin, self::$aURP_Profiles['Administrator'], 'ABCD1234@gabuzomeu');
+		$sUserId2 = $oUser2->GetKey();
+
+		$this->EventService_RegisterListener(EVENT_DB_CHECK_TO_WRITE, [$this, 'CheckToWriteEventListener'], 'User');
+		$sEventKeyUser1 = $this->GetEventKey(EVENT_DB_CHECK_TO_WRITE, UserLocal::class, $sUserId1);
+		$sEventKeyUser2 = $this->GetEventKey(EVENT_DB_CHECK_TO_WRITE, UserLocal::class, $sUserId2);
+
+		// Add URP_UserProfile
+		self::$aEventCalls = [];
+		$oURPUserProfile = new URP_UserProfile();
+		$oURPUserProfile->Set('profileid', self::$aURP_Profiles['Support Agent']);
+		$oURPUserProfile->Set('userid', $sUserId1);
+		$oURPUserProfile->Set('reason', 'UNIT Tests');
+		$oURPUserProfile->DBInsert();
+		$this->assertArrayHasKey($sEventKeyUser1, self::$aEventCalls, 'User checkToWrite should be called when a URP_UserProfile is created');
+
+		// Update URP_UserProfile (change profile)
+		self::$aEventCalls = [];
+		$oURPUserProfile->Set('profileid', self::$aURP_Profiles['Problem Manager']);
+		$oURPUserProfile->DBUpdate();
+		$this->assertArrayHasKey($sEventKeyUser1, self::$aEventCalls, 'User checkToWrite should be called when a URP_UserProfile is updated');
+
+		// Update URP_UserProfile (move from User1 to User2)
+		self::$aEventCalls = [];
+		$oURPUserProfile->Set('userid', $sUserId2);
+		$oURPUserProfile->DBUpdate();
+		$this->assertCount(2, self::$aEventCalls, 'Previous User and new User checkToWrite should be called when a URP_UserProfile is moved from a User to another');
+		$this->assertArrayHasKey($sEventKeyUser1, self::$aEventCalls, 'Previous User checkToWrite should be called when a URP_UserProfile is moved from a User to another');
+		$this->assertArrayHasKey($sEventKeyUser2, self::$aEventCalls, 'New User checkToWrite should be called when a URP_UserProfile is moved from a User to another');
+
+		// Delete URP_UserProfile from User2
+		self::$aEventCalls = [];
+		$oURPUserProfile->DBDelete();
+		$this->assertArrayHasKey($sEventKeyUser2, self::$aEventCalls, 'User checkToWrite should be called when a URP_UserProfile is deleted');
+
+		$oUser1->DBDelete();
+		$oUser2->DBDelete();
 	}
 
-	/**
-	 * @dataProvider PortaPowerUserProvider
-	 * @covers User::CheckPortalProfiles
-	 */
-	public function testUserLocalCreation($aProfilesBeforeUserCreation, $sWaitForException)
+	public function CheckToWriteEventListener(EventData $oEventData)
 	{
-		$oUser = new UserLocal();
-		$sLogin = 'testUserLocalCreationWithPortalPowerUserProfile-'.uniqid('', true);
-		$oUser->Set('login', $sLogin);
-		$oUser->Set('password', 'ABCD1234@gabuzomeu');
-		$oUser->Set('language', 'EN US');
-		if (false !== $sWaitForException) {
-			$this->expectException($sWaitForException);
-		}
-		$this->commonUserCreationTest($oUser, $aProfilesBeforeUserCreation);
+		$oObject = $oEventData->GetEventData()['object'];
+		$sEvent = $oEventData->GetEvent();
+		$sClass = get_class($oObject);
+		$sId = $oObject->GetKey();
+		self::$aEventCalls[$this->GetEventKey($sEvent, $sClass, $sId)] = true;
 	}
 
-	/**
-	 * @dataProvider PortaPowerUserProvider
-	 * @covers User::CheckPortalProfiles
-	 */
-	public function testUserLocalDelete($aProfilesBeforeUserCreation, $sWaitForException)
+	private function GetEventKey($sEvent, $sClass, $sId)
 	{
-		$oUser = new UserLocal();
-		$sLogin = 'testUserLocalCreationWithPortalPowerUserProfile-'.uniqid('', true);
-		$oUser->Set('login', $sLogin);
-		$oUser->Set('password', 'ABCD1234@gabuzomeu');
-		$oUser->Set('language', 'EN US');
-		if (false !== $sWaitForException) {
-			$this->expectException($sWaitForException);
-		}
-		$this->commonUserCreationTest($oUser, $aProfilesBeforeUserCreation, false);
-
-		$oUser->DBDelete();
+		return "event: $sEvent, class: $sClass, id: $sId";
 	}
 
-	/**
-	 * @dataProvider PortaPowerUserProvider
-	 * @covers User::CheckPortalProfiles
-	 */
-	public function testUserLocalUpdate($aProfilesBeforeUserCreation, $sWaitForException)
-	{
-		$oUser = new UserLocal();
-		$sLogin = 'testUserLocalUpdateWithPortalPowerUserProfile-'.uniqid('', true);
-		$oUser->Set('login', $sLogin);
-		$oUser->Set('password', 'ABCD1234@gabuzomeu');
-		$oUser->Set('language', 'EN US');
-		if (false !== $sWaitForException) {
-			$this->expectException($sWaitForException);
-		}
-		$this->commonUserUpdateTest($oUser, $aProfilesBeforeUserCreation);
-	}
-
-	private function commonUserCreationTest($oUserToCreate, $aProfilesBeforeUserCreation, $bTestUserItopAccess = true)
-	{
-		$sUserClass = get_class($oUserToCreate);
-		list ($sId, $aProfiles) = $this->CreateUserForProfileTesting($oUserToCreate, $aProfilesBeforeUserCreation);
-
-		$this->CheckProfilesAreOkAndThenConnectToITop($sUserClass, $sId, $aProfilesBeforeUserCreation, $bTestUserItopAccess);
-	}
-
-	private function commonUserUpdateTest($oUserToCreate, $aProfilesBeforeUserCreation)
-	{
-		$sUserClass = get_class($oUserToCreate);
-		list ($sId, $aProfiles) = $this->CreateUserForProfileTesting($oUserToCreate, ['Administrator']);
-
-		$oUserToUpdate = MetaModel::GetObject($sUserClass, $sId);
-		$oProfileList = $oUserToUpdate->Get('profile_list');
-		while ($oObj = $oProfileList->Fetch()) {
-			$oProfileList->RemoveItem($oObj->GetKey());
-		}
-
-		foreach ($aProfilesBeforeUserCreation as $sProfileName) {
-			$oAdminUrpProfile = new URP_UserProfile();
-			$oProfile = $aProfiles[$sProfileName];
-			$oAdminUrpProfile->Set('profileid', $oProfile->GetKey());
-			$oAdminUrpProfile->Set('reason', 'UNIT Tests');
-			$oProfileList->AddItem($oAdminUrpProfile);
-		}
-
-		$oUserToUpdate->Set('profile_list', $oProfileList);
-		$oUserToUpdate->DBWrite();
-
-		$this->CheckProfilesAreOkAndThenConnectToITop($sUserClass, $sId, $aProfilesBeforeUserCreation);
-	}
-
-	private function CreateUserForProfileTesting(User $oUserToCreate, array $aProfilesBeforeUserCreation, $bDbInsert = true): array
-	{
-		$aProfiles = [];
-		$oSearch = DBSearch::FromOQL('SELECT URP_Profiles');
-		$oProfileSet = new DBObjectSet($oSearch);
-		while (($oProfile = $oProfileSet->Fetch()) != null) {
-			$aProfiles[$oProfile->Get('name')] = $oProfile;
-		}
-
-		$this->CreateTestOrganization();
-		$oContact = $this->CreatePerson('1');
-		$iContactId = $oContact->GetKey();
-
-		$oUserToCreate->Set('contactid', $iContactId);
-
-		$oUserProfileList = $oUserToCreate->Get('profile_list');
-		foreach ($aProfilesBeforeUserCreation as $sProfileName) {
-			$oUserProfile = new URP_UserProfile();
-			$oProfile = $aProfiles[$sProfileName];
-			$oUserProfile->Set('profileid', $oProfile->GetKey());
-			$oUserProfile->Set('reason', 'UNIT Tests');
-			$oUserProfileList->AddItem($oUserProfile);
-		}
-
-		$oUserToCreate->Set('profile_list', $oUserProfileList);
-		if ($bDbInsert) {
-			$sId = $oUserToCreate->DBInsert();
-		} else {
-			$sId = -1;
-		}
-
-		return [$sId, $aProfiles];
-	}
-
-	private function CheckProfilesAreOkAndThenConnectToITop($sUserClass, $sId, $aExpectedAssociatedProfilesAfterUserCreation, $bTestItopConnection = true)
-	{
-		$oUser = MetaModel::GetObject($sUserClass, $sId);
-		$oUserProfileList = $oUser->Get('profile_list');
-		$aProfilesAfterCreation = [];
-		while (($oProfile = $oUserProfileList->Fetch()) != null) {
-			$aProfilesAfterCreation[] = $oProfile->Get('profile');
-		}
-
-		foreach ($aExpectedAssociatedProfilesAfterUserCreation as $sExpectedProfileName) {
-			$this->assertTrue(in_array($sExpectedProfileName, $aProfilesAfterCreation),
-				"profile \'$sExpectedProfileName\' should be asociated to user after creation. ".var_export($aProfilesAfterCreation, true));
-		}
-
-		if (!$bTestItopConnection) {
-			return;
-		}
-
-		$_SESSION = [];
-
-		UserRights::Login($oUser->Get('login'));
-
-		if (!UserRights::IsPortalUser()) {
-			//calling this API triggers Fatal Error on below OQL used by \User->GetContactObject() for a user with only 'portal power user' profile
-			/**
-			 * Error: No result for the single row query: 'SELECT DISTINCT `Contact`.`id` AS `Contactid`, `Contact`.`name` AS `Contactname`, `Contact`.`status` AS `Contactstatus`, `Contact`.`org_id` AS `Contactorg_id`, `Organization_org_id`.`name` AS `Contactorg_name`, `Contact`.`email` AS `Contactemail`, `Contact`.`phone` AS `Contactphone`, `Contact`.`notify` AS `Contactnotify`, `Contact`.`function` AS `Contactfunction`, `Contact`.`finalclass` AS `Contactfinalclass`, IF((`Contact`.`finalclass` IN ('Team', 'Contact')), CAST(CONCAT(COALESCE(`Contact`.`name`, '')) AS CHAR), CAST(CONCAT(COALESCE(`Contact_poly_Person`.`first_name`, ''), COALESCE(' ', ''), COALESCE(`Contact`.`name`, '')) AS CHAR)) AS `Contactfriendlyname`, COALESCE((`Contact`.`status` = 'inactive'), 0) AS `Contactobsolescence_flag`, `Contact`.`obsolescence_date` AS `Contactobsolescence_date`, CAST(CONCAT(COALESCE(`Organization_org_id`.`name`, '')) AS CHAR) AS `Contactorg_id_friendlyname`, COALESCE((`Organization_org_id`.`status` = 'inactive'), 0) AS `Contactorg_id_obsolescence_flag` FROM `contact` AS `Contact` INNER JOIN `organization` AS `Organization_org_id` ON `Contact`.`org_id` = `Organization_org_id`.`id` LEFT JOIN `person` AS `Contact_poly_Person` ON `Contact`.`id` = `Contact_poly_Person`.`id` WHERE ((`Contact`.`id` = 40) AND 0) '.
-			 */
-			NavigationMenuFactory::MakeStandard();
-		}
-
-		$this->assertTrue(true, 'after fix N°5324 no exception raised');
-		// logout
-		$_SESSION = [];
-	}
-
-	/**
-	 * @dataProvider ProfilesLinksProvider
-	 */
-	public function testProfilesLinksDBDelete(string $sProfileNameToRemove, $bRaiseException = false)
-	{
-		$aInitialProfiles = [$sProfileNameToRemove, 'Portal power user'];
-
-		$oUser = new UserExternal();
-		$sLogin = 'testUserLDAPUpdateWithPortalPowerUserProfile-'.uniqid('', true);
-		$oUser->Set('login', $sLogin);
-
-		[$sId, $aProfiles] = $this->CreateUserForProfileTesting($oUser, $aInitialProfiles);
-
-		if ($bRaiseException) {
-			$this->expectException(DeleteException::class);
-		}
-
-		$aURPUserProfileByUser = $this->GetURPUserProfileByUser($sId);
-		if (array_key_exists($sProfileNameToRemove, $aURPUserProfileByUser)) {
-			$oURPUserProfile = $aURPUserProfileByUser[$sProfileNameToRemove];
-			$oURPUserProfile->DBDelete();
-		}
-	}
-
-	/**
-	 * @dataProvider ProfilesLinksProvider
-	 */
-	public function testProfilesLinksEdit_ChangeProfileId(string $sInitialProfile, $bRaiseException = false)
-	{
-		$oUser = new UserExternal();
-		$sLogin = 'testUserLDAPUpdateWithPortalPowerUserProfile-'.uniqid('', true);
-		$oUser->Set('login', $sLogin);
-
-		$sUserClass = get_class($oUser);
-		list ($sId, $aProfiles) = $this->CreateUserForProfileTesting($oUser, [$sInitialProfile]);
-
-		$oURP_Profile = MetaModel::GetObjectByColumn('URP_Profiles', 'name', 'Portal power user');
-
-		$aURPUserProfileByUser = $this->GetURPUserProfileByUser($sId);
-
-		if ($bRaiseException) {
-			$this->expectException(CoreCannotSaveObjectException::class);
-		}
-
-		if (array_key_exists($sInitialProfile, $aURPUserProfileByUser)) {
-			$oURPUserProfile = $aURPUserProfileByUser[$sInitialProfile];
-			$oURPUserProfile->Set('profileid', $oURP_Profile->GetKey());
-			$oURPUserProfile->DBWrite();
-		}
-
-		if (!$bRaiseException) {
-			$aExpectedProfilesAfterUpdate = ['Portal power user', 'Portal user'];
-			$this->CheckProfilesAreOkAndThenConnectToITop($sUserClass, $sId, $aExpectedProfilesAfterUpdate);
-		}
-	}
-
-	public function ProfilesLinksProvider()
-	{
-		return [
-			'Administrator' => ['sProfileNameToMove' => 'Administrator', 'bRaiseException' => true],
-			'Portal user'   => ['sProfileNameToMove' => 'Portal user', 'bRaiseException' => true],
-		];
-	}
-
-	/**
-	 * @dataProvider ProfilesLinksProvider
-	 */
-	public function testProfilesLinksEdit_ChangeUserId($sProfileNameToMove, $bRaiseException = false)
-	{
-		$aInitialProfiles = [$sProfileNameToMove, 'Portal power user'];
-
-		$oUser = new UserExternal();
-		$sLogin1 = 'testUserLDAPUpdateWithPortalPowerUserProfile-'.uniqid('', true);
-		$oUser->Set('login', $sLogin1);
-
-		$sUserClass = get_class($oUser);
-		list ($sId, $aProfiles) = $this->CreateUserForProfileTesting($oUser, $aInitialProfiles);
-
-		$oUser = new UserExternal();
-		$sLogin2 = 'testUserLDAPUpdateWithPortalPowerUserProfile-'.uniqid('', true);
-		$oUser->Set('login', $sLogin2);
-		list ($sAnotherUserId, $aProfiles) = $this->CreateUserForProfileTesting($oUser, ['Configuration Manager']);
-
-		if ($bRaiseException) {
-			$this->expectException(CoreCannotSaveObjectException::class);
-		}
-
-		$aURPUserProfileByUser = $this->GetURPUserProfileByUser($sId);
-		if (array_key_exists($sProfileNameToMove, $aURPUserProfileByUser)) {
-			$oURPUserProfile = $aURPUserProfileByUser[$sProfileNameToMove];
-			$oURPUserProfile->Set('userid', $sAnotherUserId);
-			$oURPUserProfile->DBWrite();
-		}
-
-		if (!$bRaiseException) {
-			$aExpectedProfilesAfterUpdate = [$sProfileNameToMove, 'Configuration Manager'];
-			$this->CheckProfilesAreOkAndThenConnectToITop($sUserClass, $sAnotherUserId, $aExpectedProfilesAfterUpdate);
-
-			$aExpectedProfilesAfterUpdate = ['Portal power user', 'Portal user'];
-			$this->CheckProfilesAreOkAndThenConnectToITop($sUserClass, $sId, $aExpectedProfilesAfterUpdate);
-		}
-	}
-
-	private function GetURPUserProfileByUser($iUserId): array
-	{
-		$aRes = [];
-		$oSearch = DBSearch::FromOQL("SELECT URP_UserProfile WHERE userid=$iUserId");
-		$oSet = new DBObjectSet($oSearch);
-		while (($oURPUserProfile = $oSet->Fetch()) != null) {
-			$aRes[$oURPUserProfile->Get('profile')] = $oURPUserProfile;
-		}
-
-		return $aRes;
-	}
-
-	public function CustomizedPortalsProvider()
-	{
-		return [
-			'console + customized portal'               => [
-				'aPortalDispatcherData' => [
-					'customer-portal',
-					'backoffice',
-				],
-			],
-			'console + itop portal + customized portal' => [
-				'aPortalDispatcherData' => [
-					'itop-portal',
-					'customer-portal',
-					'backoffice',
-				],
-			],
-		];
-	}
 }
diff --git a/tests/php-unit-tests/unitary-tests/core/DBObject/CustomCheckToWriteTest.php b/tests/php-unit-tests/unitary-tests/core/DBObject/CustomCheckToWriteTest.php
new file mode 100644
index 000000000..93cea9f58
--- /dev/null
+++ b/tests/php-unit-tests/unitary-tests/core/DBObject/CustomCheckToWriteTest.php
@@ -0,0 +1,64 @@
+ [
+				'aProfiles'            => [],
+				'bExpectedCheckStatus' => false,
+			],
+			'Portal power user'                                 => [
+				'aProfiles'            => ['Portal power user',],
+				'bExpectedCheckStatus' => true,
+			],
+			'Portal power user + Configuration Manager'         => [
+				'aProfiles'            => ['Portal power user', 'Configuration Manager',],
+				'bExpectedCheckStatus' => true,
+			],
+			'Portal power user + Configuration Manager + Admin' => [
+				'aProfiles'            => ['Portal power user', 'Configuration Manager', 'Administrator',],
+				'bExpectedCheckStatus' => true,
+			],
+		];
+	}
+
+	/**
+	 * @dataProvider PortaPowerUserProvider
+	 * @covers       User::CheckPortalProfiles
+	 */
+	public function testUserLocalCheckPortalProfiles($aProfiles, $bExpectedCheckStatus)
+	{
+		$oUser = new UserLocal();
+		$sLogin = 'testUserLocalCreationWithPortalPowerUserProfile-'.uniqid('', true);
+		$oUser->Set('login', $sLogin);
+		$oUser->Set('password', 'ABCD1234@gabuzomeu');
+		$oUser->Set('language', 'EN US');
+		$oProfileList = $oUser->Get('profile_list');
+
+		foreach ($aProfiles as $sProfileName) {
+			$oAdminUrpProfile = new URP_UserProfile();
+			$oAdminUrpProfile->Set('profileid', self::$aURP_Profiles[$sProfileName]);
+			$oAdminUrpProfile->Set('reason', 'UNIT Tests');
+			$oProfileList->AddItem($oAdminUrpProfile);
+		}
+
+		$oUser->Set('profile_list', $oProfileList);
+
+		[$bCheckStatus, $aCheckIssues, $bSecurityIssue] = $oUser->CheckToWrite();
+		$this->assertEquals($bExpectedCheckStatus, $bCheckStatus);
+	}
+
+}
diff --git a/tests/php-unit-tests/unitary-tests/core/MetaModelTest.php b/tests/php-unit-tests/unitary-tests/core/MetaModelTest.php
index 721d87d52..e60006e22 100644
--- a/tests/php-unit-tests/unitary-tests/core/MetaModelTest.php
+++ b/tests/php-unit-tests/unitary-tests/core/MetaModelTest.php
@@ -33,6 +33,29 @@ class MetaModelTest extends ItopDataTestCase
 		parent::tearDown();
 	}
 
+	/**
+	 * @covers MetaModel::GetObjectByName
+	 * @return void
+	 * @throws \CoreException
+	 */
+	public function testGetFinalClassName()
+	{
+		// Standalone classes
+		$this->assertEquals('Organization', MetaModel::GetFinalClassName('Organization', 1), 'Should work with standalone classes');
+		$this->assertEquals('SynchroDataSource', MetaModel::GetFinalClassName('SynchroDataSource', 1), 'Should work with standalone classes');
+
+		// 2 levels hierarchy
+		$this->assertEquals('Person', MetaModel::GetFinalClassName('Contact', 1));
+		$this->assertEquals('Person', MetaModel::GetFinalClassName('Person', 1));
+
+		// multi-level hierarchy
+		$oServer1 = MetaModel::GetObjectByName('Server', 'Server1');
+		$sServer1Id = $oServer1->GetKey();
+		foreach (MetaModel::EnumParentClasses('Server',ENUM_PARENT_CLASSES_ALL) as $sClass) {
+			$this->assertEquals('Server', MetaModel::GetFinalClassName($sClass, $sServer1Id), 'Should return Server for all the classes in the hierarchy');
+		}
+	}
+
 	/**
      * @group itopRequestMgmt
      * @covers       MetaModel::ApplyParams()