Quellcode durchsuchen

#765: prevent two persons to edit the same object at the same time.

git-svn-id: http://svn.code.sf.net/p/itop/code/trunk@3617 a333f486-631f-4898-b8df-5754b55c2be0
dflaven vor 10 Jahren
Ursprung
Commit
968ec277fb

+ 221 - 18
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(
+<<<EOF
+	if (!sessionStorage.getItem('$sSessionStorageKey'))
+	{
+		sessionStorage.setItem('$sSessionStorageKey', 1);
+		window.location.href= "$sUrl";
+	}
+	else
+	{
+		sessionStorage.removeItem('$sSessionStorageKey');
+	}
+EOF
+		);
+
+		$oObj->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[] = "<div class=\"header_message message_error\">".Dict::Format('UI:CurrentObjectIsLockedBy_User', $sName)."</div>";
+			}
+		}
 		$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[] = "<div class=\"header_message $sMsgClass\">".$aMessageData['message']."</div>";
 				$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("<div class=\"page_header\">\n");
+			$oPage->add("<h1>".$this->GetIcon()."&nbsp;".Dict::Format('UI:ModificationTitle_Class_Object', $sClassLabel, $this->GetName())."</h1>\n");
+			$oPage->add("</div>\n");
+			$oPage->add("<div class=\"wizContainer\">\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(
 <<<EOF
-	$(window).unload(function() { return OnUnload('$iTransactionId') } );
+	$(window).unload(function() { return OnUnload('$iTransactionId', '$sClass', $iKey, $sJSToken) } );
 	window.onbeforeunload = function() {
 		if (!window.bInSubmit && !window.bInCancel)
 		{
@@ -2098,6 +2193,10 @@ EOF
 				$oPage->add("<input type=\"hidden\" name=\"$sName\" value=\"$value\">\n");
 			}
 		}
+		if ($sOwnershipToken !== null)
+		{
+			$oPage->add("<input type=\"hidden\" name=\"ownership_token\" value=\"".htmlentities($sOwnershipToken, ENT_QUOTES, 'UTF-8')."\">\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("</form>\n");
 		
+		if (isset($aExtraParams['wizard_container']) && $aExtraParams['wizard_container'])
+		{
+			$oPage->add("</div>\n");
+		}
+		
 		$iFieldsCount = count($aFieldsMap);
 		$sJsonFieldsMap = json_encode($aFieldsMap);
 		$sState = $this->GetState();
-
+		$sSessionStorageKey = $sClass.'_'.$iKey;
+		
 		$oPage->add_script(
 <<<EOF
+		sessionStorage.removeItem('$sSessionStorageKey');
+		
 		// Create the object once at the beginning of the page...
 		var oWizardHelper$sPrefix = new WizardHelper('$sClass', '$sPrefix', '$sState');
 		oWizardHelper$sPrefix.SetFieldsMap($sJsonFieldsMap);
 		oWizardHelper$sPrefix.SetFieldsCount($iFieldsCount);
 EOF
-);
+		);
 		$oPage->add_ready_script(
 <<<EOF
 		oWizardHelper$sPrefix.UpdateWizard();
@@ -2130,7 +2237,27 @@ EOF
 		CheckFields('form_{$this->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(
+<<<EOF
+				window.setInterval(function() {
+					$.post(GetAbsoluteUrlAppRoot()+'pages/ajax.render.php', {operation: 'watchdog'});
+				}, $iInterval);
+EOF
+				);
+			}			
+		}
 	}
 
 	public static function DisplayCreationForm(WebPage $oPage, $sClass, $oObjectToClone = null, $aArgs = array(), $aExtraParams = array())
@@ -2209,6 +2336,7 @@ EOF
 	public function DisplayStimulusForm(WebPage $oPage, $sStimulus)
 	{
 		$sClass = get_class($this);
+		$iKey = $this->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("<input type=\"hidden\" name=\"class\" value=\"$sClass\">\n");
 		$oPage->add("<input type=\"hidden\" name=\"operation\" value=\"apply_stimulus\">\n");
 		$oPage->add("<input type=\"hidden\" name=\"stimulus\" value=\"$sStimulus\">\n");
-		$oPage->add("<input type=\"hidden\" name=\"transaction_id\" value=\"".utils::GetNewTransactionId()."\">\n");
+		$iTransactionId = utils::GetNewTransactionId();
+		$oPage->add("<input type=\"hidden\" name=\"transaction_id\" value=\"".$iTransactionId."\">\n");
+		if ($sOwnershipToken !== null)
+		{
+			$oPage->add("<input type=\"hidden\" name=\"ownership_token\" value=\"".htmlentities($sOwnershipToken, ENT_QUOTES, 'UTF-8')."\">\n");
+		}
 		$oAppContext = new ApplicationContext();
 		$oPage->add($oAppContext->GetForForm());
-		$oPage->add("<button type=\"button\" class=\"action\" onClick=\"BackToDetails('$sClass', ".$this->GetKey().")\"><span>".Dict::S('UI:Button:Cancel')."</span></button>&nbsp;&nbsp;&nbsp;&nbsp;\n");
+		$oPage->add("<button type=\"button\" class=\"action cancel\" onClick=\"BackToDetails('$sClass', ".$this->GetKey().")\"><span>".Dict::S('UI:Button:Cancel')."</span></button>&nbsp;&nbsp;&nbsp;&nbsp;\n");
 		$oPage->add("<button type=\"submit\" class=\"action\"><span>$sActionLabel</span></button>\n");
 		$oPage->add("</form>\n");
 		$oPage->add("</div>\n");
@@ -2330,12 +2485,19 @@ EOF
 		oWizardHelper.SetFieldsCount($iFieldsCount);
 EOF
 		);
