diff --git a/application/cmdbabstract.class.inc.php b/application/cmdbabstract.class.inc.php index fd56646f5..c194b8f5f 100644 --- a/application/cmdbabstract.class.inc.php +++ b/application/cmdbabstract.class.inc.php @@ -32,6 +32,8 @@ define('HILIGHT_CLASS_WARNING', 'orange'); define('HILIGHT_CLASS_OK', 'green'); define('HILIGHT_CLASS_NONE', ''); +define('MIN_WATCHDOG_INTERVAL', 15); // Minimum interval for the watchdog: 15s + require_once(APPROOT.'/core/cmdbobject.class.inc.php'); require_once(APPROOT.'/application/applicationextension.inc.php'); require_once(APPROOT.'/application/utils.inc.php'); @@ -60,6 +62,39 @@ abstract class cmdbAbstractObject extends CMDBObject implements iDisplay { return 'UI.php'; } + + function ReloadAndDisplay($oPage, $oObj, $aParams) + { + $oAppContext = new ApplicationContext(); + // Reload the page to let the "calling" page execute its 'onunload' method. + // Note 1: The redirection MUST NOT be made via an HTTP "header" since onunload is only called when the actual content of the DOM + // is replaced by some other content. So the "bouncing" page must provide some content (in our case a script making the redirection). + // Note 2: make sure that the URL below is different from the one of the "Modify" button, otherwise the button will have no effect. This is why we add "&a=1" at the end !!! + // Note 3: we use the toggle of a flag in the sessionStorage object to prevent an infinite loop of reloads in case the object is actually locked by another window + $sSessionStorageKey = get_class($oObj).'_'.$oObj->GetKey(); + $sParams = ''; + foreach($aParams as $sName => $value) + { + $sParams .= $sName.'='.urlencode($value).'&'; // Always add a trailing & + } + $sUrl = utils::GetAbsoluteUrlAppRoot().'pages/'.$oObj->GetUIPage().'?'.$sParams.'class='.get_class($oObj).'&id='.$oObj->getKey().'&'.$oAppContext->GetForLink().'&a=1'; + $oPage->add_script( +<<Reload(); + $oObj->DisplayDetails($oPage, false); + } /** * Set a message diplayed to the end-user next time this object will be displayed @@ -90,7 +125,7 @@ abstract class cmdbAbstractObject extends CMDBObject implements iDisplay 'message' => $sMessage ); } - } + } function DisplayBareHeader(WebPage $oPage, $bEditMode = false) { @@ -98,25 +133,39 @@ abstract class cmdbAbstractObject extends CMDBObject implements iDisplay // // Is there a message for this object ?? + $aMessages = array(); + $aRanks = array(); + if (MetaModel::GetConfig()->Get('concurrent_lock_enabled')) + { + $aLockInfo = iTopOwnershipLock::IsLocked(get_class($this), $this->GetKey()); + if ($aLockInfo['locked']) + { + $aRanks[] = 0; + $sName = $aLockInfo['owner']->GetName(); + if ($aLockInfo['owner']->Get('contactid') != 0) + { + $sName .= ' ('.$aLockInfo['owner']->Get('contactid_friendlyname').')'; + } + $aResult['message'] = Dict::Format('UI:CurrentObjectIsLockedBy_User', $sName); $aMessages[] = "
".Dict::Format('UI:CurrentObjectIsLockedBy_User', $sName)."
"; + } + } $sMessageKey = get_class($this).'::'.$this->GetKey(); if (array_key_exists('obj_messages', $_SESSION) && array_key_exists($sMessageKey, $_SESSION['obj_messages'])) { - $aMessages = array(); - $aRanks = array(); foreach ($_SESSION['obj_messages'][$sMessageKey] as $sMessageId => $aMessageData) { $sMsgClass = 'message_'.$aMessageData['severity']; $aMessages[] = "
".$aMessageData['message']."
"; $aRanks[] = $aMessageData['rank']; } - array_multisort($aRanks, $aMessages); - foreach ($aMessages as $sMessage) - { - $oPage->add($sMessage); - } unset($_SESSION['obj_messages'][$sMessageKey]); } - + array_multisort($aRanks, $aMessages); + foreach ($aMessages as $sMessage) + { + $oPage->add($sMessage); + } + // action menu $oSingletonFilter = new DBObjectSearch(get_class($this)); $oSingletonFilter->AddCondition('id', $this->GetKey(), '='); @@ -1938,6 +1987,53 @@ abstract class cmdbAbstractObject extends CMDBObject implements iDisplay public function DisplayModifyForm(WebPage $oPage, $aExtraParams = array()) { + $sOwnershipToken = null; + $iKey = $this->GetKey(); + $sClass = get_class($this); + if ($iKey > 0) + { + // The concurrent access lock makes sense only for already existing objects + $LockEnabled = MetaModel::GetConfig()->Get('concurrent_lock_enabled'); + if ($LockEnabled) + { + $sOwnershipToken = utils::ReadPostedParam('ownership_token', null, false, 'raw_data'); + if ($sOwnershipToken !== null) + { + // We're probably inside something like "apply_modify" where the validation failed and we must prompt the user again to edit the object + // let's extend our lock + $aLockInfo = iTopOwnershipLock::ExtendLock($sClass, $iKey, $sOwnershipToken); + $sOwnershipDate = $aLockInfo['acquired']; + } + else + { + $aLockInfo = iTopOwnershipLock::AcquireLock($sClass, $iKey); + if ($aLockInfo['success']) + { + $sOwnershipToken = $aLockInfo['token']; + $sOwnershipDate = $aLockInfo['acquired']; + } + else + { + $oOwner = $aLockInfo['lock']->GetOwner(); + // If the object is locked by the current user, it's worth trying again, since + // the lock may be released by 'onunload' which is called AFTER loading the current page. + //$bTryAgain = $oOwner->GetKey() == UserRights::GetUserId(); + self::ReloadAndDisplay($oPage, $this, array('operation' => 'modify')); + return; + } + } + } + } + + if (isset($aExtraParams['wizard_container']) && $aExtraParams['wizard_container']) + { + $sClassLabel = MetaModel::GetName($sClass); + $oPage->set_title(Dict::Format('UI:ModificationPageTitle_Object_Class', $this->GetRawName(), $sClassLabel)); // Set title will take care of the encoding + $oPage->add("
\n"); + $oPage->add("

