This commit is contained in:
Yurii
2026-02-26 13:59:14 +02:00
parent feac638722
commit dcd3ce66ce
65 changed files with 2298 additions and 52 deletions
@@ -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;
}
+1
View File
@@ -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';
}
+2
View File
@@ -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);
}
}
}
+2
View File
@@ -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);
}
}
+1
View File
@@ -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", []),
];
}
}
+1 -1
View File
@@ -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;
}
}
+183
View File
@@ -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;
}
}