+		$sJSToken = json_encode($sOwnershipToken);
 		$oPage->add_ready_script(
 <<<EOF
 		// Starts the validation when the page is ready
 		CheckFields('apply_stimulus', false);
+		$(window).unload(function() { return OnUnload('$iTransactionId', '$sClass', $iKey, $sJSToken) } );
 EOF
-		);		
+		);
+		
+		if ($sOwnershipToken !== null)
+		{
+			$this->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(
+<<<EOF
+		window.setInterval(function() {
+			$.post(GetAbsoluteUrlAppRoot()+'pages/ajax.render.php', {operation: 'extend_lock', obj_class: $sJSClass, obj_key: $iKey, token: $sJSToken }, function(data) {
+				if (!data.status)
+				{
+					if ($('.lock_owned').length == 0)
+					{
+						$('.ui-layout-content').prepend('<div class="header_message message_error lock_owned">'+data.message+'</div>');
+						$('<div>'+data.popup_message+'</div>').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('<div class="header_message message_error lock_owned">'+data.message+'</div>');
+						$('<div>'+data.popup_message+'</div>').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
+		);
+	}
 }

+ 62 - 15
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

+ 2 - 3
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())

+ 12 - 1
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("<h1>".Dict::Format('UI:Class_Object_Updated', MetaModel::GetName(get_class($oObj)), $oObj->GetName())."</h1>\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);
+			}
+		}
 	}
 
 	/**

+ 25 - 8
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',
 		);

+ 352 - 0
core/ownershiplock.class.inc.php

@@ -0,0 +1,352 @@
+<?php
+// Copyright (C) 2015 Combodo SARL
+//
+//   This file is part of iTop.
+//
+//   iTop is free software; you can redistribute it and/or modify
+//   it under the terms of the GNU Affero General Public License as published by
+//   the Free Software Foundation, either version 3 of the License, or
+//   (at your option) any later version.
+//
+//   iTop is distributed in the hope that it will be useful,
+//   but WITHOUT ANY WARRANTY; without even the implied warranty of
+//   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+//   GNU Affero General Public License for more details.
+//
+//   You should have received a copy of the GNU Affero General Public License
+//   along with iTop. If not, see <http://www.gnu.org/licenses/>
+
+/**
+ * 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 <boolean, string, DBObjectSet>
+	 */
+	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 <boolean, string, DBObjectSet>
+	 */
+	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();
+		}
+		
+	}
+}

+ 6 - 0
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 !~~',
 ));
 ?>

+ 7 - 0
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 !',
 ));
 ?>

+ 8 - 1
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 !',
 ));
-?>
+?>

+ 7 - 4
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 }
 		});
 	}
 }

+ 32 - 17
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("<div class=\"page_header\">\n");
-				$oP->add("<h1>".$oObj->GetIcon()."&nbsp;".Dict::Format('UI:ModificationTitle_Class_Object', $sClassLabel, $oObj->GetName())."</h1>\n");
-				$oP->add("</div>\n");
-
-				$oP->add("<div class=\"wizContainer\">\n");
-				$oObj->DisplayModifyForm($oP);
-				$oP->add("</div>\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("<div class=\"page_header\">\n");
-						$oP->add("<h1>".$oObj->GetIcon()."&nbsp;".Dict::Format('UI:ModificationTitle_Class_Object', $sClassLabel, $oObj->GetName())."</h1>\n");
-						$oP->add("</div>\n");
-						$oP->add("<div class=\"wizContainer\">\n");
-						$oObj->DisplayModifyForm($oP);
-						$oP->add("</div>\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;
 
@@ -1517,6 +1522,16 @@ EOF
 		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
 		$oP->set_title(Dict::S('UI:OperationCancelled'));

+ 37 - 0
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.");

+ 13 - 2
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'];