".$this->GetIcon()." ".Dict::Format('UI:ModificationTitle_Class_Object', $sClassLabel, $this->GetName())."

\n"); + $oPage->add("
\n"); + $oPage->add("
\n"); + } self::$iGlobalFormId++; $this->aFieldsMap = array(); $sPrefix = ''; @@ -1948,10 +2044,8 @@ abstract class cmdbAbstractObject extends CMDBObject implements iDisplay $aFieldsComments = (isset($aExtraParams['fieldsComments'])) ? $aExtraParams['fieldsComments'] : array(); $this->m_iFormId = $sPrefix.self::$iGlobalFormId; - $sClass = get_class($this); $oAppContext = new ApplicationContext(); $sStateAttCode = MetaModel::GetStateAttributeCode($sClass); - $iKey = $this->GetKey(); $aDetails = array(); $aFieldsMap = array(); if (!isset($aExtraParams['action'])) @@ -2052,9 +2146,10 @@ abstract class cmdbAbstractObject extends CMDBObject implements iDisplay } $sConfirmationMessage = addslashes(Dict::S('UI:NavigateAwayConfirmationMessage')); + $sJSToken = json_encode($sOwnershipToken); $oPage->add_ready_script( <<add("\n"); } } + if ($sOwnershipToken !== null) + { + $oPage->add("\n"); + } $oPage->add($oAppContext->GetForForm()); if ($sButtonsPosition != 'top') { @@ -2111,18 +2210,26 @@ EOF $oPage->add_ready_script("$('#form_{$this->m_iFormId} button.cancel').click( function() { BackToDetails('$sClass', $iKey, '$sDefaultUrl')} );"); $oPage->add("\n"); + if (isset($aExtraParams['wizard_container']) && $aExtraParams['wizard_container']) + { + $oPage->add("
\n"); + } + $iFieldsCount = count($aFieldsMap); $sJsonFieldsMap = json_encode($aFieldsMap); $sState = $this->GetState(); - + $sSessionStorageKey = $sClass.'_'.$iKey; + $oPage->add_script( <<add_ready_script( <<m_iFormId}', false); EOF -); + ); + if ($sOwnershipToken !== null) + { + $this->GetOwnershipJSHandler($oPage, $sOwnershipToken); + } + else + { + // Probably a new object (or no concurrent lock), let's add a watchdog so that the session is kept open while editing + $iInterval = MetaModel::GetConfig()->Get('concurrent_lock_expiration_delay') * 1000 / 2; + if ($iInterval > 0) + { + $iInterval = max(MIN_WATCHDOG_INTERVAL*1000, $iInterval); // Minimum interval for the watchdog is MIN_WATCHDOG_INTERVAL + $oPage->add_ready_script( +<<GetKey(); $aTransitions = $this->EnumTransitions(); $aStimuli = MetaModel::EnumStimuli($sClass); if (!isset($aTransitions[$sStimulus])) @@ -2216,6 +2344,28 @@ EOF // Invalid stimulus throw new ApplicationException(Dict::Format('UI:Error:Invalid_Stimulus_On_Object_In_State', $sStimulus, $this->GetName(), $this->GetStateLabel())); } + // Check for concurrent access lock + $LockEnabled = MetaModel::GetConfig()->Get('concurrent_lock_enabled'); + $sOwnershipToken = null; + if ($LockEnabled) + { + $sOwnershipToken = utils::ReadPostedParam('ownership_token', null, false, 'raw_data'); + $aLockInfo = iTopOwnershipLock::AcquireLock($sClass, $iKey); + if ($aLockInfo['success']) + { + $sOwnershipToken = $aLockInfo['token']; + $sOwnershipDate = $aLockInfo['acquired']; + } + else + { + $oOwner = $aLockInfo['lock']->GetOwner(); + // If the object is locked by the current user, it's worth trying again, since + // the lock may be released by 'onunload' which is called AFTER loading the current page. + //$bTryAgain = $oOwner->GetKey() == UserRights::GetUserId(); + self::ReloadAndDisplay($oPage, $this, array('operation' => 'stimulus', 'stimulus' => $sStimulus)); + return; + } + } $sActionLabel = $aStimuli[$sStimulus]->GetLabel(); $sActionDetails = $aStimuli[$sStimulus]->GetDescription(); $aTransition = $aTransitions[$sStimulus]; @@ -2304,10 +2454,15 @@ EOF $oPage->add("\n"); $oPage->add("\n"); $oPage->add("\n"); - $oPage->add("\n"); + $iTransactionId = utils::GetNewTransactionId(); + $oPage->add("\n"); + if ($sOwnershipToken !== null) + { + $oPage->add("\n"); + } $oAppContext = new ApplicationContext(); $oPage->add($oAppContext->GetForForm()); - $oPage->add("    \n"); + $oPage->add("    \n"); $oPage->add("\n"); $oPage->add("\n"); $oPage->add("\n"); @@ -2330,12 +2485,19 @@ EOF oWizardHelper.SetFieldsCount($iFieldsCount); EOF ); + $sJSToken = json_encode($sOwnershipToken); $oPage->add_ready_script( <<GetOwnershipJSHandler($oPage, $sOwnershipToken); + } } public static function ProcessZlist($aList, $aDetails, $sCurrentTab, $sCurrentCol, $sCurrentSet) @@ -3868,4 +4030,45 @@ EOF } return $aRet; } + + /** + * Generates the javascript code handle the "watchdog" associated with the concurrent access locking mechanism + * @param Webpage $oPage + * @param string $sOwnershipToken + */ + protected function GetOwnershipJSHandler($oPage, $sOwnershipToken) + { + $iInterval = max(MIN_WATCHDOG_INTERVAL, MetaModel::GetConfig()->Get('concurrent_lock_expiration_delay')) * 1000 / 2; // Minimum interval for the watchdog is MIN_WATCHDOG_INTERVAL + $sJSClass = json_encode(get_class($this)); + $iKey = (int) $this->GetKey(); + $sJSToken = json_encode($sOwnershipToken); + $sJSTitle = json_encode(Dict::S('UI:DisconnectedDlgTitle')); + $sJSOk = json_encode(Dict::S('UI:Button:Ok')); + $oPage->add_ready_script( +<<'+data.message+''); + $('
'+data.popup_message+'
').dialog({title: $sJSTitle, modal: true, autoOpen: true, buttons:[ {text: $sJSOk, click: function() { $(this).dialog('close'); } }], close: function() { $(this).remove(); }}); + } + $('.wizContainer form button.action:not(.cancel)').attr('disabled', 'disabled'); + } + else if ((data.operation == 'lost') || (data.operation == 'expired')) + { + if ($('.lock_owned').length == 0) + { + $('.ui-layout-content').prepend('
'+data.message+'
'); + $('
'+data.popup_message+'
').dialog({title: $sJSTitle, modal: true, autoOpen: true, buttons:[ {text: $sJSOk, click: function() { $(this).dialog('close'); } }], close: function() { $(this).remove(); }}); + } + $('.wizContainer form button.action:not(.cancel)').attr('disabled', 'disabled'); + } + }, 'json'); + }, $iInterval); +EOF + ); + } } diff --git a/application/displayblock.class.inc.php b/application/displayblock.class.inc.php index ad816a360..fa2276ca6 100644 --- a/application/displayblock.class.inc.php +++ b/application/displayblock.class.inc.php @@ -1384,8 +1384,20 @@ class MenuBlock extends DisplayBlock case 1: $oObj = $oSet->Fetch(); $id = $oObj->GetKey(); - $bIsModifyAllowed = (UserRights::IsActionAllowed($sClass, UR_ACTION_MODIFY, $oSet) == UR_ALLOWED_YES) && ($oReflectionClass->IsSubclassOf('cmdbAbstractObject')); - $bIsDeleteAllowed = UserRights::IsActionAllowed($sClass, UR_ACTION_DELETE, $oSet); + $bLocked = false; + if (MetaModel::GetConfig()->Get('concurrent_lock_enabled')) + { + $aLockInfo = iTopOwnershipLock::IsLocked(get_class($oObj), $id); + if ($aLockInfo['locked']) + { + $bLocked = true; + //$this->AddMenuSeparator($aActions); + //$aActions['concurrent_lock_unlock'] = array ('label' => Dict::S('UI:Menu:ReleaseConcurrentLock'), 'url' => "{$sRootUrl}pages/$sUIPage?operation=kill_lock&class=$sClass&id=$id{$sContext}"); + } + } + $bRawModifiedAllowed = (UserRights::IsActionAllowed($sClass, UR_ACTION_MODIFY, $oSet) == UR_ALLOWED_YES) && ($oReflectionClass->IsSubclassOf('cmdbAbstractObject')); + $bIsModifyAllowed = !$bLocked && $bRawModifiedAllowed; + $bIsDeleteAllowed = !$bLocked && UserRights::IsActionAllowed($sClass, UR_ACTION_DELETE, $oSet); // Just one object in the set, possible actions are "new / clone / modify and delete" if (!isset($aExtraParams['link_attr'])) { @@ -1393,22 +1405,25 @@ class MenuBlock extends DisplayBlock if ($bIsCreationAllowed) { $aActions['UI:Menu:New'] = array ('label' => Dict::S('UI:Menu:New'), 'url' => "{$sRootUrl}pages/$sUIPage?operation=new&class=$sClass{$sContext}{$sDefault}"); } if ($bIsDeleteAllowed) { $aActions['UI:Menu:Delete'] = array ('label' => Dict::S('UI:Menu:Delete'), 'url' => "{$sRootUrl}pages/$sUIPage?operation=delete&class=$sClass&id=$id{$sContext}"); } // Transitions / Stimuli - $aTransitions = $oObj->EnumTransitions(); - if (count($aTransitions)) + if (!$bLocked) { - $this->AddMenuSeparator($aActions); - $aStimuli = Metamodel::EnumStimuli(get_class($oObj)); - foreach($aTransitions as $sStimulusCode => $aTransitionDef) + $aTransitions = $oObj->EnumTransitions(); + if (count($aTransitions)) { - $iActionAllowed = (get_class($aStimuli[$sStimulusCode]) == 'StimulusUserAction') ? UserRights::IsStimulusAllowed($sClass, $sStimulusCode, $oSet) : UR_ALLOWED_NO; - switch($iActionAllowed) + $this->AddMenuSeparator($aActions); + $aStimuli = Metamodel::EnumStimuli(get_class($oObj)); + foreach($aTransitions as $sStimulusCode => $aTransitionDef) { - case UR_ALLOWED_YES: - $aActions[$sStimulusCode] = array('label' => $aStimuli[$sStimulusCode]->GetLabel(), 'url' => "{$sRootUrl}pages/UI.php?operation=stimulus&stimulus=$sStimulusCode&class=$sClass&id=$id{$sContext}"); - break; - - default: - // Do nothing + $iActionAllowed = (get_class($aStimuli[$sStimulusCode]) == 'StimulusUserAction') ? UserRights::IsStimulusAllowed($sClass, $sStimulusCode, $oSet) : UR_ALLOWED_NO; + switch($iActionAllowed) + { + case UR_ALLOWED_YES: + $aActions[$sStimulusCode] = array('label' => $aStimuli[$sStimulusCode]->GetLabel(), 'url' => "{$sRootUrl}pages/UI.php?operation=stimulus&stimulus=$sStimulusCode&class=$sClass&id=$id{$sContext}"); + break; + + default: + // Do nothing + } } } } @@ -1429,6 +1444,38 @@ class MenuBlock extends DisplayBlock } } } + if ($bLocked && $bRawModifiedAllowed) + { + // Add a special menu to kill the lock, but only to allowed users who can also modify this object + $aAllowedProfiles = MetaModel::GetConfig()->Get('concurrent_lock_override_profiles'); + $bCanKill = false; + + $oUser = UserRights::GetUserObject(); + $aUserProfiles = array(); + if (!is_null($oUser)) + { + $oProfileSet = $oUser->Get('profile_list'); + while ($oProfile = $oProfileSet->Fetch()) + { + $aUserProfiles[$oProfile->Get('profile')] = true; + } + } + + foreach($aAllowedProfiles as $sProfile) + { + if (array_key_exists($sProfile, $aUserProfiles)) + { + $bCanKill = true; + break; + } + } + + if ($bCanKill) + { + $this->AddMenuSeparator($aActions); + $aActions['concurrent_lock_unlock'] = array ('label' => Dict::S('UI:Menu:KillConcurrentLock'), 'url' => "{$sRootUrl}pages/$sUIPage?operation=kill_lock&class=$sClass&id=$id{$sContext}"); + } + } /* $this->AddMenuSeparator($aActions); // Static menus: Email this page & CSV Export diff --git a/application/itopwebpage.class.inc.php b/application/itopwebpage.class.inc.php index ef8662f91..a6ee50864 100644 --- a/application/itopwebpage.class.inc.php +++ b/application/itopwebpage.class.inc.php @@ -444,7 +444,7 @@ EOF window.bInCancel = true; if (id > 0) { - window.location.href = AddAppContext(GetAbsoluteUrlAppRoot()+'pages/UI.php?operation=details&class='+sClass+'&id='+id); + window.location.href = AddAppContext(GetAbsoluteUrlAppRoot()+'pages/UI.php?operation=release_lock_and_details&class='+sClass+'&id='+id); } else { @@ -452,7 +452,6 @@ EOF } } - function BackToList(sClass) { window.location.href = AddAppContext(GetAbsoluteUrlAppRoot()+'pages/UI.php?operation=search_oql&oql_class='+sClass+'&oql_clause=WHERE id=0'); @@ -758,7 +757,7 @@ EOF if (utils::CanLogOff()) { - $oLogOff = new URLPopupMenuItem('UI:LogOffMenu', Dict::S('UI:LogOffMenu'), utils::GetAbsoluteUrlAppRoot().'pages/logoff.php'); + $oLogOff = new URLPopupMenuItem('UI:LogOffMenu', Dict::S('UI:LogOffMenu'), utils::GetAbsoluteUrlAppRoot().'pages/logoff.php?operation=do_logoff'); $aActions[$oLogOff->GetUID()] = $oLogOff->GetMenuItem(); } if (UserRights::CanChangePassword()) diff --git a/application/portalwebpage.class.inc.php b/application/portalwebpage.class.inc.php index bbe39b995..73b6514ef 100644 --- a/application/portalwebpage.class.inc.php +++ b/application/portalwebpage.class.inc.php @@ -337,7 +337,7 @@ EOF $sMenu = ''; if ($this->m_bEnableDisconnectButton) { - $this->AddMenuButton('logoff', 'Portal:Disconnect', utils::GetAbsoluteUrlAppRoot().'pages/logoff.php'); // This menu is always present and is the last one + $this->AddMenuButton('logoff', 'Portal:Disconnect', utils::GetAbsoluteUrlAppRoot().'pages/logoff.php?operation=do_logoff'); // This menu is always present and is the last one } foreach($this->m_aMenuButtons as $aMenuItem) { @@ -796,6 +796,17 @@ EOF $this->p("

