From 9626d0a168135472bf8db02cd29048a61a0e6aae Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Mon, 1 Nov 2021 09:43:56 -0400 Subject: [PATCH] APIv4 - Provide pseudo-fields and Revert action for managed entities This new "ManagedEntity" trait provides 2 extra fields and a Revert action to facilitate UIs which show the managed state and a revert button, similar to the AfformAdmin UI. --- CRM/Core/ManagedEntities.php | 34 ++++- Civi/Api4/Entity.php | 1 + Civi/Api4/Generic/Traits/ManagedEntity.php | 39 ++++++ Civi/Api4/SavedSearch.php | 7 +- .../Provider/ManagedEntitySpecProvider.php | 94 ++++++++++++++ ext/search_kit/Civi/Api4/SearchDisplay.php | 2 + .../api/v4/Entity/ManagedEntityTest.php | 122 ++++++++++++++++++ 7 files changed, 295 insertions(+), 4 deletions(-) create mode 100644 Civi/Api4/Generic/Traits/ManagedEntity.php create mode 100644 Civi/Api4/Service/Spec/Provider/ManagedEntitySpecProvider.php create mode 100644 tests/phpunit/api/v4/Entity/ManagedEntityTest.php diff --git a/CRM/Core/ManagedEntities.php b/CRM/Core/ManagedEntities.php index c99f450b79..a4d2096ae7 100644 --- a/CRM/Core/ManagedEntities.php +++ b/CRM/Core/ManagedEntities.php @@ -134,6 +134,27 @@ class CRM_Core_ManagedEntities { $this->reconcileUnknownModules(); } + /** + * Force-revert a record back to its original state. + * @param array $params + * Key->value properties of CRM_Core_DAO_Managed used to match an existing record + */ + public function revert(array $params) { + $mgd = new \CRM_Core_DAO_Managed(); + $mgd->copyValues($params); + $mgd->find(TRUE); + $declarations = CRM_Utils_Array::findAll($this->declarations, [ + 'module' => $mgd->module, + 'name' => $mgd->name, + 'entity' => $mgd->entity_type, + ]); + if ($mgd->id && isset($declarations[0])) { + $this->updateExistingEntity($mgd, ['update' => 'always'] + $declarations[0]); + return TRUE; + } + return FALSE; + } + /** * For all enabled modules, add new entities, update * existing entities, and remove orphaned (stale) entities. @@ -195,7 +216,7 @@ class CRM_Core_ManagedEntities { } /** - * Get the managed entities to be created. + * Get the managed entities to be updated. * * @param array $filters * @@ -287,6 +308,10 @@ class CRM_Core_ManagedEntities { * Entity specification (per hook_civicrm_managedEntities). */ protected function insertNewEntity($todo) { + if ($todo['params']['version'] == 4) { + $todo['params']['checkPermissions'] = FALSE; + } + $result = civicrm_api($todo['entity_type'], 'create', $todo['params']); if (!empty($result['is_error'])) { $this->onApiError($todo['entity_type'], 'create', $todo['params'], $result); @@ -314,7 +339,7 @@ class CRM_Core_ManagedEntities { $policy = CRM_Utils_Array::value('update', $todo, 'always'); $doUpdate = ($policy === 'always'); - if ($doUpdate) { + if ($doUpdate && $todo['params']['version'] == 3) { $defaults = ['id' => $dao->entity_id]; if ($this->isActivationSupported($dao->entity_type)) { $defaults['is_active'] = 1; @@ -343,6 +368,11 @@ class CRM_Core_ManagedEntities { $this->onApiError($dao->entity_type, 'create', $params, $result); } } + elseif ($doUpdate && $todo['params']['version'] == 4) { + $params = ['checkPermissions' => FALSE] + $todo['params']; + $params['values']['id'] = $dao->entity_id; + civicrm_api4($dao->entity_type, 'update', $params); + } if (isset($todo['cleanup'])) { $dao->cleanup = $todo['cleanup']; diff --git a/Civi/Api4/Entity.php b/Civi/Api4/Entity.php index c28f832d21..d6b0dfcc3c 100644 --- a/Civi/Api4/Entity.php +++ b/Civi/Api4/Entity.php @@ -58,6 +58,7 @@ class Entity extends Generic\AbstractEntity { 'DAOEntity' => 'DAOEntity', 'CustomValue' => 'CustomValue', 'BasicEntity' => 'BasicEntity', + 'ManagedEntity' => 'ManagedEntity', 'EntityBridge' => 'EntityBridge', 'OptionList' => 'OptionList', ], diff --git a/Civi/Api4/Generic/Traits/ManagedEntity.php b/Civi/Api4/Generic/Traits/ManagedEntity.php new file mode 100644 index 0000000000..da2e25899c --- /dev/null +++ b/Civi/Api4/Generic/Traits/ManagedEntity.php @@ -0,0 +1,39 @@ + $action->getEntityName(), 'entity_id' => $item['id']]; + if (\CRM_Core_ManagedEntities::singleton()->revert($params)) { + return $item; + } + else { + throw new \API_Exception('Cannot revert ' . $action->getEntityName() . ' with id ' . $item['id']); + } + }))->setCheckPermissions($checkPermissions); + } + +} diff --git a/Civi/Api4/SavedSearch.php b/Civi/Api4/SavedSearch.php index 74dd21cef1..d3954d5a46 100644 --- a/Civi/Api4/SavedSearch.php +++ b/Civi/Api4/SavedSearch.php @@ -11,10 +11,11 @@ namespace Civi\Api4; /** - * SavedSearch aka Smart Groups. + * SavedSearch entity. * - * Stores search parameters for populating smart groups with live results. + * Stores search criteria for smart groups and SearchKit displays. * + * @see https://docs.civicrm.org/user/en/latest/the-user-interface/search-kit/ * @see https://docs.civicrm.org/user/en/latest/organising-your-data/smart-groups/ * @searchable secondary * @since 5.24 @@ -22,6 +23,8 @@ namespace Civi\Api4; */ class SavedSearch extends Generic\DAOEntity { + use Generic\Traits\ManagedEntity; + public static function permissions() { $permissions = parent::permissions(); $permissions['get'] = ['access CiviCRM']; diff --git a/Civi/Api4/Service/Spec/Provider/ManagedEntitySpecProvider.php b/Civi/Api4/Service/Spec/Provider/ManagedEntitySpecProvider.php new file mode 100644 index 0000000000..c7ae06b979 --- /dev/null +++ b/Civi/Api4/Service/Spec/Provider/ManagedEntitySpecProvider.php @@ -0,0 +1,94 @@ +getEntity(), 'Boolean')) + ->setLabel(ts('Is Packaged')) + ->setTitle(ts('Is Packaged')) + ->setColumnName('id') + ->setDescription(ts('Is provided by an extension')) + ->setType('Extra') + ->setReadonly(TRUE) + ->setSqlRenderer([__CLASS__, 'renderHasBase']); + $spec->addFieldSpec($field); + + $field = (new FieldSpec('base_module', $spec->getEntity(), 'String')) + ->setLabel(ts('Packaged Extension')) + ->setTitle(ts('Packaged Extension')) + ->setColumnName('id') + ->setDescription(ts('Name of extension which provides this package')) + ->setType('Extra') + ->setReadonly(TRUE) + ->setOptionsCallback(['CRM_Core_PseudoConstant', 'getExtensions']) + ->setSqlRenderer([__CLASS__, 'renderBaseModule']); + $spec->addFieldSpec($field); + } + + /** + * @param string $entity + * @param string $action + * + * @return bool + */ + public function applies($entity, $action) { + if ($action !== 'get') { + return FALSE; + } + $className = CoreUtil::getApiClass($entity); + return in_array('Civi\Api4\Generic\Traits\ManagedEntity', ReflectionUtils::getTraits($className), TRUE); + } + + /** + * Get sql snippet for has_base + * @param array $field + * return string + */ + public static function renderHasBase(array $field): string { + $id = $field['sql_name']; + $entity = $field['entity']; + return "IF($id IN (SELECT `entity_id` FROM `civicrm_managed` WHERE `entity_type` = '$entity'), '1', '0')"; + } + + /** + * Get sql snippet for base_module + * @param array $field + * return string + */ + public static function renderBaseModule(array $field): string { + $id = $field['sql_name']; + $entity = $field['entity']; + return "(SELECT `civicrm_managed`.`module` FROM `civicrm_managed` WHERE `civicrm_managed`.`entity_id` = $id AND `civicrm_managed`.`entity_type` = '$entity' LIMIT 1)"; + } + +} diff --git a/ext/search_kit/Civi/Api4/SearchDisplay.php b/ext/search_kit/Civi/Api4/SearchDisplay.php index 26ccfecd68..252786c3f9 100644 --- a/ext/search_kit/Civi/Api4/SearchDisplay.php +++ b/ext/search_kit/Civi/Api4/SearchDisplay.php @@ -11,6 +11,8 @@ namespace Civi\Api4; */ class SearchDisplay extends Generic\DAOEntity { + use \Civi\Api4\Generic\Traits\ManagedEntity; + /** * @param bool $checkPermissions * @return Action\SearchDisplay\Run diff --git a/tests/phpunit/api/v4/Entity/ManagedEntityTest.php b/tests/phpunit/api/v4/Entity/ManagedEntityTest.php new file mode 100644 index 0000000000..2ce5341520 --- /dev/null +++ b/tests/phpunit/api/v4/Entity/ManagedEntityTest.php @@ -0,0 +1,122 @@ + 'civicrm', + 'name' => 'testSavedSearch', + 'entity' => 'SavedSearch', + 'cleanup' => 'never', + 'update' => 'never', + 'params' => [ + 'version' => 4, + 'values' => [ + 'name' => 'TestManagedSavedSearch', + 'label' => 'Test Saved Search', + 'description' => 'Original state', + 'api_entity' => 'Contact', + 'api_params' => [ + 'version' => 4, + 'select' => ['id'], + 'orderBy' => ['id', 'ASC'], + ], + ], + ], + ]; + } + + public function testGetFields() { + $fields = SavedSearch::getFields(FALSE) + ->addWhere('type', '=', 'Extra') + ->setLoadOptions(TRUE) + ->execute()->indexBy('name'); + + $this->assertEquals('Boolean', $fields['has_base']['data_type']); + // If this core extension ever goes away or gets renamed, just pick a different one here + $this->assertArrayHasKey('org.civicrm.flexmailer', $fields['base_module']['options']); + } + + public function testRevertSavedSearch() { + \CRM_Core_ManagedEntities::singleton(TRUE)->reconcile(); + + $search = SavedSearch::get(FALSE) + ->addWhere('name', '=', 'TestManagedSavedSearch') + ->execute()->single(); + $this->assertEquals('Original state', $search['description']); + + SavedSearch::update(FALSE) + ->addValue('id', $search['id']) + ->addValue('description', 'Altered state') + ->execute(); + + $search = SavedSearch::get(FALSE) + ->addWhere('name', '=', 'TestManagedSavedSearch') + ->addSelect('description', 'has_base', 'base_module') + ->execute()->single(); + $this->assertEquals('Altered state', $search['description']); + // Check calculated fields + $this->assertTrue($search['has_base']); + $this->assertEquals('civicrm', $search['base_module']); + + SavedSearch::revert(FALSE) + ->addWhere('name', '=', 'TestManagedSavedSearch') + ->execute(); + + // Entity should be revered to original state + $result = SavedSearch::get(FALSE) + ->addWhere('name', '=', 'TestManagedSavedSearch') + ->addSelect('description', 'has_base', 'base_module') + ->setDebug(TRUE) + ->execute(); + $search = $result->single(); + $this->assertEquals('Original state', $search['description']); + // Check calculated fields + $this->assertTrue($search['has_base']); + $this->assertEquals('civicrm', $search['base_module']); + + // Check calculated fields for a non-managed entity - they should be empty + $newName = uniqid(__FUNCTION__); + SavedSearch::create(FALSE) + ->addValue('name', $newName) + ->addValue('label', 'Whatever') + ->execute(); + $search = SavedSearch::get(FALSE) + ->addWhere('name', '=', $newName) + ->addSelect('label', 'has_base', 'base_module') + ->execute()->single(); + $this->assertEquals('Whatever', $search['label']); + // Check calculated fields + $this->assertEquals(NULL, $search['base_module']); + $this->assertFalse($search['has_base']); + } + +} -- 2.25.1