lockable
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Classes\Record\Common\Lock;
|
||||
|
||||
use Espo\Core\Record\DeleteParams;
|
||||
use Espo\Core\Record\Hook\DeleteHook;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\Tools\Lock\LockValidationHelper;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class BeforeDeleteValidation implements DeleteHook
|
||||
{
|
||||
public function __construct(
|
||||
private LockValidationHelper $lockValidationHelper,
|
||||
) {}
|
||||
|
||||
public function process(Entity $entity, DeleteParams $params): void
|
||||
{
|
||||
$this->lockValidationHelper->validateBeforeRemove($entity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Classes\Record\Common\Lock;
|
||||
|
||||
use Espo\Core\Record\Hook\SaveHook;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\Tools\Lock\LockValidationHelper;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class BeforeSaveValidation implements SaveHook
|
||||
{
|
||||
public function __construct(
|
||||
private LockValidationHelper $lockValidationHelper,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function process(Entity $entity): void
|
||||
{
|
||||
$this->lockValidationHelper->validateBeforeSave($entity);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ use Espo\Core\ORM\Type\FieldType;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Entities\User;
|
||||
use Espo\ORM\Defs;
|
||||
use Espo\ORM\Defs\Params\FieldParam;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Name\Attribute;
|
||||
@@ -333,6 +334,7 @@ class Helper
|
||||
}
|
||||
|
||||
return $fieldDefs->getType() === FieldType::LINK &&
|
||||
!$fieldDefs->getParam(FieldParam::DISABLED) &&
|
||||
$entityDefs->hasRelation(Field::ASSIGNED_USER) &&
|
||||
$entityDefs->getRelation(Field::ASSIGNED_USER)->getForeignEntityType() === User::ENTITY_TYPE;
|
||||
}
|
||||
|
||||
@@ -43,4 +43,5 @@ class Permission
|
||||
public const USER_CALENDAR = 'userCalendar';
|
||||
public const FOLLOWER_MANAGEMENT = 'followerManagement';
|
||||
public const GROUP_EMAIL_ACCOUNT = 'groupEmailAccount';
|
||||
public const LOCK = 'lock';
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
namespace Espo\Core\Action;
|
||||
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Conflict;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
|
||||
@@ -39,6 +40,7 @@ interface Action
|
||||
* @throws Forbidden
|
||||
* @throws BadRequest
|
||||
* @throws NotFound
|
||||
* @throws Conflict
|
||||
*/
|
||||
public function process(Params $params, Data $data): void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Action\Actions;
|
||||
|
||||
use Espo\Core\Action\Action;
|
||||
use Espo\Core\Action\Data;
|
||||
use Espo\Core\Action\Params;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\ForbiddenSilent;
|
||||
use Espo\Core\Record\EntityProvider;
|
||||
use Espo\Tools\Lock\LockService;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class Lock implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private LockService $lockService,
|
||||
private EntityProvider $entityProvider,
|
||||
) {}
|
||||
|
||||
public function process(Params $params, Data $data): void
|
||||
{
|
||||
$entityType = $params->getEntityType();
|
||||
$id = $params->getId();
|
||||
|
||||
if (!$this->lockService->isEnabled($entityType)) {
|
||||
throw new ForbiddenSilent("Not enabled.");
|
||||
}
|
||||
|
||||
if (!$this->lockService->isAllowed($entityType)) {
|
||||
throw new ForbiddenSilent("Not allowed.");
|
||||
}
|
||||
|
||||
$entity = $this->entityProvider->get($entityType, $id);
|
||||
|
||||
try {
|
||||
$this->lockService->lock($entity);
|
||||
} catch (Error $e) {
|
||||
throw new Forbidden(previous: $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,14 +32,17 @@ namespace Espo\Core\Action\Actions\Merge;
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Acl\Table;
|
||||
use Espo\Core\Action\Params;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Conflict;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\Name\Link;
|
||||
use Espo\Core\ORM\EntityManager;
|
||||
use Espo\Core\ORM\Type\FieldType;
|
||||
use Espo\Core\Record\ActionHistory\Action;
|
||||
use Espo\Core\Record\DeleteParams;
|
||||
use Espo\Core\Record\ServiceContainer;
|
||||
use Espo\Core\Record\UpdateParams;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Core\Utils\ObjectUtil;
|
||||
use Espo\Entities\Attachment;
|
||||
@@ -47,7 +50,6 @@ use Espo\Entities\Note;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\Entities\EmailAddress;
|
||||
use Espo\Entities\PhoneNumber;
|
||||
|
||||
use Espo\ORM\Type\RelationType;
|
||||
use stdClass;
|
||||
|
||||
@@ -64,6 +66,8 @@ class Merger
|
||||
* @param string[] $sourceIdList
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
* @throws Conflict
|
||||
* @throws BadRequest
|
||||
* @noinspection PhpDocRedundantThrowsInspection
|
||||
*/
|
||||
public function process(Params $params, array $sourceIdList, stdClass $data): void
|
||||
@@ -77,6 +81,8 @@ class Merger
|
||||
* @param string[] $sourceIdList
|
||||
* @throws NotFound
|
||||
* @throws Forbidden
|
||||
* @throws Conflict
|
||||
* @throws BadRequest
|
||||
*/
|
||||
private function processInternal(Params $params, array $sourceIdList, stdClass $data): void
|
||||
{
|
||||
@@ -145,18 +151,10 @@ class Merger
|
||||
$this->updateNotes($sourceEntity, $entity);
|
||||
}
|
||||
|
||||
$mergeLinkList = $this->getMergeLinkList($entityType);
|
||||
$this->mergeLinks($entity, $sourceEntityList);
|
||||
|
||||
foreach ($sourceEntityList as $sourceEntity) {
|
||||
foreach ($mergeLinkList as $link) {
|
||||
$this->updateRelations($sourceEntity, $entity, $link);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($sourceEntityList as $sourceEntity) {
|
||||
$this->entityManager->removeEntity($sourceEntity);
|
||||
|
||||
$service->processActionHistoryRecord(Action::DELETE, $sourceEntity);
|
||||
$service->delete($sourceEntity->getId(), DeleteParams::create());
|
||||
}
|
||||
|
||||
if ($hasPhoneNumber) {
|
||||
@@ -167,11 +165,7 @@ class Merger
|
||||
$this->prepareEmailAddressData($emailAddressToRelateList, $clonedData);
|
||||
}
|
||||
|
||||
$entity->set($clonedData);
|
||||
|
||||
$this->entityManager->saveEntity($entity);
|
||||
|
||||
$service->processActionHistoryRecord(Action::UPDATE, $entity);
|
||||
$service->update($id, $clonedData, UpdateParams::create()->withSkipDuplicateCheck());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -449,4 +443,18 @@ class Merger
|
||||
|
||||
return $columnAttributeMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Entity[] $sourceEntityList
|
||||
*/
|
||||
private function mergeLinks(Entity $entity, array $sourceEntityList): void
|
||||
{
|
||||
$mergeLinkList = $this->getMergeLinkList($entity->getEntityType());
|
||||
|
||||
foreach ($sourceEntityList as $sourceEntity) {
|
||||
foreach ($mergeLinkList as $link) {
|
||||
$this->updateRelations($sourceEntity, $entity, $link);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Action\Actions;
|
||||
|
||||
use Espo\Core\Action\Action;
|
||||
use Espo\Core\Action\Data;
|
||||
use Espo\Core\Action\Params;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\ForbiddenSilent;
|
||||
use Espo\Core\Record\EntityProvider;
|
||||
use Espo\Tools\Lock\LockService;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class Unlock implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private LockService $lockService,
|
||||
private EntityProvider $entityProvider,
|
||||
) {}
|
||||
|
||||
public function process(Params $params, Data $data): void
|
||||
{
|
||||
$entityType = $params->getEntityType();
|
||||
$id = $params->getId();
|
||||
|
||||
if (!$this->lockService->isEnabled($entityType)) {
|
||||
throw new ForbiddenSilent("Not enabled.");
|
||||
}
|
||||
|
||||
if (!$this->lockService->isAllowed($entityType)) {
|
||||
throw new ForbiddenSilent("Not allowed.");
|
||||
}
|
||||
|
||||
$entity = $this->entityProvider->get($entityType, $id);
|
||||
|
||||
try {
|
||||
$this->lockService->unlock($entity);
|
||||
} catch (Error $e) {
|
||||
throw new Forbidden(previous: $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ namespace Espo\Core\Action;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Conflict;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\ForbiddenSilent;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
@@ -55,6 +56,7 @@ class Service
|
||||
* @throws Forbidden
|
||||
* @throws BadRequest
|
||||
* @throws NotFound
|
||||
* @throws Conflict
|
||||
*/
|
||||
public function process(string $entityType, string $action, string $id, stdClass $data): Entity
|
||||
{
|
||||
|
||||
@@ -39,6 +39,7 @@ use Espo\Core\Exceptions\HasLogMessage;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Utils\Log;
|
||||
|
||||
use Espo\ORM\Exceptions\ValidationException;
|
||||
use LogicException;
|
||||
use Psr\Log\LogLevel;
|
||||
use RuntimeException;
|
||||
@@ -82,6 +83,11 @@ class ErrorOutput
|
||||
NotFound::class,
|
||||
];
|
||||
|
||||
/** @var array<class-string<Throwable>, int> */
|
||||
private array $statusCodeMap = [
|
||||
ValidationException::class => 409,
|
||||
];
|
||||
|
||||
public function __construct(private Log $log)
|
||||
{}
|
||||
|
||||
@@ -136,6 +142,8 @@ class ErrorOutput
|
||||
'request' => $request,
|
||||
]);
|
||||
|
||||
$statusCode = $this->statusCodeMap[$exception::class] ?? $statusCode;
|
||||
|
||||
if (!in_array($statusCode, $this->allowedStatusCodeList)) {
|
||||
$statusCode = 500;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\MassAction\Actions;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\ErrorSilent;
|
||||
use Espo\Core\Exceptions\ForbiddenSilent;
|
||||
use Espo\Core\MassAction\Data;
|
||||
use Espo\Core\MassAction\MassAction;
|
||||
use Espo\Core\MassAction\Params;
|
||||
use Espo\Core\MassAction\QueryBuilder;
|
||||
use Espo\Core\MassAction\Result;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Tools\Lock\LockService;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class MassLock implements MassAction
|
||||
{
|
||||
public function __construct(
|
||||
private QueryBuilder $queryBuilder,
|
||||
private LockService $lockService,
|
||||
private EntityManager $entityManager,
|
||||
private Acl $acl,
|
||||
) {}
|
||||
|
||||
public function process(Params $params, Data $data): Result
|
||||
{
|
||||
$entityType = $params->getEntityType();
|
||||
|
||||
if (!$this->lockService->isEnabled($entityType)) {
|
||||
throw new ErrorSilent("Not enabled.");
|
||||
}
|
||||
|
||||
if (!$this->lockService->isAllowed($entityType)) {
|
||||
throw new ForbiddenSilent("Not allowed.");
|
||||
}
|
||||
|
||||
if ($this->acl->getPermissionLevel(Acl\Permission::MASS_UPDATE) !== Acl\Table::LEVEL_YES) {
|
||||
throw new ForbiddenSilent("No mass update permission.");
|
||||
}
|
||||
|
||||
$query = $this->queryBuilder->build($params);
|
||||
|
||||
$collection = $this->entityManager
|
||||
->getRDBRepository($entityType)
|
||||
->clone($query)
|
||||
->sth()
|
||||
->find();
|
||||
|
||||
return $this->lockService->massLock($collection);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\MassAction\Actions;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\ErrorSilent;
|
||||
use Espo\Core\Exceptions\ForbiddenSilent;
|
||||
use Espo\Core\MassAction\Data;
|
||||
use Espo\Core\MassAction\MassAction;
|
||||
use Espo\Core\MassAction\Params;
|
||||
use Espo\Core\MassAction\QueryBuilder;
|
||||
use Espo\Core\MassAction\Result;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Tools\Lock\LockService;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class MassUnlock implements MassAction
|
||||
{
|
||||
public function __construct(
|
||||
private QueryBuilder $queryBuilder,
|
||||
private LockService $lockService,
|
||||
private EntityManager $entityManager,
|
||||
private Acl $acl,
|
||||
) {}
|
||||
|
||||
public function process(Params $params, Data $data): Result
|
||||
{
|
||||
$entityType = $params->getEntityType();
|
||||
|
||||
if (!$this->lockService->isEnabled($entityType)) {
|
||||
throw new ErrorSilent("Not enabled.");
|
||||
}
|
||||
|
||||
if (!$this->lockService->isAllowed($entityType)) {
|
||||
throw new ForbiddenSilent("Not allowed.");
|
||||
}
|
||||
|
||||
if ($this->acl->getPermissionLevel(Acl\Permission::MASS_UPDATE) !== Acl\Table::LEVEL_YES) {
|
||||
throw new ForbiddenSilent("No mass update permission.");
|
||||
}
|
||||
|
||||
$query = $this->queryBuilder->build($params);
|
||||
|
||||
$collection = $this->entityManager
|
||||
->getRDBRepository($entityType)
|
||||
->clone($query)
|
||||
->sth()
|
||||
->find();
|
||||
|
||||
return $this->lockService->massUnlock($collection);
|
||||
}
|
||||
}
|
||||
@@ -49,4 +49,5 @@ class Field
|
||||
public const EMAIL_ADDRESS = 'emailAddress';
|
||||
public const PHONE_NUMBER = 'phoneNumber';
|
||||
public const VERSION_NUMBER = 'versionNumber';
|
||||
public const string IS_LOCKED = 'isLocked';
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ use Espo\ORM\Entity;
|
||||
use Espo\Core\Record\CreateParams;
|
||||
|
||||
/**
|
||||
* @template TEntity of Entity
|
||||
* @template TEntity of Entity = Entity
|
||||
*/
|
||||
interface CreateHook
|
||||
{
|
||||
|
||||
@@ -36,7 +36,7 @@ use Espo\ORM\Entity;
|
||||
use Espo\Core\Record\DeleteParams;
|
||||
|
||||
/**
|
||||
* @template TEntity of Entity
|
||||
* @template TEntity of Entity = Entity
|
||||
*/
|
||||
interface DeleteHook
|
||||
{
|
||||
|
||||
@@ -32,7 +32,7 @@ namespace Espo\Core\Record\Hook;
|
||||
use Espo\ORM\Entity;
|
||||
|
||||
/**
|
||||
* @template TEntity of Entity
|
||||
* @template TEntity of Entity = Entity
|
||||
*/
|
||||
interface LinkHook
|
||||
{
|
||||
|
||||
@@ -97,7 +97,10 @@ class Provider
|
||||
$key = $type . 'HookClassNameList';
|
||||
|
||||
/** @var class-string[] $classNameList */
|
||||
$classNameList = $this->metadata->get(['recordDefs', $entityType, $key]) ?? [];
|
||||
$classNameList = [
|
||||
...$this->metadata->get("app.record.$key", []),
|
||||
...$this->metadata->get("recordDefs.$entityType.$key", [])
|
||||
];
|
||||
|
||||
$interfaces = $this->typeInterfaceListMap[$type] ?? null;
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ use Espo\ORM\Entity;
|
||||
use Espo\Core\Record\ReadParams;
|
||||
|
||||
/**
|
||||
* @template TEntity of Entity
|
||||
* @template TEntity of Entity = Entity
|
||||
*/
|
||||
interface ReadHook
|
||||
{
|
||||
|
||||
@@ -37,7 +37,7 @@ use Espo\ORM\Entity;
|
||||
/**
|
||||
* On record create or update.
|
||||
*
|
||||
* @template TEntity of Entity
|
||||
* @template TEntity of Entity = Entity
|
||||
* @since 8.1.0.
|
||||
*/
|
||||
interface SaveHook
|
||||
|
||||
@@ -32,7 +32,7 @@ namespace Espo\Core\Record\Hook;
|
||||
use Espo\ORM\Entity;
|
||||
|
||||
/**
|
||||
* @template TEntity of Entity
|
||||
* @template TEntity of Entity = Entity
|
||||
*/
|
||||
interface UnlinkHook
|
||||
{
|
||||
|
||||
@@ -37,7 +37,7 @@ use Espo\ORM\Entity;
|
||||
use Espo\Core\Record\UpdateParams;
|
||||
|
||||
/**
|
||||
* @template TEntity of Entity
|
||||
* @template TEntity of Entity = Entity
|
||||
*/
|
||||
interface UpdateHook
|
||||
{
|
||||
|
||||
@@ -95,7 +95,10 @@ class FilterProvider
|
||||
private function getCreateClassNameList(string $entityType): array
|
||||
{
|
||||
/** @var class-string<Filter>[] */
|
||||
return $this->metadata->get("recordDefs.$entityType.createInputFilterClassNameList") ?? [];
|
||||
return [
|
||||
...$this->metadata->get("app.record.createInputFilterClassNameList", []),
|
||||
...$this->metadata->get("recordDefs.$entityType.createInputFilterClassNameList", [])
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,6 +107,9 @@ class FilterProvider
|
||||
private function getUpdateClassNameList(string $entityType): array
|
||||
{
|
||||
/** @var class-string<Filter>[] */
|
||||
return $this->metadata->get("recordDefs.$entityType.updateInputFilterClassNameList") ?? [];
|
||||
return [
|
||||
...$this->metadata->get("app.record.updateInputFilterClassNameList", []),
|
||||
...$this->metadata->get("recordDefs.$entityType.updateInputFilterClassNameList", [])
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,9 @@ class FilterProvider
|
||||
private function getClassNameList(string $entityType): array
|
||||
{
|
||||
/** @var class-string<Filter<Entity>>[] */
|
||||
return $this->metadata->get("recordDefs.$entityType.outputFilterClassNameList") ?? [];
|
||||
return [
|
||||
...$this->metadata->get("app.record.outputFilterClassNameList", []),
|
||||
...$this->metadata->get("recordDefs.$entityType.outputFilterClassNameList", []),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,7 +580,7 @@ class Service implements Crud,
|
||||
->getFieldList();
|
||||
|
||||
foreach ($fieldDefsList as $fieldDefs) {
|
||||
if (!$fieldDefs->getParam('readOnlyAfterCreate')) {
|
||||
if (!$fieldDefs->getParam(FieldParam::READ_ONLY_AFTER_CREATE)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,25 @@ class Builder
|
||||
['app', 'client', 'scriptList'],
|
||||
['app', 'client', 'cssList'],
|
||||
['app', 'client', 'linkList'],
|
||||
|
||||
['app', 'record', 'selectApplierClassNameList'],
|
||||
['app', 'record', 'createInputFilterClassNameList'],
|
||||
['app', 'record', 'updateInputFilterClassNameList'],
|
||||
['app', 'record', 'outputFilterClassNameList'],
|
||||
['app', 'record', 'beforeReadHookClassNameList'],
|
||||
['app', 'record', 'earlyBeforeCreateHookClassNameList'],
|
||||
['app', 'record', 'beforeCreateHookClassNameList'],
|
||||
['app', 'record', 'earlyBeforeUpdateHookClassNameList'],
|
||||
['app', 'record', 'beforeUpdateHookClassNameList'],
|
||||
['app', 'record', 'beforeDeleteHookClassNameList'],
|
||||
['app', 'record', 'afterCreateHookClassNameList'],
|
||||
['app', 'record', 'afterUpdateHookClassNameList'],
|
||||
['app', 'record', 'afterDeleteHookClassNameList'],
|
||||
['app', 'record', 'beforeLinkHookClassNameList'],
|
||||
['app', 'record', 'beforeUnlinkHookClassNameList'],
|
||||
['app', 'record', 'afterLinkHookClassNameList'],
|
||||
['app', 'record', 'afterUnlinkHookClassNameList'],
|
||||
|
||||
['recordDefs', self::ANY_KEY, 'readLoaderClassNameList'],
|
||||
['recordDefs', self::ANY_KEY, 'listLoaderClassNameList'],
|
||||
['recordDefs', self::ANY_KEY, 'saverClassNameList'],
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Hooks\Common;
|
||||
|
||||
use Espo\Core\Exceptions\Conflict;
|
||||
use Espo\Core\Hook\Hook\BeforeRemove;
|
||||
use Espo\Core\Hook\Hook\BeforeSave;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\Exceptions\ValidationException;
|
||||
use Espo\ORM\Repository\Option\RemoveOptions;
|
||||
use Espo\ORM\Repository\Option\SaveOptions;
|
||||
use Espo\Tools\Lock\LockValidationHelper;
|
||||
use Espo\Tools\Lock\LockMetadataProvider;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class ValidateLocked implements BeforeSave, BeforeRemove
|
||||
{
|
||||
public static int $order = 12;
|
||||
|
||||
public function __construct(
|
||||
private LockMetadataProvider $lockMetadataProvider,
|
||||
private LockValidationHelper $lockValidationHelper,
|
||||
) {}
|
||||
|
||||
public function beforeSave(Entity $entity, SaveOptions $options): void
|
||||
{
|
||||
if (!$this->lockMetadataProvider->isEnabled($entity->getEntityType())) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->lockValidationHelper->validateBeforeSave($entity);
|
||||
} catch (Conflict $e) {
|
||||
throw new ValidationException(previous: $e);
|
||||
}
|
||||
}
|
||||
|
||||
public function beforeRemove(Entity $entity, RemoveOptions $options): void
|
||||
{
|
||||
if (!$this->lockMetadataProvider->isEnabled($entity->getEntityType())) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->lockValidationHelper->validateBeforeRemove($entity);
|
||||
} catch (Conflict $e) {
|
||||
throw new ValidationException(previous: $e);
|
||||
}
|
||||
|
||||
if ($entity->get(Field::IS_LOCKED)) {
|
||||
throw new ValidationException("Cannot remove a locked record.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,13 @@
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"lockable": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"Contact": {
|
||||
@@ -59,6 +66,13 @@
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"lockable": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"Lead": {
|
||||
@@ -90,6 +104,13 @@
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"lockable": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"Opportunity": {
|
||||
@@ -106,6 +127,13 @@
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"lockable": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"Document": {
|
||||
@@ -122,6 +150,13 @@
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"lockable": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"Case": {
|
||||
@@ -138,6 +173,13 @@
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"lockable": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"KnowledgeBaseArticle": {
|
||||
@@ -154,6 +196,13 @@
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"lockable": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"Meeting": {
|
||||
@@ -192,6 +241,13 @@
|
||||
"tooltip": true,
|
||||
"view": "crm:views/admin/entity-manager/fields/status-list"
|
||||
}
|
||||
},
|
||||
"lockable": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"Call": {
|
||||
@@ -230,6 +286,13 @@
|
||||
"tooltip": true,
|
||||
"view": "crm:views/admin/entity-manager/fields/status-list"
|
||||
}
|
||||
},
|
||||
"lockable": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"Task": {
|
||||
@@ -264,6 +327,13 @@
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"lockable": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"TargetList": {
|
||||
@@ -273,6 +343,13 @@
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"lockable": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@Event": {
|
||||
@@ -343,6 +420,13 @@
|
||||
"tooltip": true,
|
||||
"view": "views/admin/entity-manager/fields/acl-account-link"
|
||||
}
|
||||
},
|
||||
"lockable": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +160,15 @@
|
||||
"campaign": {
|
||||
"type": "link"
|
||||
},
|
||||
"isLocked": {
|
||||
"type": "bool",
|
||||
"readOnly": true,
|
||||
"audited": true,
|
||||
"layoutAvailabilityList": [
|
||||
"filters",
|
||||
"list"
|
||||
]
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true,
|
||||
@@ -188,11 +197,13 @@
|
||||
},
|
||||
"assignedUser": {
|
||||
"type": "link",
|
||||
"view": "views/fields/assigned-user"
|
||||
"view": "views/fields/assigned-user",
|
||||
"notLockable": true
|
||||
},
|
||||
"teams": {
|
||||
"type": "linkMultiple",
|
||||
"view": "views/fields/teams"
|
||||
"view": "views/fields/teams",
|
||||
"notLockable": true
|
||||
},
|
||||
"targetLists": {
|
||||
"type": "linkMultiple",
|
||||
|
||||
@@ -12,5 +12,6 @@
|
||||
"notifications": true,
|
||||
"object": true,
|
||||
"hasPersonalData": true,
|
||||
"duplicateCheckFieldList": ["name", "emailAddress"]
|
||||
"duplicateCheckFieldList": ["name", "emailAddress"],
|
||||
"lockable": true
|
||||
}
|
||||
|
||||
@@ -74,6 +74,13 @@ class FieldParam
|
||||
*/
|
||||
public const READ_ONLY = 'readOnly';
|
||||
|
||||
/**
|
||||
* Read-only after create.
|
||||
*
|
||||
* @since 9.4.0
|
||||
*/
|
||||
public const READ_ONLY_AFTER_CREATE = 'readOnlyAfterCreate';
|
||||
|
||||
/**
|
||||
* Decimal.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\ORM\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* A low level validation error.
|
||||
*
|
||||
* @since 9.4.0
|
||||
*/
|
||||
class ValidationException extends RuntimeException
|
||||
{}
|
||||
@@ -243,7 +243,8 @@
|
||||
"onlyAdmin": "Only for Admin",
|
||||
"notStorable": "Not Storable",
|
||||
"itemsEditable": "Items Editable",
|
||||
"openInNewTab": "Open in new tab"
|
||||
"openInNewTab": "Open in new tab",
|
||||
"notLockable": "Not Lockable"
|
||||
},
|
||||
"strings" : {
|
||||
"rebuildRequired": "Rebuild is required"
|
||||
|
||||
@@ -56,7 +56,8 @@
|
||||
"collaborators": "Collaborators",
|
||||
"aclContactLink": "ACL Contact Link",
|
||||
"aclAccountLink": "ACL Account Link",
|
||||
"categories": "Categories"
|
||||
"categories": "Categories",
|
||||
"lockable": "Lockable"
|
||||
},
|
||||
"options": {
|
||||
"type": {
|
||||
@@ -99,6 +100,7 @@
|
||||
"beforeSaveApiScript": "A script called on create and update API requests before an entity is saved. Use for custom validation and duplicate checking."
|
||||
},
|
||||
"tooltips": {
|
||||
"lockable": "Enables the ability to lock records.",
|
||||
"categories": "Enable the category tree feature. Records can be placed into categories.",
|
||||
"aclContactLink": "The link with Contact to use when applying access control for portal users.",
|
||||
"aclAccountLink": "The link with Account to use when applying access control for portal users.",
|
||||
|
||||
@@ -165,6 +165,8 @@
|
||||
"Linked": "Linked",
|
||||
"Unlinked": "Unlinked",
|
||||
"Done": "Done",
|
||||
"Bad request": "Bad request",
|
||||
"Conflict": "Conflict",
|
||||
"Access denied": "Access denied",
|
||||
"Not found": "Not found",
|
||||
"Internal server error": "Internal server error",
|
||||
@@ -334,7 +336,9 @@
|
||||
"View User Access": "View User Access",
|
||||
"Reacted": "Reacted",
|
||||
"Reaction Removed": "Reaction Removed",
|
||||
"Reactions": "Reactions"
|
||||
"Reactions": "Reactions",
|
||||
"Lock": "Lock",
|
||||
"Unlock": "Unlock"
|
||||
},
|
||||
"messages": {
|
||||
"checkLogsForDetails": "Check logs for details.",
|
||||
@@ -452,7 +456,15 @@
|
||||
"duplicateConflict": "A record already exists.",
|
||||
"cannotRemoveCategoryWithChildCategory": "Cannot remove a category that has a child category.",
|
||||
"cannotRemoveNotEmptyCategory": "Cannot remove a non-empty category.",
|
||||
"sameRecordIsAlreadyBeingEdited": "The record is already being edited."
|
||||
"sameRecordIsAlreadyBeingEdited": "The record is already being edited.",
|
||||
"confirmMassLock": "Are you sure you want to lock selected records?",
|
||||
"confirmMassUnlock": "Are you sure you want to unlock selected records?",
|
||||
"locked": "Record is locked",
|
||||
"unlocked": "Record is unlocked",
|
||||
"massLockDone": "{count} locked",
|
||||
"massUnlockDone": "{count} unlocked",
|
||||
"cannotModifyLockedRecord": "Cannot modify the field *{field}*. The record is locked.",
|
||||
"cannotRemoveLockedRecord": "Cannot remove a locked record."
|
||||
},
|
||||
"boolFilters": {
|
||||
"onlyMy": "Only My",
|
||||
@@ -478,7 +490,9 @@
|
||||
"unfollow": "Unfollow",
|
||||
"convertCurrency": "Convert Currency",
|
||||
"recalculateFormula": "Recalculate Formula",
|
||||
"printPdf": "Print to PDF"
|
||||
"printPdf": "Print to PDF",
|
||||
"lock": "Lock",
|
||||
"unlock": "Unlock"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
@@ -522,7 +536,8 @@
|
||||
"types": "Types",
|
||||
"targetListIsOptedOut": "Is Opted Out (Target List)",
|
||||
"childList": "Child List",
|
||||
"category": "Category"
|
||||
"category": "Category",
|
||||
"isLocked": "Locked"
|
||||
},
|
||||
"links": {
|
||||
"assignedUser": "Assigned User",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"auditPermission": "Audit Permission",
|
||||
"mentionPermission": "Mention Permission",
|
||||
"userCalendarPermission": "User Calendar Permission",
|
||||
"lockPermission": "Lock Permission",
|
||||
"data": "Data",
|
||||
"fieldData": "Field Data"
|
||||
},
|
||||
@@ -22,6 +23,7 @@
|
||||
"teams": "Teams"
|
||||
},
|
||||
"tooltips": {
|
||||
"lockPermission": "Allows to lock and unlock records.",
|
||||
"messagePermission": "Allows to send messages to other users.\n\n* all – can send to all\n* team – can send only to teammates\n* no – cannot send",
|
||||
"assignmentPermission": "Allows to assign records to other users.\n\n* all – no restriction\n* team – can assign only to teammates\n* no – can assign only to self",
|
||||
"userPermission": "Allows to view stream of other users. Allows users to view the access levels other users have for specific records.",
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
{"name": "auditPermission"},
|
||||
{"name": "userPermission"},
|
||||
{"name": "dataPrivacyPermission"}
|
||||
],
|
||||
[
|
||||
{"name": "lockPermission"},
|
||||
false,
|
||||
false
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -176,7 +176,8 @@
|
||||
"portalPermission",
|
||||
"groupEmailAccountPermission",
|
||||
"followerManagementPermission",
|
||||
"dataPrivacyPermission"
|
||||
"dataPrivacyPermission",
|
||||
"lockPermission"
|
||||
],
|
||||
"valuePermissionHighestLevels": {
|
||||
"assignmentPermission": "all",
|
||||
@@ -190,7 +191,8 @@
|
||||
"dataPrivacyPermission": "yes",
|
||||
"auditPermission": "yes",
|
||||
"mentionPermission": "yes",
|
||||
"userCalendarPermission": "all"
|
||||
"userCalendarPermission": "all",
|
||||
"lockPermission": "yes"
|
||||
},
|
||||
"permissionsStrictDefaults": {
|
||||
"assignmentPermission": "no",
|
||||
@@ -204,6 +206,7 @@
|
||||
"dataPrivacyPermission": "no",
|
||||
"auditPermission": "no",
|
||||
"mentionPermission": "no",
|
||||
"userCalendarPermission": "no"
|
||||
"userCalendarPermission": "no",
|
||||
"lockPermission": "no"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,11 @@
|
||||
},
|
||||
"merge": {
|
||||
"implementationClassName": "Espo\\Core\\Action\\Actions\\Merge"
|
||||
},
|
||||
"lock": {
|
||||
"implementationClassName": "Espo\\Core\\Action\\Actions\\Lock"
|
||||
},
|
||||
"unlock": {
|
||||
"implementationClassName": "Espo\\Core\\Action\\Actions\\Unlock"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"Espo\\Tools\\EntityManager\\Hook\\Hooks\\AssignedUsersUpdateHook",
|
||||
"Espo\\Tools\\EntityManager\\Hook\\Hooks\\CollaboratorsUpdateHook",
|
||||
"Espo\\Tools\\EntityManager\\Hook\\Hooks\\StreamUpdateHook",
|
||||
"Espo\\Tools\\EntityManager\\Hook\\Hooks\\CategoriesUpdateHook"
|
||||
"Espo\\Tools\\EntityManager\\Hook\\Hooks\\CategoriesUpdateHook",
|
||||
"Espo\\Tools\\EntityManager\\Hook\\Hooks\\LockableUpdateHook"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -69,6 +69,13 @@
|
||||
"tooltip": true,
|
||||
"view": "views/admin/entity-manager/fields/acl-account-link"
|
||||
}
|
||||
},
|
||||
"lockable": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@Person": {
|
||||
@@ -118,6 +125,13 @@
|
||||
"tooltip": true,
|
||||
"view": "views/admin/entity-manager/fields/acl-account-link"
|
||||
}
|
||||
},
|
||||
"lockable": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@Base": {
|
||||
@@ -174,6 +188,13 @@
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"lockable": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"@BasePlus": {
|
||||
@@ -230,6 +251,13 @@
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
},
|
||||
"lockable": {
|
||||
"location": "scopes",
|
||||
"fieldDefs": {
|
||||
"type": "bool",
|
||||
"tooltip": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,11 @@
|
||||
},
|
||||
"delete": {
|
||||
"implementationClassName": "Espo\\Core\\MassAction\\Actions\\MassDelete"
|
||||
},
|
||||
"lock": {
|
||||
"implementationClassName": "Espo\\Core\\MassAction\\Actions\\MassLock"
|
||||
},
|
||||
"unlock": {
|
||||
"implementationClassName": "Espo\\Core\\MassAction\\Actions\\MassUnlock"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
{
|
||||
"selectApplierClassNameList": [
|
||||
"Espo\\Core\\Select\\Applier\\AdditionalAppliers\\IsStarred"
|
||||
],
|
||||
"beforeCreateHookClassNameList": [
|
||||
"Espo\\Classes\\Record\\Common\\Lock\\BeforeSaveValidation"
|
||||
],
|
||||
"beforeUpdateHookClassNameList": [
|
||||
"Espo\\Classes\\Record\\Common\\Lock\\BeforeSaveValidation"
|
||||
],
|
||||
"beforeDeleteHookClassNameList": [
|
||||
"Espo\\Classes\\Record\\Common\\Lock\\BeforeDeleteValidation"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,6 +15,56 @@
|
||||
"checkVisibilityFunction": "isAvailable",
|
||||
"handler": "handlers/record/view-user-access",
|
||||
"groupIndex": 4
|
||||
},
|
||||
{
|
||||
"name": "lock",
|
||||
"acl": "edit",
|
||||
"label": "Lock",
|
||||
"handler": "handlers/record/lock-action",
|
||||
"checkVisibilityFunction": "canBeLocked",
|
||||
"actionFunction": "actionLock",
|
||||
"groupIndex": 9
|
||||
},
|
||||
{
|
||||
"name": "unlock",
|
||||
"acl": "edit",
|
||||
"label": "Unlock",
|
||||
"handler": "handlers/record/lock-action",
|
||||
"checkVisibilityFunction": "canBeUnlocked",
|
||||
"actionFunction": "actionUnlock",
|
||||
"groupIndex": 9
|
||||
}
|
||||
]
|
||||
],
|
||||
"massActionList": [
|
||||
"lock",
|
||||
"unlock"
|
||||
],
|
||||
"checkAllResultMassActionList": [
|
||||
"lock",
|
||||
"unlock"
|
||||
],
|
||||
"massActionDefs": {
|
||||
"lock": {
|
||||
"acl": "edit",
|
||||
"handler": "handlers/record/lock-mass-action",
|
||||
"actionFunction": "actionLock",
|
||||
"initFunction": "initLock",
|
||||
"groupIndex": 9
|
||||
},
|
||||
"unlock": {
|
||||
"acl": "edit",
|
||||
"handler": "handlers/record/lock-mass-action",
|
||||
"actionFunction": "actionUnlock",
|
||||
"initFunction": "initUnlock",
|
||||
"groupIndex": 9
|
||||
}
|
||||
},
|
||||
"viewSetupHandlers": {
|
||||
"record/detail": [
|
||||
"handlers/record/lock-view-setup"
|
||||
],
|
||||
"record/edit": [
|
||||
"handlers/record/lock-view-setup"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,15 @@
|
||||
"view": "views/role/fields/permission",
|
||||
"audited": true
|
||||
},
|
||||
"lockPermission": {
|
||||
"type": "enum",
|
||||
"options": ["not-set", "yes", "no"],
|
||||
"default": "not-set",
|
||||
"tooltip": true,
|
||||
"translation": "Role.options.levelList",
|
||||
"view": "views/role/fields/permission",
|
||||
"audited": true
|
||||
},
|
||||
"data": {
|
||||
"type": "jsonObject",
|
||||
"audited": true
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Tools\EntityManager\Hook\Hooks;
|
||||
|
||||
use Espo\Core\DataManager;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\ORM\Type\FieldType;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Modules\Crm\Entities\Account;
|
||||
use Espo\ORM\Defs\Params\FieldParam;
|
||||
use Espo\Tools\EntityManager\Hook\UpdateHook;
|
||||
use Espo\Tools\EntityManager\Params;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class LockableUpdateHook implements UpdateHook
|
||||
{
|
||||
private const string PARAM = 'lockable';
|
||||
private const string FIELD = Field::IS_LOCKED;
|
||||
|
||||
/** @var string[] */
|
||||
private array $enabledByDefaultEntityTypeList = [
|
||||
Account::ENTITY_TYPE,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private Metadata $metadata,
|
||||
private Log $log,
|
||||
private DataManager $dataManager,
|
||||
) {}
|
||||
|
||||
public function process(Params $params, Params $previousParams): void
|
||||
{
|
||||
if ($params->get(self::PARAM) && !$previousParams->get(self::PARAM)) {
|
||||
$this->add($params->getName());
|
||||
} else if (!$params->get(self::PARAM) && $previousParams->get(self::PARAM)) {
|
||||
$this->remove($params->getName());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
*/
|
||||
private function add(string $entityType): void
|
||||
{
|
||||
if ($this->metadata->get("entityDefs.$entityType.fields." . self::FIELD . ".isCustom")) {
|
||||
$this->log->warning("Cannot enable lockable for $entityType as the field already exists.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isEnabledByDefault($entityType)) {
|
||||
$this->addEnabledByDefault($entityType);
|
||||
} else {
|
||||
$this->addInternal($entityType);
|
||||
}
|
||||
|
||||
|
||||
$this->metadata->save();
|
||||
$this->dataManager->rebuild([$entityType]);
|
||||
}
|
||||
|
||||
private function addEnabledByDefault(string $entityType): void
|
||||
{
|
||||
$this->metadata->delete('entityDefs', $entityType, [
|
||||
'fields.' . self::FIELD,
|
||||
]);
|
||||
}
|
||||
|
||||
private function addInternal(string $entityType): void
|
||||
{
|
||||
$this->metadata->set('entityDefs', $entityType, [
|
||||
'fields' => [
|
||||
self::FIELD => [
|
||||
FieldParam::TYPE => FieldType::BOOL,
|
||||
FieldParam::READ_ONLY => true,
|
||||
'audited' => true,
|
||||
'fieldManagerParamList' => [
|
||||
'audited',
|
||||
'tooltipText',
|
||||
],
|
||||
'layoutAvailabilityList' => [
|
||||
'filters',
|
||||
'list',
|
||||
],
|
||||
],
|
||||
]
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
private function remove(string $entityType): void
|
||||
{
|
||||
$this->metadata->delete('entityDefs', $entityType, [
|
||||
'fields.' . self::FIELD,
|
||||
]);
|
||||
|
||||
$this->metadata->save();
|
||||
|
||||
// Must be after metadata is saved.
|
||||
if ($this->isEnabledByDefault($entityType)) {
|
||||
$this->metadata->set('entityDefs', $entityType, [
|
||||
'fields' => [
|
||||
self::FIELD => [
|
||||
FieldParam::DISABLED => true,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->metadata->save();
|
||||
}
|
||||
}
|
||||
|
||||
private function isEnabledByDefault(string $entityType): bool
|
||||
{
|
||||
return in_array($entityType, $this->enabledByDefaultEntityTypeList);
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,7 @@ class NameUtil
|
||||
Field::CREATED_AT,
|
||||
Field::MODIFIED_BY,
|
||||
Field::MODIFIED_AT,
|
||||
Field::IS_LOCKED,
|
||||
|
||||
'emailAddressList',
|
||||
'userEmailAddressList',
|
||||
|
||||
@@ -705,6 +705,12 @@ class FieldManager
|
||||
],
|
||||
];
|
||||
|
||||
if ($this->metadata->get("scopes.$scope.lockable") === true) {
|
||||
$additionalParamList['notLockable'] = [
|
||||
FieldParam::TYPE => FieldType::BOOL,
|
||||
];
|
||||
}
|
||||
|
||||
$type = $fieldDefs[FieldParam::TYPE] ?? null;
|
||||
|
||||
if (!$type) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Tools\Lock;
|
||||
|
||||
use Espo\Core\Utils\Metadata;
|
||||
|
||||
class LockMetadataProvider
|
||||
{
|
||||
public function __construct(
|
||||
private Metadata $metadata,
|
||||
) {}
|
||||
|
||||
public function isEnabled(string $entityType): bool
|
||||
{
|
||||
if (!$this->metadata->get("scopes.$entityType.lockable")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->metadata->get("entityDefs.$entityType.fields.isLocked")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Tools\Lock;
|
||||
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\ErrorSilent;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\ForbiddenSilent;
|
||||
use Espo\Core\MassAction\Result;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\ORM\Collection;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\ORM\EntityManager;
|
||||
|
||||
class LockService
|
||||
{
|
||||
public function __construct(
|
||||
private Acl $acl,
|
||||
private EntityManager $entityManager,
|
||||
private LockMetadataProvider $lockMetadataProvider,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param Collection<Entity> $collection
|
||||
*/
|
||||
public function massLock(Collection $collection): Result
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
foreach ($collection as $entity) {
|
||||
try {
|
||||
$this->lock($entity);
|
||||
} catch (Error|Forbidden|BadRequest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count ++;
|
||||
}
|
||||
|
||||
return new Result(count: $count);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<Entity> $collection
|
||||
*/
|
||||
public function massUnlock(Collection $collection): Result
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
foreach ($collection as $entity) {
|
||||
try {
|
||||
$this->unlock($entity);
|
||||
} catch (Error|Forbidden|BadRequest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count ++;
|
||||
}
|
||||
|
||||
return new Result(count: $count);
|
||||
}
|
||||
|
||||
public function isEnabled(string $entityType): bool
|
||||
{
|
||||
return $this->lockMetadataProvider->isEnabled($entityType);
|
||||
}
|
||||
|
||||
public function isAllowed(string $entityType): bool
|
||||
{
|
||||
if (!$this->acl->checkScope($entityType, Acl\Table::ACTION_EDIT)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->acl->getPermissionLevel(Acl\Permission::LOCK) !== Acl\Table::LEVEL_YES) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
* @throws Forbidden
|
||||
* @throws BadRequest
|
||||
*/
|
||||
public function lock(Entity $entity): void
|
||||
{
|
||||
$this->check($entity);
|
||||
|
||||
if ($entity->get(Field::IS_LOCKED)) {
|
||||
throw new BadRequest("Already locked.");
|
||||
}
|
||||
|
||||
$this->lockEntity($entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
* @throws Forbidden
|
||||
* @throws BadRequest
|
||||
*/
|
||||
public function unlock(Entity $entity): void
|
||||
{
|
||||
$this->check($entity);
|
||||
|
||||
if (!$entity->get(Field::IS_LOCKED)) {
|
||||
throw new BadRequest("Already unlocked.");
|
||||
}
|
||||
|
||||
$this->unlockEntity($entity);
|
||||
}
|
||||
|
||||
private function lockEntity(Entity $entity): void
|
||||
{
|
||||
if ($entity->get(Field::IS_LOCKED)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entity->set(Field::IS_LOCKED, true);
|
||||
|
||||
$this->entityManager->saveEntity($entity);
|
||||
|
||||
}
|
||||
|
||||
private function unlockEntity(Entity $entity): void
|
||||
{
|
||||
if (!$entity->get(Field::IS_LOCKED)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entity->set(Field::IS_LOCKED, false);
|
||||
|
||||
$this->entityManager->saveEntity($entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Error
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function check(Entity $entity): void
|
||||
{
|
||||
if (!$this->isEnabled($entity->getEntityType())) {
|
||||
throw new ErrorSilent("Lock is not enabled.");
|
||||
}
|
||||
|
||||
if ($this->acl->getPermissionLevel(Acl\Permission::LOCK) !== Acl\Table::LEVEL_YES) {
|
||||
throw new ForbiddenSilent("Not lock permission.");
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntityEdit($entity)) {
|
||||
throw new ForbiddenSilent("No edit access.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Tools\Lock;
|
||||
|
||||
use Espo\Core\Acl\AssignmentChecker\Helper;
|
||||
use Espo\Core\Exceptions\Conflict;
|
||||
use Espo\Core\Exceptions\ConflictSilent;
|
||||
use Espo\Core\Exceptions\Error\Body;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Core\Utils\FieldUtil;
|
||||
use Espo\Core\Utils\Language;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\ORM\Defs;
|
||||
use Espo\ORM\Defs\Params\FieldParam;
|
||||
use Espo\ORM\Entity;
|
||||
|
||||
class LockValidationHelper
|
||||
{
|
||||
private const string PARAM_NOT_LOCKABLE = 'notLockable';
|
||||
|
||||
/** @var string[] */
|
||||
private array $ignoreFieldList = [
|
||||
Field::MODIFIED_AT,
|
||||
Field::MODIFIED_BY,
|
||||
Field::IS_LOCKED,
|
||||
Field::STREAM_UPDATED_AT,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private LockMetadataProvider $lockMetadataProvider,
|
||||
private FieldUtil $fieldUtil,
|
||||
private Defs $defs,
|
||||
private Log $log,
|
||||
private Language $language,
|
||||
private Helper $assignmentHelper,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Conflict
|
||||
*/
|
||||
public function validateBeforeSave(Entity $entity): void
|
||||
{
|
||||
if ($entity->isNew()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->lockMetadataProvider->isEnabled($entity->getEntityType())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$entity->getFetched(Field::IS_LOCKED)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$changedField = $this->getChangedLockedField($entity);
|
||||
|
||||
if ($changedField === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->log->info("Cannot modify a locked record, '{field}' cannot be changed.", ['field' => $changedField]);
|
||||
|
||||
$fieldLabel = $this->language->translateLabel($changedField, 'fields', $entity->getEntityType());
|
||||
|
||||
throw ConflictSilent::createWithBody(
|
||||
'cannotModifyLockedRecord',
|
||||
Body::create()->withMessageTranslation('cannotModifyLockedRecord', data: ['field' => $fieldLabel])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Conflict
|
||||
*/
|
||||
public function validateBeforeRemove(Entity $entity): void
|
||||
{
|
||||
if (!$this->lockMetadataProvider->isEnabled($entity->getEntityType())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$entity->get(Field::IS_LOCKED)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw ConflictSilent::createWithBody(
|
||||
'cannotRemoveLockedRecord',
|
||||
Body::create()->withMessageTranslation('cannotRemoveLockedRecord')
|
||||
);
|
||||
}
|
||||
|
||||
private function getChangedLockedField(Entity $entity): ?string
|
||||
{
|
||||
$entityType = $entity->getEntityType();
|
||||
|
||||
$entityDefs = $this->defs->getEntity($entityType);
|
||||
|
||||
$ignoreFieldList = $this->getIgnoreFieldList($entityType);
|
||||
|
||||
foreach ($entityDefs->getFieldList() as $fieldDefs) {
|
||||
$field = $fieldDefs->getName();
|
||||
|
||||
if (
|
||||
$fieldDefs->getParam(FieldParam::READ_ONLY) ||
|
||||
$fieldDefs->getParam(FieldParam::READ_ONLY_AFTER_CREATE)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($field, $ignoreFieldList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($fieldDefs->getParam(self::PARAM_NOT_LOCKABLE)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($this->fieldUtil->getActualAttributeList($entityType, $field) as $attribute) {
|
||||
if ($entity->isAttributeChanged($attribute)) {
|
||||
return $field;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function getIgnoreFieldList(string $entityType): array
|
||||
{
|
||||
$ignoreFieldList = $this->ignoreFieldList;
|
||||
|
||||
$entityDefs = $this->defs->getEntity($entityType);
|
||||
|
||||
if ($this->assignmentHelper->hasCollaboratorsField($entityType)) {
|
||||
if ($this->assignmentHelper->hasAssignedUsersField($entityType)) {
|
||||
if (
|
||||
$entityDefs->tryGetField(Field::ASSIGNED_USERS)
|
||||
?->getParam(self::PARAM_NOT_LOCKABLE)
|
||||
) {
|
||||
$ignoreFieldList[] = Field::COLLABORATORS;
|
||||
}
|
||||
} else if ($this->assignmentHelper->hasAssignedUserField($entityType)) {
|
||||
if (
|
||||
$entityDefs->tryGetField(Field::ASSIGNED_USER)
|
||||
?->getParam(self::PARAM_NOT_LOCKABLE)
|
||||
) {
|
||||
$ignoreFieldList[] = Field::COLLABORATORS;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $ignoreFieldList;
|
||||
}
|
||||
}
|
||||
@@ -277,6 +277,10 @@ class AclManager {
|
||||
return this.checkScope(subject, action, precise);
|
||||
}
|
||||
|
||||
if (!subject) {
|
||||
throw new Error("No model.");
|
||||
}
|
||||
|
||||
return this.checkModel(subject, action, precise);
|
||||
}
|
||||
|
||||
|
||||
@@ -1268,6 +1268,11 @@ class App {
|
||||
|
||||
break;
|
||||
|
||||
case 409:
|
||||
this._processErrorAlert(xhr, 'Conflict');
|
||||
|
||||
break;
|
||||
|
||||
case 404:
|
||||
// noinspection JSUnresolvedReference
|
||||
if (options.main) {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
import ActionHandler from 'action-handler';
|
||||
import {inject} from 'di';
|
||||
import AclManager from 'acl-manager';
|
||||
import Metadata from 'metadata';
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
export default class LockActionHandler extends ActionHandler {
|
||||
|
||||
/**
|
||||
* @type {AclManager}
|
||||
*/
|
||||
@inject(AclManager)
|
||||
acl
|
||||
|
||||
/**
|
||||
* @type {Metadata}
|
||||
*/
|
||||
@inject(Metadata)
|
||||
metadata
|
||||
|
||||
async actionLock() {
|
||||
const model = this.view.model;
|
||||
|
||||
Espo.Ui.notifyWait();
|
||||
|
||||
let attributes;
|
||||
|
||||
try {
|
||||
attributes = await Espo.Ajax.postRequest('Action', {
|
||||
action: 'lock',
|
||||
entityType: model.entityType,
|
||||
id: model.id,
|
||||
});
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.setMultiple(attributes, {sync: true});
|
||||
model.trigger('sync', this.model, null, {});
|
||||
|
||||
Espo.Ui.success(this.view.translate('locked', 'messages'));
|
||||
}
|
||||
|
||||
async actionUnlock() {
|
||||
const model = this.view.model;
|
||||
|
||||
Espo.Ui.notifyWait();
|
||||
|
||||
let attributes;
|
||||
|
||||
try {
|
||||
attributes = await Espo.Ajax.postRequest('Action', {
|
||||
action: 'unlock',
|
||||
entityType: model.entityType,
|
||||
id: model.id,
|
||||
});
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.setMultiple(attributes, {sync: true});
|
||||
model.trigger('sync', this.model, null, {});
|
||||
|
||||
Espo.Ui.success(this.view.translate('unlocked', 'messages'));
|
||||
}
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
canBeLocked() {
|
||||
const model = this.view.model;
|
||||
|
||||
if (!this.isEnabled(model.entityType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.acl.getPermissionLevel('lock') !== 'yes') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (model.attributes.isLocked) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
canBeUnlocked() {
|
||||
const model = this.view.model;
|
||||
|
||||
if (!this.isEnabled(model.entityType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.acl.getPermissionLevel('lock') !== 'yes') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!model.attributes.isLocked) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} entityType
|
||||
* @return {boolean}
|
||||
*/
|
||||
isEnabled(entityType) {
|
||||
return this.metadata.get(`scopes.${entityType}.lockable`) === true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
import ActionHandler from 'action-handler';
|
||||
import {inject} from 'di';
|
||||
import AclManager from 'acl-manager';
|
||||
import Language from 'language';
|
||||
import MassActionHelper from 'helpers/mass-action';
|
||||
import Metadata from 'metadata';
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
export default class LockMassActionHandler extends ActionHandler {
|
||||
|
||||
/**
|
||||
* @type {AclManager}
|
||||
*/
|
||||
@inject(AclManager)
|
||||
acl
|
||||
|
||||
/**
|
||||
* @type {Language}
|
||||
*/
|
||||
@inject(Language)
|
||||
language
|
||||
|
||||
/**
|
||||
* @type {Metadata}
|
||||
*/
|
||||
@inject(Metadata)
|
||||
metadata
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
async actionLock() {
|
||||
const msg = this.language.translate('confirmMassLock', 'messages');
|
||||
|
||||
await this.view.confirm(msg);
|
||||
|
||||
await this.process('lock');
|
||||
}
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
async actionUnlock() {
|
||||
const msg = this.language.translate('confirmMassUnlock', 'messages');
|
||||
|
||||
await this.view.confirm(msg);
|
||||
|
||||
await this.process('unlock');
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} action
|
||||
*/
|
||||
async process(action) {
|
||||
const helper = new MassActionHelper(this.view);
|
||||
const params = this.view.getMassActionSelectionPostData();
|
||||
const idle = !!params.searchParams && helper.checkIsIdle(this.view.collection.total);
|
||||
|
||||
const onDone = count => {
|
||||
const labelKey = action === 'lock' ? 'massLockDone': 'massUnlockDone';
|
||||
|
||||
const msg = this.view.translate(labelKey, 'messages')
|
||||
.replace('{count}', count.toString());
|
||||
|
||||
Espo.Ui.success(msg);
|
||||
};
|
||||
|
||||
Espo.Ui.notifyWait();
|
||||
|
||||
const result = await Espo.Ajax.postRequest('MassAction', {
|
||||
entityType: this.view.entityType,
|
||||
action: action,
|
||||
params: params,
|
||||
idle: idle,
|
||||
});
|
||||
|
||||
if (result.id) {
|
||||
const view = await helper.process(result.id, action)
|
||||
|
||||
this.view.listenToOnce(view, 'close:success', result => onDone(result.count));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onDone(result.count);
|
||||
}
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
initLock() {
|
||||
if (
|
||||
!this.view.collection ||
|
||||
!this.isEnabled(this.view.collection.entityType) ||
|
||||
this.acl.getPermissionLevel('massUpdate') !== 'yes' ||
|
||||
this.acl.getPermissionLevel('lock') !== 'yes'
|
||||
) {
|
||||
this.view.removeMassAction('lock');
|
||||
}
|
||||
}
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
initUnlock() {
|
||||
if (
|
||||
!this.view.collection ||
|
||||
!this.isEnabled(this.view.collection.entityType) ||
|
||||
this.acl.getPermissionLevel('massUpdate') !== 'yes' ||
|
||||
this.acl.getPermissionLevel('lock') !== 'yes'
|
||||
) {
|
||||
this.view.removeMassAction('unlock');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} entityType
|
||||
* @return {boolean}
|
||||
*/
|
||||
isEnabled(entityType) {
|
||||
return this.metadata.get(`scopes.${entityType}.lockable`) === true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
import {inject} from 'di';
|
||||
import Metadata from 'metadata';
|
||||
import AssignmentHelper from 'helpers/assignment';
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
export default class LockViewSetup {
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {Metadata}
|
||||
*/
|
||||
@inject(Metadata)
|
||||
metadata
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {import('model').default}
|
||||
*/
|
||||
model
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {import('views/record/detail').default}
|
||||
*/
|
||||
view
|
||||
|
||||
ignoreFieldList = [
|
||||
'isLocked',
|
||||
'modifiedAt',
|
||||
'modifiedBy',
|
||||
'streamUpdatedAt',
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {import('views/record/detail').default} view
|
||||
*/
|
||||
constructor(view) {
|
||||
this.view = view;
|
||||
this.model = view.model;
|
||||
}
|
||||
|
||||
process() {
|
||||
const entityType = this.model.entityType;
|
||||
|
||||
if (this.metadata.get(`scopes.${entityType}.lockable`) !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
let wasLocked = false;
|
||||
const lockedMap = {};
|
||||
|
||||
const ignoreFieldList = this.getIgnoreFieldList();
|
||||
|
||||
const fieldsDefs = /** @type {Record.<string, Record>} */
|
||||
this.metadata.get(`entityDefs.${entityType}.fields`) ?? {};
|
||||
|
||||
const lockableFields = Object.keys(fieldsDefs)
|
||||
.filter(field => {
|
||||
if (ignoreFieldList.includes(field)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const defs = fieldsDefs[field];
|
||||
|
||||
return !defs.notLockable &&
|
||||
!defs.readOnly &&
|
||||
!defs.readOnlyAfterCreate;
|
||||
});
|
||||
|
||||
const controlLocked = () => {
|
||||
const isLocked = this.model.attributes.isLocked;
|
||||
|
||||
if (!isLocked && !wasLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLocked) {
|
||||
wasLocked = true;
|
||||
}
|
||||
|
||||
lockableFields.forEach(field => {
|
||||
if (isLocked) {
|
||||
if (
|
||||
this.view.recordHelper &&
|
||||
this.view.recordHelper.getFieldStateParam(field, 'readOnly')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
lockedMap[field] = true;
|
||||
|
||||
this.view.setFieldReadOnly(field);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lockedMap[field]) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete lockedMap[field];
|
||||
|
||||
this.view.setFieldNotReadOnly(field);
|
||||
});
|
||||
}
|
||||
|
||||
controlLocked();
|
||||
this.view.listenTo(this.model, 'change:isLocked', () => controlLocked());
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @return {string[]}
|
||||
*/
|
||||
getIgnoreFieldList() {
|
||||
const entityType = this.model.entityType;
|
||||
|
||||
const ignoreFieldList = [...this.ignoreFieldList];
|
||||
|
||||
const helper = new AssignmentHelper;
|
||||
|
||||
if (helper.hasCollaboratorsField(entityType)) {
|
||||
if (helper.hasAssignedUsersField(entityType)) {
|
||||
if (this.model.getFieldParam('assignedUsers', 'notLockable')) {
|
||||
ignoreFieldList.push('collaborators');
|
||||
}
|
||||
} else if (helper.hasAssignedUserField(entityType)) {
|
||||
if (this.model.getFieldParam('assignedUser', 'notLockable')) {
|
||||
ignoreFieldList.push('collaborators');
|
||||
}
|
||||
}
|
||||
}
|
||||
return ignoreFieldList;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
import {inject} from 'di';
|
||||
import Metadata from 'metadata';
|
||||
|
||||
/**
|
||||
* @since 9.4.0
|
||||
*/
|
||||
export default class AssignmentHelper {
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {Metadata}
|
||||
*/
|
||||
@inject(Metadata)
|
||||
metadata
|
||||
|
||||
/**
|
||||
* @param {string} entityType
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasAssignedUserField(entityType) {
|
||||
return this.metadata.get(`entityDefs.${entityType}.fields.assignedUser.type`) === 'link' &&
|
||||
!this.metadata.get(`entityDefs.${entityType}.fields.assignedUser.disabled`) &&
|
||||
this.metadata.get(`entityDefs.${entityType}.links.assignedUser.entity`) === 'User';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} entityType
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasAssignedUsersField(entityType) {
|
||||
return this.metadata.get(`entityDefs.${entityType}.fields.assignedUsers.type`) === 'linkMultiple' &&
|
||||
this.metadata.get(`entityDefs.${entityType}.links.assignedUsers.entity`) === 'User';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} entityType
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasCollaboratorsField(entityType) {
|
||||
if (!this.metadata.get(`scopes.${entityType}.collaborators`)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.metadata.get(`entityDefs.${entityType}.fields.collaborators.type`) === 'linkMultiple' &&
|
||||
this.metadata.get(`entityDefs.${entityType}.links.collaborators.entity`) === 'User';
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,12 @@ class FieldManagerEditView extends View {
|
||||
*/
|
||||
paramDataList
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {boolean}
|
||||
*/
|
||||
isEntityTypeLockable
|
||||
|
||||
data() {
|
||||
return {
|
||||
scope: this.scope,
|
||||
@@ -146,6 +152,8 @@ class FieldManagerEditView extends View {
|
||||
|
||||
this.entityTypeIsCustom = !!this.getMetadata().get(['scopes', this.scope, 'isCustom']);
|
||||
|
||||
this.isEntityTypeLockable = this.getMetadata().get(`scopes.${this.scope}.lockable`) === true;
|
||||
|
||||
this.globalRestriction = {};
|
||||
|
||||
if (!this.isNew) {
|
||||
@@ -312,6 +320,16 @@ class FieldManagerEditView extends View {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
this.isEntityTypeLockable &&
|
||||
!this.globalRestriction.readOnly
|
||||
) {
|
||||
this.paramList.push({
|
||||
name: 'notLockable',
|
||||
type: 'bool',
|
||||
});
|
||||
}
|
||||
|
||||
if (this.hasTooltipText) {
|
||||
this.paramList.push({
|
||||
name: 'tooltipText',
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
import BoolFieldView from 'views/fields/bool';
|
||||
|
||||
export default class IsLockedFieldView extends BoolFieldView {
|
||||
|
||||
//language=Handlebars
|
||||
detailTemplateContent = `
|
||||
{{~#if valueIsSet~}}
|
||||
{{~#if value~}}
|
||||
<span class="fas fa-lock text-soft"></span>
|
||||
{{~else~}}
|
||||
<input
|
||||
class="form-checkbox"
|
||||
type="checkbox" {{#if value}} checked {{/if}}
|
||||
disabled
|
||||
>
|
||||
{{~/if~}}
|
||||
{{~else~}}
|
||||
<span class="loading-value"></span>
|
||||
{{~/if~}}
|
||||
`
|
||||
}
|
||||
@@ -2330,7 +2330,7 @@ class ListRecordView extends View {
|
||||
|
||||
/** @private */
|
||||
setupMassActions() {
|
||||
if (this.massActionsDisabled) {
|
||||
if (this.massActionsDisabled || !this.checkboxes) {
|
||||
this.massActionList = [];
|
||||
this.checkAllResultMassActionList = [];
|
||||
this.massActionDefs = {};
|
||||
@@ -2391,7 +2391,7 @@ class ListRecordView extends View {
|
||||
|
||||
if (
|
||||
!Espo.Utils.checkActionAvailability(this.getHelper(), defs) ||
|
||||
!Espo.Utils.checkActionAccess(this.getAcl(), this.entityType, defs)
|
||||
this.entityType && !Espo.Utils.checkActionAccess(this.getAcl(), this.entityType, defs)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,12 @@ class DefaultSidePanelView extends SidePanelView {
|
||||
*/
|
||||
complexModifiedDisabled
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* @type {boolean}
|
||||
*/
|
||||
hasIsLocked
|
||||
|
||||
data() {
|
||||
const data = super.data();
|
||||
|
||||
@@ -74,12 +80,24 @@ class DefaultSidePanelView extends SidePanelView {
|
||||
allFieldList.includes('modifiedAt') ||
|
||||
allFieldList.includes('modifiedBy');
|
||||
|
||||
this.hasIsLocked = this.getMetadata().get(`scopes.${this.model.entityType}.lockable`) === true;
|
||||
|
||||
super.setup();
|
||||
}
|
||||
|
||||
setupFields() {
|
||||
super.setupFields();
|
||||
|
||||
if (this.hasIsLocked) {
|
||||
this.fieldList.push({
|
||||
name: 'isLocked',
|
||||
view: 'views/global/fields/is-locked',
|
||||
});
|
||||
|
||||
this.controlIsLockedField();
|
||||
this.listenTo(this.model, 'change:isLocked', () => this.controlIsLockedField());
|
||||
}
|
||||
|
||||
if (!this.complexCreatedDisabled) {
|
||||
if (this.hasComplexCreated) {
|
||||
this.fieldList.push({
|
||||
@@ -194,6 +212,17 @@ class DefaultSidePanelView extends SidePanelView {
|
||||
|
||||
this.recordViewObject.hideField('followers');
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
controlIsLockedField() {
|
||||
if (this.model.attributes.isLocked) {
|
||||
this.recordViewObject.showField('isLocked');
|
||||
} else {
|
||||
this.recordViewObject.hideField('isLocked');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default DefaultSidePanelView;
|
||||
|
||||
@@ -14,6 +14,118 @@
|
||||
]
|
||||
},
|
||||
"description": "Classes for additional handling of a select query. Should implement Espo\\Core\\Select\\Applier\\AdditionalApplier interface. An entity type is passed to the constructor in `$entityType` parameter. As of v9.0."
|
||||
},
|
||||
"createInputFilterClassNameList": {
|
||||
"description": "Create input filters. Should implement Espo\\Core\\Record\\Input\\Filter. As of v8.2.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"updateInputFilterClassNameList": {
|
||||
"description": "Update input filters. Should implement Espo\\Core\\Record\\Input\\Filter. As of v8.2.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"outputFilterClassNameList": {
|
||||
"description": "Output filters. Should implement Espo\\Core\\Record\\Output\\Filter. As of v8.2.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"beforeReadHookClassNameList": {
|
||||
"description": "Before-read hooks. Should implement the Espo\\Core\\Record\\Hook\\ReadHook interface.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"earlyBeforeCreateHookClassNameList": {
|
||||
"description": "Early-before-create hooks. Run before validation. Should implement the Espo\\Core\\Record\\Hook\\CreateHook or Espo\\Core\\Record\\Hook\\SaveHook interface. As of v9.0.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"beforeCreateHookClassNameList": {
|
||||
"description": "Before-create hooks. Should implement the Espo\\Core\\Record\\Hook\\CreateHook or Espo\\Core\\Record\\Hook\\SaveHook interface.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"afterCreateHookClassNameList": {
|
||||
"description": "After-create hooks. Should implement the Espo\\Core\\Record\\Hook\\CreateHook or Espo\\Core\\Record\\Hook\\SaveHook interface. As of v8.2.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"earlyBeforeUpdateHookClassNameList": {
|
||||
"description": "Early-before-update hooks. Run before validation. Should implement the Espo\\Core\\Record\\Hook\\UpdateHook or Espo\\Core\\Record\\Hook\\SaveHook interface. As of v9.0.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"beforeUpdateHookClassNameList": {
|
||||
"description": "Before-update hooks. Should implement the Espo\\Core\\Record\\Hook\\UpdateHook or Espo\\Core\\Record\\Hook\\SaveHook interface.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"afterUpdateHookClassNameList": {
|
||||
"description": "Before-update hooks. Should implement the Espo\\Core\\Record\\Hook\\UpdateHook or Espo\\Core\\Record\\Hook\\SaveHook interface. As of v8.2.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"beforeDeleteHookClassNameList": {
|
||||
"description": "Before-delete hooks. Should implement the Espo\\Core\\Record\\Hook\\DeleteHook interface.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"afterDeleteHookClassNameList": {
|
||||
"description": "Before-delete hooks. Should implement the Espo\\Core\\Record\\Hook\\DeleteHook interface. As of v8.2.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"beforeLinkHookClassNameList": {
|
||||
"description": "Before-link hooks. Should implement the Espo\\Core\\Record\\Hook\\LinkHook interface.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"beforeUnlinkHookClassNameList": {
|
||||
"description": "Before-unlink hooks. Should implement the Espo\\Core\\Record\\Hook\\UnlinkHook interface.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"afterLinkHookClassNameList": {
|
||||
"description": "After-link hooks. Should implement the Espo\\Core\\Record\\Hook\\LinkHook interface. As of v8.2.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"afterUnlinkHookClassNameList": {
|
||||
"description": "After-unlink hooks. Should implement the Espo\\Core\\Record\\Hook\\UnlinkHook interface. As of v8.2.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1256,6 +1256,15 @@
|
||||
},
|
||||
"description": "Dependee attributes."
|
||||
},
|
||||
"massUpdateActionList": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Actions for mass update. Overrides the value from fields metadata."
|
||||
},
|
||||
"notLockable": {
|
||||
"type": "boolean",
|
||||
"description": "Is not locked when the record is locked. As of v9.4."
|
||||
},
|
||||
"select": {
|
||||
"type": "object",
|
||||
"description": "A custom complex expression to be used for the field when reading from DB. It's possible to define leftJoins and joins that are needed to be applied when the field is being selected.",
|
||||
@@ -1354,11 +1363,6 @@
|
||||
"description": "Apply DISTINCT to the query."
|
||||
}
|
||||
}
|
||||
},
|
||||
"massUpdateActionList": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Actions for mass update. Overrides the value from fields metadata."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
"description": "Enables collaborators.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"lockable": {
|
||||
"description": "Enables record locking.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"importable": {
|
||||
"description": "Whether the entity is available in the Import tool.",
|
||||
"type": "boolean"
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2026 EspoCRM, Inc.
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program 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.
|
||||
*
|
||||
* This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace tests\integration\Espo\Tools\Lock;
|
||||
|
||||
use Espo\Core\Action\ActionFactory;
|
||||
use Espo\Core\Action\Data;
|
||||
use Espo\Core\Action\Params;
|
||||
use Espo\Core\Name\Field;
|
||||
use Espo\Modules\Crm\Entities\Account;
|
||||
use Espo\ORM\Exceptions\ValidationException;
|
||||
use tests\integration\Core\BaseTestCase;
|
||||
|
||||
class LockTest extends BaseTestCase
|
||||
{
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
public function testLock1(): void
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
$account = $em->getRDBRepositoryByClass(Account::class)->getNew();
|
||||
$account->setName('Test');
|
||||
$em->saveEntity($account);
|
||||
|
||||
$account->set(Field::IS_LOCKED, true);
|
||||
$em->saveEntity($account);
|
||||
|
||||
$this->expectException(ValidationException::class);
|
||||
|
||||
$account->setName('Test 1');
|
||||
$em->saveEntity($account);
|
||||
|
||||
//
|
||||
|
||||
$actionFactory = $this->getInjectableFactory()->create(ActionFactory::class);
|
||||
|
||||
$actionFactory->create('Unlock', Account::ENTITY_TYPE)->process(
|
||||
params: new Params(
|
||||
entityType: Account::ENTITY_TYPE,
|
||||
id: $account->getId(),
|
||||
),
|
||||
data: Data::fromRaw((object) [])
|
||||
);
|
||||
|
||||
|
||||
$em->refreshEntity($account);
|
||||
|
||||
$this->assertFalse($account->get(Field::IS_LOCKED));
|
||||
|
||||
//
|
||||
|
||||
$actionFactory->create('Lock', Account::ENTITY_TYPE)->process(
|
||||
params: new Params(
|
||||
entityType: Account::ENTITY_TYPE,
|
||||
id: $account->getId(),
|
||||
),
|
||||
data: Data::fromRaw((object) [])
|
||||
);
|
||||
|
||||
|
||||
$em->refreshEntity($account);
|
||||
|
||||
$this->assertTrue($account->get(Field::IS_LOCKED));
|
||||
}
|
||||
}
|
||||
@@ -214,8 +214,10 @@ class HookManagerTest extends \PHPUnit\Framework\TestCase
|
||||
$this->metadata
|
||||
->expects($this->any())
|
||||
->method('get')
|
||||
->with(['recordDefs', $this->entityType, $type . 'HookClassNameList'])
|
||||
->willReturn($hookClassNameList);
|
||||
->willReturnMap([
|
||||
["app.record.{$type}HookClassNameList", [], []],
|
||||
["recordDefs.$this->entityType.{$type}HookClassNameList", [], $hookClassNameList],
|
||||
]);
|
||||
|
||||
foreach ($hookClassNameList as $i => $className) {
|
||||
$this->injectableFactory
|
||||
|
||||
Reference in New Issue
Block a user