".Dict::Format('UI:Class_Object_Updated', MetaModel::GetName(get_class($oObj)), $oObj->GetName())."

\n"); } + $bLockEnabled = MetaModel::GetConfig()->Get('concurrent_lock_enabled'); + if ($bLockEnabled) + { + // Release the concurrent lock, if any + $sOwnershipToken = utils::ReadPostedParam('ownership_token', null, false, 'raw_data'); + if ($sOwnershipToken !== null) + { + // We're done, let's release the lock + iTopOwnershipLock::ReleaseLock(get_class($oObj), $oObj->GetKey(), $sOwnershipToken); + } + } } /** diff --git a/core/config.class.inc.php b/core/config.class.inc.php index d9113bd92..170c7e165 100644 --- a/core/config.class.inc.php +++ b/core/config.class.inc.php @@ -777,14 +777,6 @@ class Config 'source_of_value' => '', 'show_in_conf_sample' => false, ), - 'xlsx_exporter_cleanup_old_files_delay' => array( - 'type' => 'int', - 'description' => 'Delay (in seconds) for which to let the exported XLSX files on the server so that the user who initiated the export can download the result', - 'default' => 86400, - 'value' => '', - 'source_of_value' => '', - 'show_in_conf_sample' => false, - ), 'xlsx_exporter_memory_limit' => array( 'type' => 'string', 'description' => 'Memory limit to use when (interactively) exporting data to Excel', @@ -817,6 +809,30 @@ class Config 'source_of_value' => '', 'show_in_conf_sample' => false, ), + 'concurrent_lock_enabled' => array( + 'type' => 'bool', + 'description' => 'Whether or not to activate the locking mechanism in order to prevent concurrent edition of the same object.', + 'default' => true, + 'value' => '', + 'source_of_value' => '', + 'show_in_conf_sample' => true, + ), + 'concurrent_lock_expiration_delay' => array( + 'type' => 'integer', + 'description' => 'Delay (in seconds) for a concurrent lock to expire', + 'default' => 120, + 'value' => '', + 'source_of_value' => '', + 'show_in_conf_sample' => false, + ), + 'concurrent_lock_override_profiles' => array( + 'type' => 'array', + 'description' => 'The list of profiles allowed to "kill" a lock', + 'default' => array('Administrator'), + 'value' => '', + 'source_of_value' => '', + 'show_in_conf_sample' => false, + ), ); public function IsProperty($sPropCode) @@ -958,6 +974,7 @@ class Config 'core/action.class.inc.php', 'core/trigger.class.inc.php', 'core/bulkexport.class.inc.php', + 'core/ownershiplock.class.inc.php', 'synchro/synchrodatasource.class.inc.php', 'core/backgroundtask.class.inc.php', ); diff --git a/core/ownershiplock.class.inc.php b/core/ownershiplock.class.inc.php new file mode 100644 index 000000000..852c02731 --- /dev/null +++ b/core/ownershiplock.class.inc.php @@ -0,0 +1,352 @@ + + +/** + * Mechanism to obtain an exclusive lock while editing an object + * + * @package iTopORM + */ + +/** + * Persistent storage (in the database) for remembering that an object is locked + */ +class iTopOwnershipToken extends DBObject +{ + public static function Init() + { + $aParams = array + ( + 'category' => 'application', + 'key_type' => 'autoincrement', + 'name_attcode' => array('obj_class', 'obj_key'), + 'state_attcode' => '', + 'reconc_keys' => array(''), + 'db_table' => 'priv_ownership_token', + 'db_key_field' => 'id', + 'db_finalclass_field' => '', + ); + MetaModel::Init_Params($aParams); + MetaModel::Init_InheritAttributes(); + MetaModel::Init_AddAttribute(new AttributeDateTime("acquired", array("allowed_values"=>null, "sql"=>'acquired', "default_value"=>'', "is_null_allowed"=>false, "depends_on"=>array()))); + MetaModel::Init_AddAttribute(new AttributeDateTime("last_seen", array("allowed_values"=>null, "sql"=>'last_seen', "default_value"=>'', "is_null_allowed"=>false, "depends_on"=>array()))); + MetaModel::Init_AddAttribute(new AttributeString("obj_class", array("allowed_values"=>null, "sql"=>'obj_class', "default_value"=>'', "is_null_allowed"=>false, "depends_on"=>array()))); + MetaModel::Init_AddAttribute(new AttributeInteger("obj_key", array("allowed_values"=>null, "sql"=>'obj_key', "default_value"=>'', "is_null_allowed"=>true, "depends_on"=>array()))); + MetaModel::Init_AddAttribute(new AttributeString("token", array("allowed_values"=>null, "sql"=>'token', "default_value"=>'', "is_null_allowed"=>true, "depends_on"=>array()))); + MetaModel::Init_AddAttribute(new AttributeExternalKey("user_id", array("targetclass"=>"User", "jointype"=> '', "allowed_values"=>null, "sql"=>"user_id", "is_null_allowed"=>true, "on_target_delete"=>DEL_SILENT, "depends_on"=>array()))); + + MetaModel::Init_SetZListItems('details', array ('obj_class', 'obj_key', 'last_seen', 'token')); + MetaModel::Init_SetZListItems('standard_search', array ('obj_class', 'obj_key', 'last_seen', 'token')); + MetaModel::Init_SetZListItems('list', array ('obj_class', 'obj_key', 'last_seen', 'token')); + + } +} + +/** + * Utility class to acquire/extend/release/kill an exclusive lock on a given persistent object, + * for example to prevent concurrent edition of the same object. + * Each lock has an expiration delay of 120 seconds (tunable via the configuration parameter 'concurrent_lock_expiration_delay') + * A watchdog (called twice during this delay) is in charge of keeping the lock "alive" while an object is being edited. + */ +class iTopOwnershipLock +{ + protected $sObjClass; + protected $iObjKey; + protected $oToken; + + /** + * Acquires an exclusive lock on the specified DBObject. Once acquired, the lock is identified + * by a unique "token" string. + * @param string $sObjClass The class of the object for which to acquire the lock + * @param integer $iObjKey The identifier of the object for which to acquire the lock + * @return multitype:boolean iTopOwnershipLock Ambigous + */ + public static function AcquireLock($sObjClass, $iObjKey) + { + $oMutex = new iTopMutex('lock_'.$sObjClass.'::'.$iObjKey); + + $oMutex->Lock(); + $oOwnershipLock = new iTopOwnershipLock($sObjClass, $iObjKey); + $token = $oOwnershipLock->Acquire(); + $oMutex->Unlock(); + + return array('success' => $token !== false, 'token' => $token, 'lock' => $oOwnershipLock, 'acquired' => $oOwnershipLock->oToken->Get('acquired')); + } + + /** + * Extends the ownership lock or acquires it if none exists + * Returns a hash array with 3 elements: + * 'status': either true or false, tells if the lock is still owned + * 'owner': is status is false, the User object currently owning the lock + * 'operation': whether the lock was 'renewed' (i.e. the lock was valid, its duration has been extended) or 'acquired' (there was no valid lock for this object and a new one was created) + * @param string $sToken + * @return multitype:boolean string User + */ + public static function ExtendLock($sObjClass, $iObjKey, $sToken) + { + $oMutex = new iTopMutex('lock_'.$sObjClass.'::'.$iObjKey); + + $oMutex->Lock(); + $oOwnershipLock = new iTopOwnershipLock($sObjClass, $iObjKey); + $aResult = $oOwnershipLock->Extend($sToken); + $oMutex->Unlock(); + + return $aResult; + } + + /** + * Releases the given lock for the specified object + * + * @param string $sObjClass The class of the object + * @param int $iObjKey The identifier of the object + * @param string $sToken The string identifying the lock + * @return boolean + */ + public static function ReleaseLock($sObjClass, $iObjKey, $sToken) + { + $oMutex = new iTopMutex('lock_'.$sObjClass.'::'.$iObjKey); + + $oMutex->Lock(); + $oOwnershipLock = new iTopOwnershipLock($sObjClass, $iObjKey); + $bResult = $oOwnershipLock->Release($sToken); + self::DeleteExpiredLocks(); // Cleanup orphan locks + $oMutex->Unlock(); + + return $bResult; + } + + /** + * Kills the lock for the specified object + * + * @param string $sObjClass The class of the object + * @param int $iObjKey The identifier of the object + * @return boolean + */ + public static function KillLock($sObjClass, $iObjKey) + { + $oMutex = new iTopMutex('lock_'.$sObjClass.'::'.$iObjKey); + + $oMutex->Lock(); + $sOQL = "SELECT iTopOwnershipToken WHERE obj_class = :obj_class AND obj_key = :obj_key"; + $oSet = new DBObjectSet(DBObjectSearch::FromOQL($sOQL, array('obj_class' => $sObjClass, 'obj_key' => $iObjKey))); + while($oLock = $oSet->Fetch()) + { + $oLock->DBDelete(); + } + $oMutex->Unlock(); + } + + /** + * Checks if an exclusive lock exists on the specified DBObject. + * @param string $sObjClass The class of the object for which to acquire the lock + * @param integer $iObjKey The identifier of the object for which to acquire the lock + * @return multitype:boolean iTopOwnershipLock Ambigous + */ + public static function IsLocked($sObjClass, $iObjKey) + { + $bLocked = false; + $oMutex = new iTopMutex('lock_'.$sObjClass.'::'.$iObjKey); + + $oMutex->Lock(); + $oOwnershipLock = new iTopOwnershipLock($sObjClass, $iObjKey); + if ($oOwnershipLock->IsOwned()) + { + $bLocked = true; + } + $oMutex->Unlock(); + + return array('locked' =>$bLocked, 'owner' => $oOwnershipLock->GetOwner()); + } + + /** + * Get the current owner of the lock + * @return User + */ + public function GetOwner() + { + if ($this->IsTokenValid()) + { + return MetaModel::GetObject('User', $this->oToken->Get('user_id'), false); + } + return null; + } + + /** + * The constructor is protected. Use the static methods AcquireLock / ExtendLock / ReleaseLock / KillLock + * which are protected against concurrent access by a Mutex. + * @param string $sObjClass The class of the object for which to create a lock + * @param integer $iObjKey The identifier of the object for which to create a lock + */ + protected function __construct($sObjClass, $iObjKey) + { + $sOQL = "SELECT iTopOwnershipToken WHERE obj_class = :obj_class AND obj_key = :obj_key"; + $oSet = new DBObjectSet(DBObjectSearch::FromOQL($sOQL, array('obj_class' => $sObjClass, 'obj_key' => $iObjKey))); + $this->oToken = $oSet->Fetch(); + $this->sObjClass = $sObjClass; + $this->iObjKey = $iObjKey; + // IssueLog::Info("iTopOwnershipLock::__construct($sObjClass, $iObjKey) oToken::".($this->oToken ? $this->oToken->GetKey() : 'null')); + } + + protected function IsOwned() + { + return $this->IsTokenValid(); + } + + protected function Acquire($sToken = null) + { + if ($this->IsTokenValid()) + { + // IssueLog::Info("Acquire($sToken) returns false"); + return false; + } + else + { + $sToken = $this->TakeOwnership($sToken); + // IssueLog::Info("Acquire($sToken) returns $sToken"); + return $sToken; + } + } + + /** + * Extends the ownership lock or acquires it if none exists + * Returns a hash array with 3 elements: + * 'status': either true or false, tells if the lock is still owned + * 'owner': is status is false, the User object currently owning the lock + * 'operation': whether the lock was 'renewed' (i.e. the lock was valid, its duration was extended) or 'expired' (there was no valid lock for this object) or 'lost' (someone else grabbed it) + * 'acquired': date at which the lock was initially acquired + * @param string $sToken + * @return multitype:boolean string User + */ + protected function Extend($sToken) + { + $aResult = array('status' => true, 'owner' => '', 'operation' => 'renewed'); + + if ($this->IsTokenValid()) + { + if ($sToken === $this->oToken->Get('token')) + { + $this->oToken->Set('last_seen', date('Y-m-d H:i:s')); + $this->oToken->DBUpdate(); + $aResult['acquired'] = $this->oToken->Get('acquired'); + } + else + { + // IssueLog::Info("Extend($sToken) returns false"); + $aResult['status'] = false; + $aResult['operation'] = 'lost'; + $aResult['owner'] = $this->GetOwner(); + $aResult['acquired'] = $this->oToken->Get('acquired'); + } + } + else + { + $aResult['status'] = false; + $aResult['operation'] = 'expired'; + } + // IssueLog::Info("Extend($sToken) returns true"); + return $aResult; + } + + protected function HasOwnership($sToken) + { + $bRet = false; + if ($this->IsTokenValid()) + { + if ($sToken === $this->oToken->Get('token')) + { + $bRet = true; + } + } + // IssueLog::Info("HasOwnership($sToken) return $bRet"); + return $bRet; + } + + protected function Release($sToken) + { + $bRet = false; + // IssueLog::Info("Release... begin [$sToken]"); + if (($this->oToken) && ($sToken === $this->oToken->Get('token'))) + { + // IssueLog::Info("oToken::".$this->oToken->GetKey().' ('.$sToken.') to be deleted'); + $this->oToken->DBDelete(); + // IssueLog::Info("oToken deleted"); + $this->oToken = null; + $bRet = true; + } + else if ($this->oToken == null) + { + // IssueLog::Info("Release FAILED oToken == null !!!"); + } + else + { + // IssueLog::Info("Release FAILED inconsistent tokens: sToken=\"".$sToken.'", oToken->Get(\'token\')="'.$this->oToken->Get('token').'"'); + } + // IssueLog::Info("Release... end"); + return $bRet; + } + + protected function IsTokenValid() + { + $bRet = false; + if ($this->oToken != null) + { + $sToken = $this->oToken->Get('token'); + $sDate = $this->oToken->Get('last_seen'); + if (($sDate != '') && ($sToken != '')) + { + $oLastSeenTime = new DateTime($sDate); + $iNow = date('U'); + if (($iNow - $oLastSeenTime->format('U')) < MetaModel::GetConfig()->Get('concurrent_lock_expiration_delay')) + { + $bRet = true; + } + } + } + return $bRet; + } + + protected function TakeOwnership($sToken = null) + { + if ($this->oToken == null) + { + $this->oToken = new iTopOwnershipToken(); + $this->oToken->Set('obj_class', $this->sObjClass); + $this->oToken->Set('obj_key', $this->iObjKey); + } + $this->oToken->Set('acquired', date('Y-m-d H:i:s')); + $this->oToken->Set('user_id', UserRights::GetUserId()); + $this->oToken->Set('last_seen', date('Y-m-d H:i:s')); + if ($sToken === null) + { + $sToken = sprintf('%X', microtime(true)); + } + $this->oToken->Set('token', $sToken); + $this->oToken->DBWrite(); + return $this->oToken->Get('token'); + } + + protected static function DeleteExpiredLocks() + { + $sOQL = "SELECT iTopOwnershipToken WHERE last_seen < :last_seen_limit"; + $oSet = new DBObjectSet(DBObjectSearch::FromOQL($sOQL, array('last_seen_limit' => date('Y-m-d H:i:s', time() - MetaModel::GetConfig()->Get('concurrent_lock_expiration_delay'))))); + while($oToken = $oSet->Fetch()) + { + $oToken->DBDelete(); + } + + } +} \ No newline at end of file diff --git a/dictionaries/de.dictionary.itop.ui.php b/dictionaries/de.dictionary.itop.ui.php index 89597f615..e56ee5779 100644 --- a/dictionaries/de.dictionary.itop.ui.php +++ b/dictionaries/de.dictionary.itop.ui.php @@ -991,5 +991,11 @@ Wenn Aktionen mit Trigger verknüpft sind, bekommt jede Aktion eine Auftragsnumm 'UI:DisconnectedDlgTitle' => 'Warning!~~', 'UI:LoginAgain' => 'Login again~~', 'UI:StayOnThePage' => 'Stay on this page~~', + 'UI:CurrentObjectIsLockedBy_User' => 'The object is locked since it is currently being modified by %1$s.~~', + 'UI:CurrentObjectIsLockedBy_User_Explanation' => 'The object is currently being modified by %1$s. Your modifications cannot be submitted since they would be overwritten.~~', + 'UI:CurrentObjectLockExpired' => 'The lock to prevent concurrent modifications of the object has expired.~~', + 'UI:CurrentObjectLockExpired_Explanation' => 'The lock to prevent concurrent modifications of the object has expired. You can no longer submit your modification since other users are now allowed to modify this object.~~', + 'UI:ConcurrentLockKilled' => 'The lock preventing modifications on the current object has been deleted.~~', + 'UI:Menu:KillConcurrentLock' => 'Kill the Concurrent Modification Lock !~~', )); ?> diff --git a/dictionaries/dictionary.itop.ui.php b/dictionaries/dictionary.itop.ui.php index d1ead1914..a090b0d08 100644 --- a/dictionaries/dictionary.itop.ui.php +++ b/dictionaries/dictionary.itop.ui.php @@ -1257,5 +1257,12 @@ When associated with a trigger, each action is given an "order" number, specifyi 'ExcelExport:Statistics' => 'Statistics', 'portal:legacy_portal' => 'End-User Portal', 'portal:backoffice' => 'iTop Back-Office User Interface', + + 'UI:CurrentObjectIsLockedBy_User' => 'The object is locked since it is currently being modified by %1$s.', + 'UI:CurrentObjectIsLockedBy_User_Explanation' => 'The object is currently being modified by %1$s. Your modifications cannot be submitted since they would be overwritten.', + 'UI:CurrentObjectLockExpired' => 'The lock to prevent concurrent modifications of the object has expired.', + 'UI:CurrentObjectLockExpired_Explanation' => 'The lock to prevent concurrent modifications of the object has expired. You can no longer submit your modification since other users are now allowed to modify this object.', + 'UI:ConcurrentLockKilled' => 'The lock preventing modifications on the current object has been deleted.', + 'UI:Menu:KillConcurrentLock' => 'Kill the Concurrent Modification Lock !', )); ?> diff --git a/dictionaries/fr.dictionary.itop.ui.php b/dictionaries/fr.dictionary.itop.ui.php index 97531b3f2..1f759cefd 100644 --- a/dictionaries/fr.dictionary.itop.ui.php +++ b/dictionaries/fr.dictionary.itop.ui.php @@ -1099,5 +1099,12 @@ Lors de l\'association à un déclencheur, on attribue à chaque action un numé 'ExcelExport:Statistics' => 'Statistiques', 'portal:legacy_portal' => 'Portail Utilisateurs', 'portal:backoffice' => 'Console iTop', + + 'UI:CurrentObjectIsLockedBy_User' => 'L\'objet est verrouillé car il est en train d\'être modifié par %1$s.', + 'UI:CurrentObjectIsLockedBy_User_Explanation' => 'L\'objet est en train d\'être modifié par %1$s. Vos modifications ne peuvent pas être acceptées car elles risquent d\'être écrasées.', + 'UI:CurrentObjectLockExpired' => 'Le verrouillage interdisant les modifications concurrentes à expiré.', + 'UI:CurrentObjectLockExpired_Explanation' => 'Le verrouillage interdisant les modifications concurrentes à expiré. Vos modifications ne peuvent pas être acceptées car d\'autres utilisateurs peuvent modifier cet objet.', + 'UI:ConcurrentLockKilled' => 'Le verrouillage en édition de l\'objet courant a été supprimé.', + 'UI:Menu:KillConcurrentLock' => 'Supprimer le verrouillage !', )); -?> +?> \ No newline at end of file diff --git a/js/forms-json-utils.js b/js/forms-json-utils.js index db5598691..8d71cff21 100644 --- a/js/forms-json-utils.js +++ b/js/forms-json-utils.js @@ -90,14 +90,17 @@ function ActivateStep(iTargetStep) //$('#wizStep'+(iTargetStep)).block({ message: null }); } -function OnUnload(sTransactionId) +function OnUnload(sTransactionId, sObjClass, iObjKey, sToken) { if (!window.bInSubmit) { // If it's not a submit, then it's a "cancel" (Pressing the Cancel button, closing the window, using the back button...) - $.post(GetAbsoluteUrlAppRoot()+'pages/ajax.render.php', {operation: 'on_form_cancel', transaction_id: sTransactionId }, function() - { - // Do nothing for now... + // IMPORTANT: the ajax request MUST BE synchronous to be executed in this context + $.ajax({ + url: GetAbsoluteUrlAppRoot()+'pages/ajax.render.php', + async: false, + method: 'POST', + data: {operation: 'on_form_cancel', transaction_id: sTransactionId, obj_class: sObjClass, obj_key: iObjKey, token: sToken } }); } } diff --git a/pages/UI.php b/pages/UI.php index f038045d1..1fe8d783e 100644 --- a/pages/UI.php +++ b/pages/UI.php @@ -357,6 +357,13 @@ try } } break; + + case 'release_lock_and_details': + $sClass = utils::ReadParam('class', ''); + $id = utils::ReadParam('id', ''); + $oObj = MetaModel::GetObject($sClass, $id); + cmdbAbstractObject::ReloadAndDisplay($oP, $oObj, array('operation' => 'details')); + break; /////////////////////////////////////////////////////////////////////////////////////////// @@ -540,7 +547,6 @@ EOF case 'modify': // Form to modify an object $sClass = utils::ReadParam('class', '', false, 'class'); - $sClassLabel = MetaModel::GetName($sClass); $id = utils::ReadParam('id', ''); if ( empty($sClass) || empty($id)) // TO DO: check that the class name is valid ! { @@ -562,14 +568,7 @@ EOF throw new SecurityException('User not allowed to modify this object', array('class' => $sClass, 'id' => $id)); } // Note: code duplicated to the case 'apply_modify' when a data integrity issue has been found - $oP->set_title(Dict::Format('UI:ModificationPageTitle_Object_Class', $oObj->GetRawName(), $sClassLabel)); // Set title will take care of the encoding - $oP->add("
\n"); - $oP->add("

