mirror of
https://github.com/Combodo/iTop.git
synced 2026-04-24 02:58:43 +02:00
N°4921 - Add support for attcode & attvalue parameters in URL to access an object (#273)
This is a way to solve problems when an object ref and id isn't equals : for example id=99 and ref = 100. This could happen since iTop 2.7.0, see https://www.itophub.io/wiki/page?id=2_7_0%3Arelease%3A2_7_whats_new#ticket_ref_generation Note that id parameter can be set to the object's friendlyname as a workaround, but this might not be enough for some objects where friendlyname contains more that the ref field (for example title, org, ...) * Admin console : new UI.php URL parameters : attcode and attvalue. Example URLs : /pages/UI.php?operation=details&class=UserRequest&id=99 /pages/UI.php?operation=details&class=UserRequest&attcode=ref&attvalue=R-000100 An exception will be thrown if no object is found or multiple instances are. * User portal New route : /object/view/{sObjectClass}/{sObjectAttCode}/{sObjectAttValue} For example : /pages/exec.php/object/view/UserRequest/99?exec_module=itop-portal-base&exec_page=index.php&portal_id=itop-portal /pages/exec.php/object/view/UserRequest/ref/R-000100?exec_module=itop-portal-base&exec_page=index.php&portal_id=itop-portal On error we will get a 404 error page
This commit is contained in:
@@ -7145,20 +7145,28 @@ abstract class MetaModel
|
|||||||
/**
|
/**
|
||||||
* @param string $sClass
|
* @param string $sClass
|
||||||
* @param string $sAttCode
|
* @param string $sAttCode
|
||||||
* @param $value
|
* @param mixed $value
|
||||||
* @param bool $bMustBeFoundUnique
|
* @param bool $bMustBeFoundUnique
|
||||||
|
* @param bool $bAllowAllData
|
||||||
|
*
|
||||||
|
* @return \DBObject if $bMustBeFoundUnique=true and no object or multiple objects found will throw a CoreException
|
||||||
|
* else will return null
|
||||||
*
|
*
|
||||||
* @return \DBObject
|
|
||||||
* @throws \CoreException
|
* @throws \CoreException
|
||||||
* @throws \Exception
|
* @throws \CoreUnexpectedValue
|
||||||
|
* @throws \MissingQueryArgument
|
||||||
|
* @throws \MySQLException
|
||||||
|
* @throws \MySQLHasGoneAwayException
|
||||||
|
*
|
||||||
|
* @since 2.7.7 Add new $bAllowAllData parameter
|
||||||
*/
|
*/
|
||||||
public static function GetObjectByColumn($sClass, $sAttCode, $value, $bMustBeFoundUnique = true)
|
public static function GetObjectByColumn($sClass, $sAttCode, $value, $bMustBeFoundUnique = true, $bAllowAllData = false)
|
||||||
{
|
{
|
||||||
if (!isset(self::$m_aCacheObjectByColumn[$sClass][$sAttCode][$value]))
|
if (!isset(self::$m_aCacheObjectByColumn[$sClass][$sAttCode][$value])) {
|
||||||
{
|
|
||||||
self::_check_subclass($sClass);
|
self::_check_subclass($sClass);
|
||||||
|
|
||||||
$oObjSearch = new DBObjectSearch($sClass);
|
$oObjSearch = new DBObjectSearch($sClass);
|
||||||
|
$oObjSearch->AllowAllData($bAllowAllData);
|
||||||
$oObjSearch->AddCondition($sAttCode, $value, '=');
|
$oObjSearch->AddCondition($sAttCode, $value, '=');
|
||||||
$oSet = new DBObjectSet($oObjSearch);
|
$oSet = new DBObjectSet($oObjSearch);
|
||||||
if ($oSet->Count() == 1)
|
if ($oSet->Count() == 1)
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ p_object_view:
|
|||||||
defaults:
|
defaults:
|
||||||
_controller: 'Combodo\iTop\Portal\Controller\ObjectController::ViewAction'
|
_controller: 'Combodo\iTop\Portal\Controller\ObjectController::ViewAction'
|
||||||
|
|
||||||
|
p_object_view_from_attribute:
|
||||||
|
path: '/object/view/{sObjectClass}/{sObjectAttCode}/{sObjectAttValue}'
|
||||||
|
defaults:
|
||||||
|
_controller: 'Combodo\iTop\Portal\Controller\ObjectController::ViewFromAttributeAction'
|
||||||
|
|
||||||
p_object_apply_stimulus:
|
p_object_apply_stimulus:
|
||||||
path: '/object/apply-stimulus/{sStimulusCode}/{sObjectClass}/{sObjectId}'
|
path: '/object/apply-stimulus/{sStimulusCode}/{sObjectClass}/{sObjectId}'
|
||||||
defaults:
|
defaults:
|
||||||
|
|||||||
@@ -66,11 +66,11 @@ class ObjectController extends BrickController
|
|||||||
const DEFAULT_LIST_LENGTH = 10;
|
const DEFAULT_LIST_LENGTH = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays an cmdbAbstractObject if the connected user is allowed to.
|
* Displays an cmdbAbstractObject (from its ID) if the connected user is allowed to.
|
||||||
*
|
*
|
||||||
* @param \Symfony\Component\HttpFoundation\Request $oRequest
|
* @param \Symfony\Component\HttpFoundation\Request $oRequest
|
||||||
* @param string $sObjectClass (Class must be instance of cmdbAbstractObject)
|
* @param string $sObjectClass (Class must be an instance of cmdbAbstractObject)
|
||||||
* @param string $sObjectId
|
* @param string $sObjectId
|
||||||
*
|
*
|
||||||
* @return \Symfony\Component\HttpFoundation\Response
|
* @return \Symfony\Component\HttpFoundation\Response
|
||||||
*
|
*
|
||||||
@@ -83,29 +83,19 @@ class ObjectController extends BrickController
|
|||||||
*/
|
*/
|
||||||
public function ViewAction(Request $oRequest, $sObjectClass, $sObjectId)
|
public function ViewAction(Request $oRequest, $sObjectClass, $sObjectId)
|
||||||
{
|
{
|
||||||
/** @var \Combodo\iTop\Portal\Helper\RequestManipulatorHelper $oRequestManipulator */
|
|
||||||
$oRequestManipulator = $this->get('request_manipulator');
|
|
||||||
/** @var \Combodo\iTop\Portal\Routing\UrlGenerator $oUrlGenerator */
|
|
||||||
$oUrlGenerator = $this->get('url_generator');
|
|
||||||
/** @var \Combodo\iTop\Portal\Helper\ObjectFormHandlerHelper $oObjectFormHandler */
|
|
||||||
$oObjectFormHandler = $this->get('object_form_handler');
|
|
||||||
/** @var \Combodo\iTop\Portal\Helper\SecurityHelper $oSecurityHelper */
|
/** @var \Combodo\iTop\Portal\Helper\SecurityHelper $oSecurityHelper */
|
||||||
$oSecurityHelper = $this->get('security_helper');
|
$oSecurityHelper = $this->get('security_helper');
|
||||||
/** @var \Combodo\iTop\Portal\Helper\ScopeValidatorHelper $oScopeValidator */
|
/** @var \Combodo\iTop\Portal\Helper\ScopeValidatorHelper $oScopeValidator */
|
||||||
$oScopeValidator = $this->get('scope_validator');
|
$oScopeValidator = $this->get('scope_validator');
|
||||||
/** @var \Combodo\iTop\Portal\Brick\BrickCollection $oBrickCollection */
|
|
||||||
$oBrickCollection = $this->get('brick_collection');
|
|
||||||
|
|
||||||
// Checking parameters
|
// Checking parameters
|
||||||
if ($sObjectClass === '' || $sObjectId === '')
|
if ($sObjectClass === '' || $sObjectId === '') {
|
||||||
{
|
|
||||||
IssueLog::Info(__METHOD__.' at line '.__LINE__.' : sObjectClass and sObjectId expected, "'.$sObjectClass.'" and "'.$sObjectId.'" given.');
|
IssueLog::Info(__METHOD__.' at line '.__LINE__.' : sObjectClass and sObjectId expected, "'.$sObjectClass.'" and "'.$sObjectId.'" given.');
|
||||||
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, Dict::Format('UI:Error:2ParametersMissing', 'class', 'id'));
|
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, Dict::Format('UI:Error:2ParametersMissing', 'class', 'id'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checking security layers
|
// Checking security layers
|
||||||
if (!$oSecurityHelper->IsActionAllowed(UR_ACTION_READ, $sObjectClass, $sObjectId))
|
if (!$oSecurityHelper->IsActionAllowed(UR_ACTION_READ, $sObjectClass, $sObjectId)) {
|
||||||
{
|
|
||||||
IssueLog::Warning(__METHOD__.' at line '.__LINE__.' : User #'.UserRights::GetUserId().' not allowed to read '.$sObjectClass.'::'.$sObjectId.' object.');
|
IssueLog::Warning(__METHOD__.' at line '.__LINE__.' : User #'.UserRights::GetUserId().' not allowed to read '.$sObjectClass.'::'.$sObjectId.' object.');
|
||||||
throw new HttpException(Response::HTTP_NOT_FOUND, Dict::S('UI:ObjectDoesNotExist'));
|
throw new HttpException(Response::HTTP_NOT_FOUND, Dict::S('UI:ObjectDoesNotExist'));
|
||||||
}
|
}
|
||||||
@@ -113,14 +103,97 @@ class ObjectController extends BrickController
|
|||||||
// Retrieving object
|
// Retrieving object
|
||||||
$oObject = MetaModel::GetObject($sObjectClass, $sObjectId, false /* MustBeFound */,
|
$oObject = MetaModel::GetObject($sObjectClass, $sObjectId, false /* MustBeFound */,
|
||||||
$oScopeValidator->IsAllDataAllowedForScope(UserRights::ListProfiles(), $sObjectClass));
|
$oScopeValidator->IsAllDataAllowedForScope(UserRights::ListProfiles(), $sObjectClass));
|
||||||
if ($oObject === null)
|
if ($oObject === null) {
|
||||||
{
|
|
||||||
// We should never be there as the secuirty helper makes sure that the object exists, but just in case.
|
// We should never be there as the secuirty helper makes sure that the object exists, but just in case.
|
||||||
IssueLog::Info(__METHOD__.' at line '.__LINE__.' : Could not load object '.$sObjectClass.'::'.$sObjectId.'.');
|
IssueLog::Info(__METHOD__.' at line '.__LINE__.' : Could not load object '.$sObjectClass.'::'.$sObjectId.'.');
|
||||||
throw new HttpException(Response::HTTP_NOT_FOUND, Dict::S('UI:ObjectDoesNotExist'));
|
throw new HttpException(Response::HTTP_NOT_FOUND, Dict::S('UI:ObjectDoesNotExist'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->PrepareViewObjectResponse($oRequest, $oObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays an cmdbAbstractObject (if the connected user is allowed to) from a specific attribute. If several or none objects are found with the attribute value, an exception is thrown.
|
||||||
|
*
|
||||||
|
* @param \Symfony\Component\HttpFoundation\Request $oRequest
|
||||||
|
* @param string $sObjectClass (Class must be an instance of cmdbAbstractObject)
|
||||||
|
* @param string $sObjectAttCode
|
||||||
|
* @param string $sObjectAttValue
|
||||||
|
*
|
||||||
|
* @return \Symfony\Component\HttpFoundation\JsonResponse|\Symfony\Component\HttpFoundation\Response|null
|
||||||
|
* @throws \CoreException
|
||||||
|
* @throws \CoreUnexpectedValue
|
||||||
|
* @throws \MissingQueryArgument
|
||||||
|
* @throws \MySQLException
|
||||||
|
* @throws \MySQLHasGoneAwayException
|
||||||
|
* @throws \OQLException
|
||||||
|
*
|
||||||
|
* @since 2.7.7 method creation
|
||||||
|
*/
|
||||||
|
public function ViewFromAttributeAction(Request $oRequest, $sObjectClass, $sObjectAttCode, $sObjectAttValue)
|
||||||
|
{
|
||||||
|
/** @var \Combodo\iTop\Portal\Helper\SecurityHelper $oSecurityHelper */
|
||||||
|
$oSecurityHelper = $this->get('security_helper');
|
||||||
|
/** @var \Combodo\iTop\Portal\Helper\ScopeValidatorHelper $oScopeValidator */
|
||||||
|
$oScopeValidator = $this->get('scope_validator');
|
||||||
|
|
||||||
|
// Checking parameters
|
||||||
|
if ($sObjectClass === '' || $sObjectAttCode === '' || $sObjectAttValue === '') {
|
||||||
|
IssueLog::Info(__METHOD__.' at line '.__LINE__.' : sObjectClass and sObjectAttCode/sObjectAttValue expected, "'
|
||||||
|
.$sObjectClass.'" and "'.$sObjectAttCode.' / '.$sObjectAttValue.'" given.');
|
||||||
|
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, Dict::Format('UI:Error:3ParametersMissing', 'class', 'attcode', 'attvalue'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$oObject = MetaModel::GetObjectByColumn($sObjectClass, $sObjectAttCode, $sObjectAttValue, false,
|
||||||
|
$oScopeValidator->IsAllDataAllowedForScope(UserRights::ListProfiles(), $sObjectClass));
|
||||||
|
if ($oObject === null) {
|
||||||
|
// null if object not found or multiple matches
|
||||||
|
IssueLog::Info(__METHOD__.' at line '.__LINE__.' : Could not load object '.$sObjectClass.'" and "'.$sObjectAttCode.' / '.$sObjectAttValue.'.');
|
||||||
|
throw new HttpException(Response::HTTP_NOT_FOUND, Dict::S('UI:ObjectDoesNotExist'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checking security layers
|
||||||
|
$sObjectId = $oObject->GetKey();
|
||||||
|
if (!$oSecurityHelper->IsActionAllowed(UR_ACTION_READ, $sObjectClass, $sObjectId)) {
|
||||||
|
IssueLog::Warning(__METHOD__.' at line '.__LINE__.' : User #'.UserRights::GetUserId().' not allowed to read '.$sObjectClass.'::'.$sObjectId.' object.');
|
||||||
|
throw new HttpException(Response::HTTP_NOT_FOUND, Dict::S('UI:ObjectDoesNotExist'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->PrepareViewObjectResponse($oRequest, $oObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \Symfony\Component\HttpFoundation\Request $oRequest
|
||||||
|
* @param \DBObject $oObject
|
||||||
|
*
|
||||||
|
* @return \Symfony\Component\HttpFoundation\JsonResponse|\Symfony\Component\HttpFoundation\Response|null
|
||||||
|
* @throws \ArchivedObjectException
|
||||||
|
* @throws \Combodo\iTop\Portal\Brick\BrickNotFoundException
|
||||||
|
* @throws \CoreException
|
||||||
|
* @throws \DictExceptionMissingString
|
||||||
|
* @throws \MissingQueryArgument
|
||||||
|
* @throws \MySQLException
|
||||||
|
* @throws \MySQLHasGoneAwayException
|
||||||
|
* @throws \OQLException
|
||||||
|
*
|
||||||
|
* @since 2.7.7 method creation (refactor for new `p_object_view_from_attribute` route)
|
||||||
|
*/
|
||||||
|
protected function PrepareViewObjectResponse(Request $oRequest, DBObject $oObject)
|
||||||
|
{
|
||||||
|
/** @var \Combodo\iTop\Portal\Helper\SecurityHelper $oSecurityHelper */
|
||||||
|
$oSecurityHelper = $this->get('security_helper');
|
||||||
|
/** @var \Combodo\iTop\Portal\Helper\RequestManipulatorHelper $oRequestManipulator */
|
||||||
|
$oRequestManipulator = $this->get('request_manipulator');
|
||||||
|
/** @var \Combodo\iTop\Portal\Routing\UrlGenerator $oUrlGenerator */
|
||||||
|
$oUrlGenerator = $this->get('url_generator');
|
||||||
|
/** @var \Combodo\iTop\Portal\Helper\ObjectFormHandlerHelper $oObjectFormHandler */
|
||||||
|
$oObjectFormHandler = $this->get('object_form_handler');
|
||||||
|
/** @var \Combodo\iTop\Portal\Brick\BrickCollection $oBrickCollection */
|
||||||
|
$oBrickCollection = $this->get('brick_collection');
|
||||||
|
|
||||||
$sOperation = $oRequestManipulator->ReadParam('operation', '');
|
$sOperation = $oRequestManipulator->ReadParam('operation', '');
|
||||||
|
$sObjectClass = get_class($oObject);
|
||||||
|
$sObjectId = $oObject->GetKey();
|
||||||
|
|
||||||
$aData = array('sMode' => 'view');
|
$aData = array('sMode' => 'view');
|
||||||
$aData['form'] = $oObjectFormHandler->HandleForm($oRequest, $aData['sMode'], $sObjectClass, $sObjectId);
|
$aData['form'] = $oObjectFormHandler->HandleForm($oRequest, $aData['sMode'], $sObjectClass, $sObjectId);
|
||||||
@@ -128,8 +201,7 @@ class ObjectController extends BrickController
|
|||||||
$oObject->GetName());
|
$oObject->GetName());
|
||||||
|
|
||||||
// Add an edit button if user is allowed
|
// Add an edit button if user is allowed
|
||||||
if ($oSecurityHelper->IsActionAllowed(UR_ACTION_MODIFY, $sObjectClass, $sObjectId))
|
if ($oSecurityHelper->IsActionAllowed(UR_ACTION_MODIFY, $sObjectClass, $sObjectId)) {
|
||||||
{
|
|
||||||
$sModifyUrl = $oUrlGenerator->generate('p_object_edit', array('sObjectClass' => $sObjectClass, 'sObjectId' => $sObjectId));
|
$sModifyUrl = $oUrlGenerator->generate('p_object_edit', array('sObjectClass' => $sObjectClass, 'sObjectId' => $sObjectId));
|
||||||
$oModifyButton = new JSButtonItem(
|
$oModifyButton = new JSButtonItem(
|
||||||
'modify_object',
|
'modify_object',
|
||||||
@@ -141,27 +213,19 @@ class ObjectController extends BrickController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Preparing response
|
// Preparing response
|
||||||
if ($oRequest->isXmlHttpRequest())
|
if ($oRequest->isXmlHttpRequest()) {
|
||||||
{
|
|
||||||
// We have to check whether the 'operation' parameter is defined or not in order to know if the form is required via ajax (to be displayed as a modal dialog) or if it's a lifecycle call from a existing form.
|
// We have to check whether the 'operation' parameter is defined or not in order to know if the form is required via ajax (to be displayed as a modal dialog) or if it's a lifecycle call from a existing form.
|
||||||
if (empty($sOperation))
|
if (empty($sOperation)) {
|
||||||
{
|
|
||||||
$oResponse = $this->render('itop-portal-base/portal/templates/bricks/object/modal.html.twig', $aData);
|
$oResponse = $this->render('itop-portal-base/portal/templates/bricks/object/modal.html.twig', $aData);
|
||||||
}
|
} else {
|
||||||
else
|
|
||||||
{
|
|
||||||
$oResponse = new JsonResponse($aData);
|
$oResponse = new JsonResponse($aData);
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
else
|
|
||||||
{
|
|
||||||
// Adding brick if it was passed
|
// Adding brick if it was passed
|
||||||
$sBrickId = $oRequestManipulator->ReadParam('sBrickId', '');
|
$sBrickId = $oRequestManipulator->ReadParam('sBrickId', '');
|
||||||
if (!empty($sBrickId))
|
if (!empty($sBrickId)) {
|
||||||
{
|
|
||||||
$oBrick = $oBrickCollection->GetBrickById($sBrickId);
|
$oBrick = $oBrickCollection->GetBrickById($sBrickId);
|
||||||
if ($oBrick !== null)
|
if ($oBrick !== null) {
|
||||||
{
|
|
||||||
$aData['oBrick'] = $oBrick;
|
$aData['oBrick'] = $oBrick;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -862,6 +926,7 @@ class ObjectController extends BrickController
|
|||||||
if (!empty($sQuery))
|
if (!empty($sQuery))
|
||||||
{
|
{
|
||||||
$oFullExpr = null;
|
$oFullExpr = null;
|
||||||
|
/** @noinspection SlowArrayOperationsInLoopInspection */
|
||||||
for ($i = 0; $i < count($aAttCodes); $i++)
|
for ($i = 0; $i < count($aAttCodes); $i++)
|
||||||
{
|
{
|
||||||
// Checking if the current attcode is an external key in order to search on the friendlyname
|
// Checking if the current attcode is an external key in order to search on the friendlyname
|
||||||
|
|||||||
37
pages/UI.php
37
pages/UI.php
@@ -346,22 +346,30 @@ try
|
|||||||
|
|
||||||
case 'details': // Details of an object
|
case 'details': // Details of an object
|
||||||
$sClass = utils::ReadParam('class', '', false, 'class');
|
$sClass = utils::ReadParam('class', '', false, 'class');
|
||||||
$id = utils::ReadParam('id', '');
|
|
||||||
if ( empty($sClass) || empty($id))
|
if (empty($sClass)) {
|
||||||
{
|
throw new ApplicationException(Dict::Format('UI:Error:1ParametersMissing', 'class'));
|
||||||
throw new ApplicationException(Dict::Format('UI:Error:2ParametersMissing', 'class', 'id'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_numeric($id))
|
$id = utils::ReadParam('id', null);
|
||||||
{
|
if (false === is_null($id)) {
|
||||||
$oObj = MetaModel::GetObject($sClass, $id, false /* MustBeFound */);
|
if (is_numeric($id)) {
|
||||||
|
$oObj = MetaModel::GetObject($sClass, $id, false /* MustBeFound */);
|
||||||
|
} else {
|
||||||
|
$oObj = MetaModel::GetObjectByName($sClass, $id, false /* MustBeFound */);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$sAttCode = utils::ReadParam('attcode', '');
|
||||||
|
$sAttValue = utils::ReadParam('attvalue', '');
|
||||||
|
|
||||||
|
if ((strlen($sAttCode) === 0) || (strlen($sAttValue) === 0)) {
|
||||||
|
throw new ApplicationException(Dict::Format('UI:Error:1ParametersMissing', 'id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$oObj = MetaModel::GetObjectByColumn($sClass, $sAttCode, $sAttValue, true);
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
if (is_null($oObj)) {
|
||||||
$oObj = MetaModel::GetObjectByName($sClass, $id, false /* MustBeFound */);
|
|
||||||
}
|
|
||||||
if (is_null($oObj))
|
|
||||||
{
|
|
||||||
// Check anyhow if there is a message for this object (like you've just created it)
|
// Check anyhow if there is a message for this object (like you've just created it)
|
||||||
$sMessageKey = $sClass.'::'.$id;
|
$sMessageKey = $sClass.'::'.$id;
|
||||||
DisplayMessages($sMessageKey, $oP);
|
DisplayMessages($sMessageKey, $oP);
|
||||||
@@ -369,8 +377,7 @@ try
|
|||||||
|
|
||||||
// Attempt to load the object in archive mode
|
// Attempt to load the object in archive mode
|
||||||
utils::PushArchiveMode(true);
|
utils::PushArchiveMode(true);
|
||||||
if (is_numeric($id))
|
if (is_numeric($id)) {
|
||||||
{
|
|
||||||
$oObj = MetaModel::GetObject($sClass, $id, false /* MustBeFound */);
|
$oObj = MetaModel::GetObject($sClass, $id, false /* MustBeFound */);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
Reference in New Issue
Block a user