From dcd3ce66ce368269125c85eadf91c7c4cfd63fad Mon Sep 17 00:00:00 2001 From: Yurii Date: Thu, 26 Feb 2026 13:59:14 +0200 Subject: [PATCH] lockable --- .../Common/Lock/BeforeDeleteValidation.php | 50 +++++ .../Common/Lock/BeforeSaveValidation.php | 52 +++++ .../Core/Acl/AssignmentChecker/Helper.php | 2 + application/Espo/Core/Acl/Permission.php | 1 + application/Espo/Core/Action/Action.php | 2 + application/Espo/Core/Action/Actions/Lock.php | 72 +++++++ .../Espo/Core/Action/Actions/Merge/Merger.php | 42 ++-- .../Espo/Core/Action/Actions/Unlock.php | 72 +++++++ application/Espo/Core/Action/Service.php | 2 + application/Espo/Core/Api/ErrorOutput.php | 8 + .../Espo/Core/MassAction/Actions/MassLock.php | 81 ++++++++ .../Core/MassAction/Actions/MassUnlock.php | 81 ++++++++ application/Espo/Core/Name/Field.php | 1 + .../Espo/Core/Record/Hook/CreateHook.php | 2 +- .../Espo/Core/Record/Hook/DeleteHook.php | 2 +- .../Espo/Core/Record/Hook/LinkHook.php | 2 +- .../Espo/Core/Record/Hook/Provider.php | 5 +- .../Espo/Core/Record/Hook/ReadHook.php | 2 +- .../Espo/Core/Record/Hook/SaveHook.php | 2 +- .../Espo/Core/Record/Hook/UnlinkHook.php | 2 +- .../Espo/Core/Record/Hook/UpdateHook.php | 2 +- .../Espo/Core/Record/Input/FilterProvider.php | 10 +- .../Core/Record/Output/FilterProvider.php | 5 +- application/Espo/Core/Record/Service.php | 2 +- .../Espo/Core/Utils/Metadata/Builder.php | 19 ++ .../Espo/Hooks/Common/ValidateLocked.php | 84 ++++++++ .../metadata/app/entityManagerParams.json | 84 ++++++++ .../metadata/entityDefs/Account.json | 15 +- .../Resources/metadata/scopes/Account.json | 3 +- .../Espo/ORM/Defs/Params/FieldParam.php | 7 + .../ORM/Exceptions/ValidationException.php | 40 ++++ .../Espo/Resources/i18n/en_US/Admin.json | 3 +- .../Resources/i18n/en_US/EntityManager.json | 4 +- .../Espo/Resources/i18n/en_US/Global.json | 23 ++- .../Espo/Resources/i18n/en_US/Role.json | 2 + .../Espo/Resources/layouts/Role/detail.json | 5 + .../Espo/Resources/metadata/app/acl.json | 9 +- .../Espo/Resources/metadata/app/actions.json | 6 + .../Resources/metadata/app/entityManager.json | 3 +- .../metadata/app/entityManagerParams.json | 28 +++ .../Resources/metadata/app/massActions.json | 6 + .../Espo/Resources/metadata/app/record.json | 9 + .../Resources/metadata/clientDefs/Global.json | 52 ++++- .../Resources/metadata/entityDefs/Role.json | 9 + .../Hook/Hooks/LockableUpdateHook.php | 148 ++++++++++++++ .../Espo/Tools/EntityManager/NameUtil.php | 1 + .../Espo/Tools/FieldManager/FieldManager.php | 6 + .../Espo/Tools/Lock/LockMetadataProvider.php | 52 +++++ application/Espo/Tools/Lock/LockService.php | 183 ++++++++++++++++++ .../Espo/Tools/Lock/LockValidationHelper.php | 181 +++++++++++++++++ client/src/acl-manager.js | 4 + client/src/app.js | 5 + client/src/handlers/record/lock-action.js | 147 ++++++++++++++ .../src/handlers/record/lock-mass-action.js | 145 ++++++++++++++ client/src/handlers/record/lock-view-setup.js | 163 ++++++++++++++++ client/src/helpers/assignment.js | 75 +++++++ client/src/views/admin/field-manager/edit.js | 18 ++ client/src/views/global/fields/is-locked.js | 49 +++++ client/src/views/record/list.js | 4 +- .../src/views/record/panels/default-side.js | 29 +++ schema/metadata/app/record.json | 112 +++++++++++ schema/metadata/entityDefs.json | 14 +- schema/metadata/scopes.json | 4 + .../integration/Espo/Tools/Lock/LockTest.php | 91 +++++++++ .../unit/Espo/Core/Record/HookManagerTest.php | 6 +- 65 files changed, 2298 insertions(+), 52 deletions(-) create mode 100644 application/Espo/Classes/Record/Common/Lock/BeforeDeleteValidation.php create mode 100644 application/Espo/Classes/Record/Common/Lock/BeforeSaveValidation.php create mode 100644 application/Espo/Core/Action/Actions/Lock.php create mode 100644 application/Espo/Core/Action/Actions/Unlock.php create mode 100644 application/Espo/Core/MassAction/Actions/MassLock.php create mode 100644 application/Espo/Core/MassAction/Actions/MassUnlock.php create mode 100644 application/Espo/Hooks/Common/ValidateLocked.php create mode 100644 application/Espo/ORM/Exceptions/ValidationException.php create mode 100644 application/Espo/Tools/EntityManager/Hook/Hooks/LockableUpdateHook.php create mode 100644 application/Espo/Tools/Lock/LockMetadataProvider.php create mode 100644 application/Espo/Tools/Lock/LockService.php create mode 100644 application/Espo/Tools/Lock/LockValidationHelper.php create mode 100644 client/src/handlers/record/lock-action.js create mode 100644 client/src/handlers/record/lock-mass-action.js create mode 100644 client/src/handlers/record/lock-view-setup.js create mode 100644 client/src/helpers/assignment.js create mode 100644 client/src/views/global/fields/is-locked.js create mode 100644 tests/integration/Espo/Tools/Lock/LockTest.php diff --git a/application/Espo/Classes/Record/Common/Lock/BeforeDeleteValidation.php b/application/Espo/Classes/Record/Common/Lock/BeforeDeleteValidation.php new file mode 100644 index 0000000000..f1fb1c093c --- /dev/null +++ b/application/Espo/Classes/Record/Common/Lock/BeforeDeleteValidation.php @@ -0,0 +1,50 @@ +. + * + * 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); + } +} diff --git a/application/Espo/Classes/Record/Common/Lock/BeforeSaveValidation.php b/application/Espo/Classes/Record/Common/Lock/BeforeSaveValidation.php new file mode 100644 index 0000000000..d56abfa9da --- /dev/null +++ b/application/Espo/Classes/Record/Common/Lock/BeforeSaveValidation.php @@ -0,0 +1,52 @@ +. + * + * 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); + } +} diff --git a/application/Espo/Core/Acl/AssignmentChecker/Helper.php b/application/Espo/Core/Acl/AssignmentChecker/Helper.php index 45a360169e..0cb569d3cf 100644 --- a/application/Espo/Core/Acl/AssignmentChecker/Helper.php +++ b/application/Espo/Core/Acl/AssignmentChecker/Helper.php @@ -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; } diff --git a/application/Espo/Core/Acl/Permission.php b/application/Espo/Core/Acl/Permission.php index 12a3722720..e49fd047b6 100644 --- a/application/Espo/Core/Acl/Permission.php +++ b/application/Espo/Core/Acl/Permission.php @@ -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'; } diff --git a/application/Espo/Core/Action/Action.php b/application/Espo/Core/Action/Action.php index 1fdf438c38..0367b6c850 100644 --- a/application/Espo/Core/Action/Action.php +++ b/application/Espo/Core/Action/Action.php @@ -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; } diff --git a/application/Espo/Core/Action/Actions/Lock.php b/application/Espo/Core/Action/Actions/Lock.php new file mode 100644 index 0000000000..ec7b3a5209 --- /dev/null +++ b/application/Espo/Core/Action/Actions/Lock.php @@ -0,0 +1,72 @@ +. + * + * 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); + } + } +} diff --git a/application/Espo/Core/Action/Actions/Merge/Merger.php b/application/Espo/Core/Action/Actions/Merge/Merger.php index 6a940b6b8c..605fca61ac 100644 --- a/application/Espo/Core/Action/Actions/Merge/Merger.php +++ b/application/Espo/Core/Action/Actions/Merge/Merger.php @@ -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); + } + } + } } diff --git a/application/Espo/Core/Action/Actions/Unlock.php b/application/Espo/Core/Action/Actions/Unlock.php new file mode 100644 index 0000000000..f84a886956 --- /dev/null +++ b/application/Espo/Core/Action/Actions/Unlock.php @@ -0,0 +1,72 @@ +. + * + * 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); + } + } +} diff --git a/application/Espo/Core/Action/Service.php b/application/Espo/Core/Action/Service.php index 0990825906..35ff72169e 100644 --- a/application/Espo/Core/Action/Service.php +++ b/application/Espo/Core/Action/Service.php @@ -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 { diff --git a/application/Espo/Core/Api/ErrorOutput.php b/application/Espo/Core/Api/ErrorOutput.php index 72eb2c96d8..ed2fad3fa5 100644 --- a/application/Espo/Core/Api/ErrorOutput.php +++ b/application/Espo/Core/Api/ErrorOutput.php @@ -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, 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; } diff --git a/application/Espo/Core/MassAction/Actions/MassLock.php b/application/Espo/Core/MassAction/Actions/MassLock.php new file mode 100644 index 0000000000..70743d766a --- /dev/null +++ b/application/Espo/Core/MassAction/Actions/MassLock.php @@ -0,0 +1,81 @@ +. + * + * 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); + } +} diff --git a/application/Espo/Core/MassAction/Actions/MassUnlock.php b/application/Espo/Core/MassAction/Actions/MassUnlock.php new file mode 100644 index 0000000000..d8fea43999 --- /dev/null +++ b/application/Espo/Core/MassAction/Actions/MassUnlock.php @@ -0,0 +1,81 @@ +. + * + * 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); + } +} diff --git a/application/Espo/Core/Name/Field.php b/application/Espo/Core/Name/Field.php index 2be665d6aa..c4e8f9d559 100644 --- a/application/Espo/Core/Name/Field.php +++ b/application/Espo/Core/Name/Field.php @@ -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'; } diff --git a/application/Espo/Core/Record/Hook/CreateHook.php b/application/Espo/Core/Record/Hook/CreateHook.php index f0e63b6f43..358e43f7fc 100644 --- a/application/Espo/Core/Record/Hook/CreateHook.php +++ b/application/Espo/Core/Record/Hook/CreateHook.php @@ -36,7 +36,7 @@ use Espo\ORM\Entity; use Espo\Core\Record\CreateParams; /** - * @template TEntity of Entity + * @template TEntity of Entity = Entity */ interface CreateHook { diff --git a/application/Espo/Core/Record/Hook/DeleteHook.php b/application/Espo/Core/Record/Hook/DeleteHook.php index aaa0232e06..490d7dbd26 100644 --- a/application/Espo/Core/Record/Hook/DeleteHook.php +++ b/application/Espo/Core/Record/Hook/DeleteHook.php @@ -36,7 +36,7 @@ use Espo\ORM\Entity; use Espo\Core\Record\DeleteParams; /** - * @template TEntity of Entity + * @template TEntity of Entity = Entity */ interface DeleteHook { diff --git a/application/Espo/Core/Record/Hook/LinkHook.php b/application/Espo/Core/Record/Hook/LinkHook.php index e41f837d39..cc6afd9532 100644 --- a/application/Espo/Core/Record/Hook/LinkHook.php +++ b/application/Espo/Core/Record/Hook/LinkHook.php @@ -32,7 +32,7 @@ namespace Espo\Core\Record\Hook; use Espo\ORM\Entity; /** - * @template TEntity of Entity + * @template TEntity of Entity = Entity */ interface LinkHook { diff --git a/application/Espo/Core/Record/Hook/Provider.php b/application/Espo/Core/Record/Hook/Provider.php index 94bfb02963..8d0b713788 100644 --- a/application/Espo/Core/Record/Hook/Provider.php +++ b/application/Espo/Core/Record/Hook/Provider.php @@ -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; diff --git a/application/Espo/Core/Record/Hook/ReadHook.php b/application/Espo/Core/Record/Hook/ReadHook.php index 706871325a..f3d2c2eff6 100644 --- a/application/Espo/Core/Record/Hook/ReadHook.php +++ b/application/Espo/Core/Record/Hook/ReadHook.php @@ -33,7 +33,7 @@ use Espo\ORM\Entity; use Espo\Core\Record\ReadParams; /** - * @template TEntity of Entity + * @template TEntity of Entity = Entity */ interface ReadHook { diff --git a/application/Espo/Core/Record/Hook/SaveHook.php b/application/Espo/Core/Record/Hook/SaveHook.php index a39bc7e87c..041de4d3cb 100644 --- a/application/Espo/Core/Record/Hook/SaveHook.php +++ b/application/Espo/Core/Record/Hook/SaveHook.php @@ -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 diff --git a/application/Espo/Core/Record/Hook/UnlinkHook.php b/application/Espo/Core/Record/Hook/UnlinkHook.php index 72200b7613..dd94b30a0a 100644 --- a/application/Espo/Core/Record/Hook/UnlinkHook.php +++ b/application/Espo/Core/Record/Hook/UnlinkHook.php @@ -32,7 +32,7 @@ namespace Espo\Core\Record\Hook; use Espo\ORM\Entity; /** - * @template TEntity of Entity + * @template TEntity of Entity = Entity */ interface UnlinkHook { diff --git a/application/Espo/Core/Record/Hook/UpdateHook.php b/application/Espo/Core/Record/Hook/UpdateHook.php index 3570797def..a1890eaaf0 100644 --- a/application/Espo/Core/Record/Hook/UpdateHook.php +++ b/application/Espo/Core/Record/Hook/UpdateHook.php @@ -37,7 +37,7 @@ use Espo\ORM\Entity; use Espo\Core\Record\UpdateParams; /** - * @template TEntity of Entity + * @template TEntity of Entity = Entity */ interface UpdateHook { diff --git a/application/Espo/Core/Record/Input/FilterProvider.php b/application/Espo/Core/Record/Input/FilterProvider.php index 3eb1bbb4d9..4d00c5da43 100644 --- a/application/Espo/Core/Record/Input/FilterProvider.php +++ b/application/Espo/Core/Record/Input/FilterProvider.php @@ -95,7 +95,10 @@ class FilterProvider private function getCreateClassNameList(string $entityType): array { /** @var class-string[] */ - 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[] */ - return $this->metadata->get("recordDefs.$entityType.updateInputFilterClassNameList") ?? []; + return [ + ...$this->metadata->get("app.record.updateInputFilterClassNameList", []), + ...$this->metadata->get("recordDefs.$entityType.updateInputFilterClassNameList", []) + ]; } } diff --git a/application/Espo/Core/Record/Output/FilterProvider.php b/application/Espo/Core/Record/Output/FilterProvider.php index 33e7e17cf3..fc5d8f0757 100644 --- a/application/Espo/Core/Record/Output/FilterProvider.php +++ b/application/Espo/Core/Record/Output/FilterProvider.php @@ -69,6 +69,9 @@ class FilterProvider private function getClassNameList(string $entityType): array { /** @var class-string>[] */ - return $this->metadata->get("recordDefs.$entityType.outputFilterClassNameList") ?? []; + return [ + ...$this->metadata->get("app.record.outputFilterClassNameList", []), + ...$this->metadata->get("recordDefs.$entityType.outputFilterClassNameList", []), + ]; } } diff --git a/application/Espo/Core/Record/Service.php b/application/Espo/Core/Record/Service.php index af3559537a..2f1b3f75ef 100644 --- a/application/Espo/Core/Record/Service.php +++ b/application/Espo/Core/Record/Service.php @@ -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; } diff --git a/application/Espo/Core/Utils/Metadata/Builder.php b/application/Espo/Core/Utils/Metadata/Builder.php index 57e39b4f80..2117b590b5 100644 --- a/application/Espo/Core/Utils/Metadata/Builder.php +++ b/application/Espo/Core/Utils/Metadata/Builder.php @@ -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'], diff --git a/application/Espo/Hooks/Common/ValidateLocked.php b/application/Espo/Hooks/Common/ValidateLocked.php new file mode 100644 index 0000000000..130067962b --- /dev/null +++ b/application/Espo/Hooks/Common/ValidateLocked.php @@ -0,0 +1,84 @@ +. + * + * 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."); + } + } +} diff --git a/application/Espo/Modules/Crm/Resources/metadata/app/entityManagerParams.json b/application/Espo/Modules/Crm/Resources/metadata/app/entityManagerParams.json index 337e4fb07c..dd3f40ffa7 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/app/entityManagerParams.json +++ b/application/Espo/Modules/Crm/Resources/metadata/app/entityManagerParams.json @@ -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 + } } } } diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Account.json b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Account.json index 64b32a7197..6e2b75ca9a 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Account.json +++ b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Account.json @@ -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", diff --git a/application/Espo/Modules/Crm/Resources/metadata/scopes/Account.json b/application/Espo/Modules/Crm/Resources/metadata/scopes/Account.json index 447c2f329b..ef9ef976a9 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/scopes/Account.json +++ b/application/Espo/Modules/Crm/Resources/metadata/scopes/Account.json @@ -12,5 +12,6 @@ "notifications": true, "object": true, "hasPersonalData": true, - "duplicateCheckFieldList": ["name", "emailAddress"] + "duplicateCheckFieldList": ["name", "emailAddress"], + "lockable": true } diff --git a/application/Espo/ORM/Defs/Params/FieldParam.php b/application/Espo/ORM/Defs/Params/FieldParam.php index 484360c2af..db675bf79f 100644 --- a/application/Espo/ORM/Defs/Params/FieldParam.php +++ b/application/Espo/ORM/Defs/Params/FieldParam.php @@ -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. */ diff --git a/application/Espo/ORM/Exceptions/ValidationException.php b/application/Espo/ORM/Exceptions/ValidationException.php new file mode 100644 index 0000000000..2ae663b6f8 --- /dev/null +++ b/application/Espo/ORM/Exceptions/ValidationException.php @@ -0,0 +1,40 @@ +. + * + * 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 +{} diff --git a/application/Espo/Resources/i18n/en_US/Admin.json b/application/Espo/Resources/i18n/en_US/Admin.json index 57ea4f7ca8..29d62547fc 100644 --- a/application/Espo/Resources/i18n/en_US/Admin.json +++ b/application/Espo/Resources/i18n/en_US/Admin.json @@ -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" diff --git a/application/Espo/Resources/i18n/en_US/EntityManager.json b/application/Espo/Resources/i18n/en_US/EntityManager.json index 79c3a50177..15f798d036 100644 --- a/application/Espo/Resources/i18n/en_US/EntityManager.json +++ b/application/Espo/Resources/i18n/en_US/EntityManager.json @@ -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.", diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index 7e2798532c..44ff93d0a0 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -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", diff --git a/application/Espo/Resources/i18n/en_US/Role.json b/application/Espo/Resources/i18n/en_US/Role.json index a155acd287..17f254b7dd 100644 --- a/application/Espo/Resources/i18n/en_US/Role.json +++ b/application/Espo/Resources/i18n/en_US/Role.json @@ -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.", diff --git a/application/Espo/Resources/layouts/Role/detail.json b/application/Espo/Resources/layouts/Role/detail.json index 8ef1638ca7..2a716722e0 100644 --- a/application/Espo/Resources/layouts/Role/detail.json +++ b/application/Espo/Resources/layouts/Role/detail.json @@ -29,6 +29,11 @@ {"name": "auditPermission"}, {"name": "userPermission"}, {"name": "dataPrivacyPermission"} + ], + [ + {"name": "lockPermission"}, + false, + false ] ] } diff --git a/application/Espo/Resources/metadata/app/acl.json b/application/Espo/Resources/metadata/app/acl.json index 3b3bef9c67..507030f5e7 100644 --- a/application/Espo/Resources/metadata/app/acl.json +++ b/application/Espo/Resources/metadata/app/acl.json @@ -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" } } diff --git a/application/Espo/Resources/metadata/app/actions.json b/application/Espo/Resources/metadata/app/actions.json index e0cafde239..e64ddb6a36 100644 --- a/application/Espo/Resources/metadata/app/actions.json +++ b/application/Espo/Resources/metadata/app/actions.json @@ -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" } } diff --git a/application/Espo/Resources/metadata/app/entityManager.json b/application/Espo/Resources/metadata/app/entityManager.json index b47316b6b2..184bad5677 100644 --- a/application/Espo/Resources/metadata/app/entityManager.json +++ b/application/Espo/Resources/metadata/app/entityManager.json @@ -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" ] } diff --git a/application/Espo/Resources/metadata/app/entityManagerParams.json b/application/Espo/Resources/metadata/app/entityManagerParams.json index 486f114f56..f22719e246 100644 --- a/application/Espo/Resources/metadata/app/entityManagerParams.json +++ b/application/Espo/Resources/metadata/app/entityManagerParams.json @@ -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 + } } } } diff --git a/application/Espo/Resources/metadata/app/massActions.json b/application/Espo/Resources/metadata/app/massActions.json index 75d1d30c0d..d2a64e660d 100644 --- a/application/Espo/Resources/metadata/app/massActions.json +++ b/application/Espo/Resources/metadata/app/massActions.json @@ -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" } } diff --git a/application/Espo/Resources/metadata/app/record.json b/application/Espo/Resources/metadata/app/record.json index 260f3a1bc4..4515c334df 100644 --- a/application/Espo/Resources/metadata/app/record.json +++ b/application/Espo/Resources/metadata/app/record.json @@ -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" ] } diff --git a/application/Espo/Resources/metadata/clientDefs/Global.json b/application/Espo/Resources/metadata/clientDefs/Global.json index 540f8a9f85..2ad179b07f 100644 --- a/application/Espo/Resources/metadata/clientDefs/Global.json +++ b/application/Espo/Resources/metadata/clientDefs/Global.json @@ -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" + ] + } } diff --git a/application/Espo/Resources/metadata/entityDefs/Role.json b/application/Espo/Resources/metadata/entityDefs/Role.json index 718325e5a5..7bb78a2e87 100644 --- a/application/Espo/Resources/metadata/entityDefs/Role.json +++ b/application/Espo/Resources/metadata/entityDefs/Role.json @@ -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 diff --git a/application/Espo/Tools/EntityManager/Hook/Hooks/LockableUpdateHook.php b/application/Espo/Tools/EntityManager/Hook/Hooks/LockableUpdateHook.php new file mode 100644 index 0000000000..68252f821e --- /dev/null +++ b/application/Espo/Tools/EntityManager/Hook/Hooks/LockableUpdateHook.php @@ -0,0 +1,148 @@ +. + * + * 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); + } +} diff --git a/application/Espo/Tools/EntityManager/NameUtil.php b/application/Espo/Tools/EntityManager/NameUtil.php index 781dfecbdb..f0e900b06b 100644 --- a/application/Espo/Tools/EntityManager/NameUtil.php +++ b/application/Espo/Tools/EntityManager/NameUtil.php @@ -156,6 +156,7 @@ class NameUtil Field::CREATED_AT, Field::MODIFIED_BY, Field::MODIFIED_AT, + Field::IS_LOCKED, 'emailAddressList', 'userEmailAddressList', diff --git a/application/Espo/Tools/FieldManager/FieldManager.php b/application/Espo/Tools/FieldManager/FieldManager.php index 08d13fbf9b..a5d908a874 100644 --- a/application/Espo/Tools/FieldManager/FieldManager.php +++ b/application/Espo/Tools/FieldManager/FieldManager.php @@ -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) { diff --git a/application/Espo/Tools/Lock/LockMetadataProvider.php b/application/Espo/Tools/Lock/LockMetadataProvider.php new file mode 100644 index 0000000000..06db0dda16 --- /dev/null +++ b/application/Espo/Tools/Lock/LockMetadataProvider.php @@ -0,0 +1,52 @@ +. + * + * 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; + } +} diff --git a/application/Espo/Tools/Lock/LockService.php b/application/Espo/Tools/Lock/LockService.php new file mode 100644 index 0000000000..84af62f84c --- /dev/null +++ b/application/Espo/Tools/Lock/LockService.php @@ -0,0 +1,183 @@ +. + * + * 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 $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 $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."); + } + } +} diff --git a/application/Espo/Tools/Lock/LockValidationHelper.php b/application/Espo/Tools/Lock/LockValidationHelper.php new file mode 100644 index 0000000000..f06d34149e --- /dev/null +++ b/application/Espo/Tools/Lock/LockValidationHelper.php @@ -0,0 +1,181 @@ +. + * + * 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; + } +} diff --git a/client/src/acl-manager.js b/client/src/acl-manager.js index 30581e1d00..58b8735896 100644 --- a/client/src/acl-manager.js +++ b/client/src/acl-manager.js @@ -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); } diff --git a/client/src/app.js b/client/src/app.js index 11c965b8ee..fa6c3d3069 100644 --- a/client/src/app.js +++ b/client/src/app.js @@ -1268,6 +1268,11 @@ class App { break; + case 409: + this._processErrorAlert(xhr, 'Conflict'); + + break; + case 404: // noinspection JSUnresolvedReference if (options.main) { diff --git a/client/src/handlers/record/lock-action.js b/client/src/handlers/record/lock-action.js new file mode 100644 index 0000000000..382132a028 --- /dev/null +++ b/client/src/handlers/record/lock-action.js @@ -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 . + * + * 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; + } +} diff --git a/client/src/handlers/record/lock-mass-action.js b/client/src/handlers/record/lock-mass-action.js new file mode 100644 index 0000000000..9855a384f8 --- /dev/null +++ b/client/src/handlers/record/lock-mass-action.js @@ -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 . + * + * 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; + } +} diff --git a/client/src/handlers/record/lock-view-setup.js b/client/src/handlers/record/lock-view-setup.js new file mode 100644 index 0000000000..034ba9c676 --- /dev/null +++ b/client/src/handlers/record/lock-view-setup.js @@ -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 . + * + * 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.} */ + 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; + } +} diff --git a/client/src/helpers/assignment.js b/client/src/helpers/assignment.js new file mode 100644 index 0000000000..4115aa34a7 --- /dev/null +++ b/client/src/helpers/assignment.js @@ -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 . + * + * 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'; + } +} diff --git a/client/src/views/admin/field-manager/edit.js b/client/src/views/admin/field-manager/edit.js index 7c0bb2c536..559ad6e660 100644 --- a/client/src/views/admin/field-manager/edit.js +++ b/client/src/views/admin/field-manager/edit.js @@ -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', diff --git a/client/src/views/global/fields/is-locked.js b/client/src/views/global/fields/is-locked.js new file mode 100644 index 0000000000..a340ae687d --- /dev/null +++ b/client/src/views/global/fields/is-locked.js @@ -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 . + * + * 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~}} + + {{~else~}} + + {{~/if~}} + {{~else~}} + + {{~/if~}} + ` +} diff --git a/client/src/views/record/list.js b/client/src/views/record/list.js index f538030512..32ce93e8b6 100644 --- a/client/src/views/record/list.js +++ b/client/src/views/record/list.js @@ -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; } diff --git a/client/src/views/record/panels/default-side.js b/client/src/views/record/panels/default-side.js index 561000bd04..47a2e02b31 100644 --- a/client/src/views/record/panels/default-side.js +++ b/client/src/views/record/panels/default-side.js @@ -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; diff --git a/schema/metadata/app/record.json b/schema/metadata/app/record.json index 3647d9904e..b3a93c8252 100644 --- a/schema/metadata/app/record.json +++ b/schema/metadata/app/record.json @@ -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" + } } } } diff --git a/schema/metadata/entityDefs.json b/schema/metadata/entityDefs.json index bef33f87d6..67820d888d 100644 --- a/schema/metadata/entityDefs.json +++ b/schema/metadata/entityDefs.json @@ -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." } } } diff --git a/schema/metadata/scopes.json b/schema/metadata/scopes.json index d6652bab78..2f82a599dc 100644 --- a/schema/metadata/scopes.json +++ b/schema/metadata/scopes.json @@ -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" diff --git a/tests/integration/Espo/Tools/Lock/LockTest.php b/tests/integration/Espo/Tools/Lock/LockTest.php new file mode 100644 index 0000000000..384c0d977b --- /dev/null +++ b/tests/integration/Espo/Tools/Lock/LockTest.php @@ -0,0 +1,91 @@ +. + * + * 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)); + } +} diff --git a/tests/unit/Espo/Core/Record/HookManagerTest.php b/tests/unit/Espo/Core/Record/HookManagerTest.php index 02ffbba826..3d081dcc32 100644 --- a/tests/unit/Espo/Core/Record/HookManagerTest.php +++ b/tests/unit/Espo/Core/Record/HookManagerTest.php @@ -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