".$oObj->GetIcon()." ".Dict::Format('UI:ModificationTitle_Class_Object', $sClassLabel, $oObj->GetName())."

\n"); - $oP->add("
\n"); - - $oP->add("
\n"); - $oObj->DisplayModifyForm($oP); - $oP->add("
\n"); + $oObj->DisplayModifyForm($oP, array('wizard_container' => 1)); // wizard_container: Display the blue borders and the title above the form } break; @@ -792,20 +791,14 @@ EOF $bDisplayDetails = false; // Found issues, explain and give the user a second chance // - $oP->set_title(Dict::Format('UI:ModificationPageTitle_Object_Class', $oObj->GetRawName(), $sClassLabel)); // Set title will take care of the encoding - $oP->add("
\n"); - $oP->add("

".$oObj->GetIcon()." ".Dict::Format('UI:ModificationTitle_Class_Object', $sClassLabel, $oObj->GetName())."

\n"); - $oP->add("
\n"); - $oP->add("
\n"); - $oObj->DisplayModifyForm($oP); - $oP->add("
\n"); + $oObj->DisplayModifyForm($oP, array('wizard_container' => true), $sToken); // wizard_container: display the wizard border and the title $sIssueDesc = Dict::Format('UI:ObjectCouldNotBeWritten', implode(', ', $aIssues)); $oP->add_ready_script("alert('".addslashes($sIssueDesc)."');"); } } } if ($bDisplayDetails) - { + { $oObj = MetaModel::GetObject(get_class($oObj), $oObj->GetKey()); //Workaround: reload the object so that the linkedset are displayed properly $sNextAction = utils::ReadPostedParam('next_action', ''); if (!empty($sNextAction)) @@ -817,6 +810,18 @@ EOF // Nothing more to do ReloadAndDisplay($oP, $oObj, 'update', $sMessage, $sSeverity); } + + $bLockEnabled = MetaModel::GetConfig()->Get('concurrent_lock_enabled'); + if ($bLockEnabled) + { + // Release the concurrent lock, if any + $sOwnershipToken = utils::ReadPostedParam('ownership_token', null, false, 'raw_data'); + if ($sOwnershipToken !== null) + { + // We're done, let's release the lock + iTopOwnershipLock::ReleaseLock(get_class($oObj), $oObj->GetKey(), $sOwnershipToken); + } + } } break; @@ -1516,6 +1521,16 @@ EOF $oP->SetCurrentTab(''); break; + /////////////////////////////////////////////////////////////////////////////////////////// + + case 'kill_lock': + $sClass = utils::ReadParam('class', ''); + $id = utils::ReadParam('id', ''); + iTopOwnershipLock::KillLock($sClass, $id); + $oObj = MetaModel::GetObject($sClass, $id); + ReloadAndDisplay($oP, $oObj, 'concurrent_lock_killed', Dict::S('UI:ConcurrentLockKilled'), 'info'); + break; + /////////////////////////////////////////////////////////////////////////////////////////// case 'cancel': // An action was cancelled diff --git a/pages/ajax.render.php b/pages/ajax.render.php index 58aca80f3..1a0b6d54e 100644 --- a/pages/ajax.render.php +++ b/pages/ajax.render.php @@ -825,6 +825,13 @@ try { $oExtensionInstance->OnFormCancel($sTempId); } + $sObjClass = utils::ReadParam('obj_class', '', false, 'class'); + $iObjKey = (int)utils::ReadParam('obj_key', 0, false, 'integer'); + $sToken = utils::ReadParam('token', 0, false, 'raw_data'); + if (($sObjClass != '') && ($iObjKey != 0) && ($sToken != '')) + { + $bReleaseLock = iTopOwnershipLock::ReleaseLock($sObjClass, $iObjKey, $sToken); + } break; case 'reload_dashboard': @@ -2143,6 +2150,36 @@ EOF $aResult = array('code' => 'error', 'percentage' => 100, 'message' => Dict::S('Core:BulkExport:ExportCancelledByUser')); $oPage->add(json_encode($aResult)); break; + + case 'extend_lock': + $sObjClass = utils::ReadParam('obj_class', '', false, 'class'); + $iObjKey = (int)utils::ReadParam('obj_key', 0, false, 'integer'); + $sToken = utils::ReadParam('token', 0, false, 'raw_data'); + $aResult = iTopOwnershipLock::ExtendLock($sObjClass, $iObjKey, $sToken); + if (!$aResult['status']) + { + if ($aResult['operation'] == 'lost') + { + $sName = $aResult['owner']->GetName(); + if ($aResult['owner']->Get('contactid') != 0) + { + $sName .= ' ('.$aResult['owner']->Get('contactid_friendlyname').')'; + } + $aResult['message'] = Dict::Format('UI:CurrentObjectIsLockedBy_User', $sName); + $aResult['popup_message'] = Dict::Format('UI:CurrentObjectIsLockedBy_User_Explanation', $sName); + } + else if ($aResult['operation'] == 'expired') + { + $aResult['message'] = Dict::S('UI:CurrentObjectLockExpired'); + $aResult['popup_message'] = Dict::S('UI:CurrentObjectLockExpired_Explanation'); + } + } + $oPage->add(json_encode($aResult)); + break; + + case 'watchdog': + $oPage->add('ok'); // Better for debugging... + break; default: $oPage->p("Invalid query."); diff --git a/pages/logoff.php b/pages/logoff.php index dc73052e6..a2a9ca4af 100644 --- a/pages/logoff.php +++ b/pages/logoff.php @@ -25,10 +25,22 @@ require_once(APPROOT.'/application/startup.inc.php'); $oAppContext = new ApplicationContext(); $currentOrganization = utils::ReadParam('org_id', ''); $operation = utils::ReadParam('operation', ''); - require_once(APPROOT.'/application/loginwebpage.class.inc.php'); +require_once(APPROOT.'/application/ajaxwebpage.class.inc.php'); $bPortal = utils::ReadParam('portal', false); $sUrl = utils::GetAbsoluteUrlAppRoot(); + +if ($operation == 'do_logoff') +{ + // Reload the same dummy page to let the "calling" page execute its 'onunload' method before performing the actual logoff. + // Note the redirection MUST NOT be made via an HTTP "header" since onunload is called only when the actual content of the DOM + // is replaced by some other content. So the "bouncing" page must provide some content (in our case a script making the redirection). + $oPage = new ajax_page(''); + $oPage->add_script("window.location.href='{$sUrl}pages/logoff.php?portal=$bPortal'"); + $oPage->output(); + exit; +} + if ($bPortal) { $sUrl .= 'portal/'; @@ -37,7 +49,6 @@ else { $sUrl .= 'pages/UI.php'; } - if (isset($_SESSION['auth_user'])) { $sAuthUser = $_SESSION['auth_user'];