X-Git-Url: https://vcs.fsf.org/?a=blobdiff_plain;f=CRM%2FCore%2FEntityTokens.php;h=8f7866971b5a5230c43acc062d5b08bea1896aa5;hb=96d9c94a6d9c49a84cd38ab3b3d68167b3274d47;hp=2901360468785909ad2f4485d1bf82a1ef502f8d;hpb=89d280e88d30f9715c839dfa58c0d22a6af7e7f6;p=civicrm-core.git diff --git a/CRM/Core/EntityTokens.php b/CRM/Core/EntityTokens.php index 2901360468..8f7866971b 100644 --- a/CRM/Core/EntityTokens.php +++ b/CRM/Core/EntityTokens.php @@ -11,10 +11,12 @@ */ use Civi\Token\AbstractTokenSubscriber; +use Civi\Token\Event\TokenRegisterEvent; use Civi\Token\Event\TokenValueEvent; use Civi\Token\TokenRow; use Civi\ActionSchedule\Event\MailingQueryEvent; use Civi\Token\TokenProcessor; +use Brick\Money\Money; /** * Class CRM_Core_EntityTokens @@ -28,11 +30,65 @@ use Civi\Token\TokenProcessor; */ class CRM_Core_EntityTokens extends AbstractTokenSubscriber { + /** + * Metadata about all tokens. + * + * @var array + */ + protected $tokensMetadata = []; /** * @var array */ protected $prefetch = []; + /** + * Should permissions be checked when loading tokens. + * + * @var bool + */ + protected $checkPermissions = FALSE; + + /** + * Register the declared tokens. + * + * @param \Civi\Token\Event\TokenRegisterEvent $e + * The registration event. Add new tokens using register(). + */ + public function registerTokens(TokenRegisterEvent $e) { + if (!$this->checkActive($e->getTokenProcessor())) { + return; + } + foreach ($this->getTokenMetadata() as $tokenName => $field) { + if ($field['audience'] === 'user') { + $e->register([ + 'entity' => $this->entity, + 'field' => $tokenName, + 'label' => $field['title'], + ]); + } + } + } + + /** + * Get the metadata about the available tokens + * + * @return array + */ + protected function getTokenMetadata(): array { + $cacheKey = $this->getCacheKey(); + if (!Civi::cache('metadata')->has($cacheKey)) { + $tokensMetadata = $this->getBespokeTokens(); + foreach ($this->getFieldMetadata() as $field) { + $this->addFieldToTokenMetadata($tokensMetadata, $field, $this->getExposedFields()); + } + foreach ($this->getHiddenTokens() as $name) { + $tokensMetadata[$name]['audience'] = 'hidden'; + } + Civi::cache('metadata')->set($cacheKey, $tokensMetadata); + } + return Civi::cache('metadata')->get($cacheKey); + } + /** * @inheritDoc * @throws \CRM_Core_Exception @@ -56,16 +112,30 @@ class CRM_Core_EntityTokens extends AbstractTokenSubscriber { return $row->tokens($entity, $field, $this->getPseudoValue($split[0], $split[1], $this->getFieldValue($row, $split[0]))); } if ($this->isCustomField($field)) { + $prefetchedValue = $this->getCustomFieldValue($this->getFieldValue($row, 'id'), $field); + if ($prefetchedValue) { + return $row->format('text/html')->tokens($entity, $field, $prefetchedValue); + } return $row->customToken($entity, \CRM_Core_BAO_CustomField::getKeyID($field), $this->getFieldValue($row, 'id')); } if ($this->isMoneyField($field)) { + $currency = $this->getCurrency($row); + if (empty($fieldValue) && !is_numeric($fieldValue)) { + $fieldValue = 0; + } + if (!$currency) { + // too hard basket for now - just do what we always did. + return $row->format('text/plain')->tokens($entity, $field, + \CRM_Utils_Money::format($fieldValue, $currency)); + } return $row->format('text/plain')->tokens($entity, $field, - \CRM_Utils_Money::format($fieldValue, $this->getCurrency($row))); + Money::of($fieldValue, $currency)); + } if ($this->isDateField($field)) { try { return $row->format('text/plain') - ->tokens($entity, $field, new DateTime($fieldValue)); + ->tokens($entity, $field, ($fieldValue ? new DateTime($fieldValue) : $fieldValue)); } catch (Exception $e) { Civi::log()->info('invalid date token'); @@ -113,44 +183,18 @@ class CRM_Core_EntityTokens extends AbstractTokenSubscriber { return CRM_Core_DAO_AllCoreTables::getTableForEntityName($this->getApiEntityName()); } - /** - * Get the relevant bao name. - */ - public function getBAOName(): string { - return CRM_Core_DAO_AllCoreTables::getFullName($this->getApiEntityName()); - } - /** * Get an array of fields to be requested. * + * @todo this function should look up tokenMetadata that + * is already loaded. + * * @return string[] */ - public function getReturnFields(): array { + protected function getReturnFields(): array { return array_keys($this->getBasicTokens()); } - /** - * Get all the tokens supported by this processor. - * - * @return array|string[] - * @throws \API_Exception - */ - protected function getAllTokens(): array { - $basicTokens = $this->getBasicTokens(); - foreach (array_keys($basicTokens) as $fieldName) { - // The goal is to be able to render more complete tokens - // (eg. actual booleans, field names, raw ids) for a more - // advanced audiences - ie those using conditionals - // and to specify that audience in the api that retrieves. - // But, for now, let's not advertise, given that most of these fields - // aren't really needed even once... - if ($this->isBooleanField($fieldName)) { - unset($basicTokens[$fieldName]); - } - } - return array_merge($basicTokens, $this->getPseudoTokens(), CRM_Utils_Token::getCustomFieldTokens($this->getApiEntityName())); - } - /** * Is the given field a boolean field. * @@ -158,8 +202,8 @@ class CRM_Core_EntityTokens extends AbstractTokenSubscriber { * * @return bool */ - public function isBooleanField(string $fieldName): bool { - return $this->getFieldMetadata()[$fieldName]['data_type'] === 'Boolean'; + protected function isBooleanField(string $fieldName): bool { + return $this->getMetadataForField($fieldName)['data_type'] === 'Boolean'; } /** @@ -169,8 +213,8 @@ class CRM_Core_EntityTokens extends AbstractTokenSubscriber { * * @return bool */ - public function isDateField(string $fieldName): bool { - return in_array($this->getFieldMetadata()[$fieldName]['data_type'], ['Timestamp', 'Date'], TRUE); + protected function isDateField(string $fieldName): bool { + return in_array($this->getMetadataForField($fieldName)['data_type'], ['Timestamp', 'Date'], TRUE); } /** @@ -180,7 +224,7 @@ class CRM_Core_EntityTokens extends AbstractTokenSubscriber { * * @return bool */ - public function isPseudoField(string $fieldName): bool { + protected function isPseudoField(string $fieldName): bool { return strpos($fieldName, ':') !== FALSE; } @@ -191,7 +235,7 @@ class CRM_Core_EntityTokens extends AbstractTokenSubscriber { * * @return bool */ - public function isCustomField(string $fieldName) : bool { + protected function isCustomField(string $fieldName) : bool { return (bool) \CRM_Core_BAO_CustomField::getKeyID($fieldName); } @@ -202,8 +246,8 @@ class CRM_Core_EntityTokens extends AbstractTokenSubscriber { * * @return bool */ - public function isMoneyField(string $fieldName): bool { - return $this->getFieldMetadata()[$fieldName]['data_type'] === 'Money'; + protected function isMoneyField(string $fieldName): bool { + return $this->getMetadataForField($fieldName)['data_type'] === 'Money'; } /** @@ -225,54 +269,10 @@ class CRM_Core_EntityTokens extends AbstractTokenSubscriber { } /** - * Get pseudoTokens - it tokens that reflect the name or label of a pseudoconstant. - * - * @internal - this function will likely be made protected soon. - * - * @return array - */ - public function getPseudoTokens(): array { - $return = []; - foreach (array_keys($this->getBasicTokens()) as $fieldName) { - if ($this->isAddPseudoTokens($fieldName)) { - $fieldLabel = $this->fieldMetadata[$fieldName]['input_attrs']['label'] ?? $this->fieldMetadata[$fieldName]['label']; - $return[$fieldName . ':label'] = $fieldLabel; - $return[$fieldName . ':name'] = ts('Machine name') . ': ' . $fieldLabel; - } - if ($this->isBooleanField($fieldName)) { - $return[$fieldName . ':label'] = $this->getFieldMetadata()[$fieldName]['title']; - } - } - return $return; - } - - /** - * Is this a field we should add pseudo-tokens to? - * - * Pseudo-tokens allow access to name and label fields - e.g - * - * {contribution.contribution_status_id:name} might resolve to 'Completed' - * - * @param string $fieldName + * Get any tokens with custom calculation. */ - public function isAddPseudoTokens($fieldName): bool { - if ($fieldName === 'currency') { - // 'currency' is manually added to the skip list as an anomaly. - // name & label aren't that suitable for 'currency' (symbol, which - // possibly maps to 'abbr' would be) and we can't gather that - // from the metadata as yet. - return FALSE; - } - if ($this->getFieldMetadata()[$fieldName]['type'] === 'Custom') { - // If we remove this early return then we get that extra nuanced goodness - // and support for the more portable v4 style field names - // on custom fields - where labels or names can be returned. - // At present the gap is that the metadata for the label is not accessed - // and tests failed on the enotice and we don't have a clear plan about - // v4 style custom tokens - but medium term this IF will probably go. - return FALSE; - } - return (bool) ($this->getFieldMetadata()[$fieldName]['options'] || !empty($this->getFieldMetadata()[$fieldName]['suffixes'])); + protected function getBespokeTokens(): array { + return []; } /** @@ -287,12 +287,17 @@ class CRM_Core_EntityTokens extends AbstractTokenSubscriber { * * @internal function will likely be protected soon. */ - public function getPseudoValue(string $realField, string $pseudoKey, $fieldValue): string { + protected function getPseudoValue(string $realField, string $pseudoKey, $fieldValue): string { + $bao = CRM_Core_DAO_AllCoreTables::getFullName($this->getMetadataForField($realField)['entity']); if ($pseudoKey === 'name') { - $fieldValue = (string) CRM_Core_PseudoConstant::getName($this->getBAOName(), $realField, $fieldValue); + $fieldValue = (string) CRM_Core_PseudoConstant::getName($bao, $realField, $fieldValue); } if ($pseudoKey === 'label') { - $fieldValue = (string) CRM_Core_PseudoConstant::getLabel($this->getBAOName(), $realField, $fieldValue); + $fieldValue = (string) CRM_Core_PseudoConstant::getLabel($bao, $realField, $fieldValue); + } + if ($pseudoKey === 'abbr' && $realField === 'state_province_id') { + // hack alert - currently only supported for state. + $fieldValue = (string) CRM_Core_PseudoConstant::stateProvinceAbbreviation($fieldValue); } return (string) $fieldValue; } @@ -308,12 +313,10 @@ class CRM_Core_EntityTokens extends AbstractTokenSubscriber { return $row->context[$entityName][$field]; } - $actionSearchResult = $row->context['actionSearchResult']; - $aliasedField = $this->getEntityAlias() . $field; - if (isset($actionSearchResult->{$aliasedField})) { - return $actionSearchResult->{$aliasedField}; - } $entityID = $row->context[$this->getEntityIDField()]; + if ($field === 'id') { + return $entityID; + } return $this->prefetch[$entityID][$field] ?? ''; } @@ -321,8 +324,7 @@ class CRM_Core_EntityTokens extends AbstractTokenSubscriber { * Class constructor. */ public function __construct() { - $tokens = $this->getAllTokens(); - parent::__construct($this->getEntityName(), $tokens); + parent::__construct($this->getEntityName(), []); } /** @@ -348,24 +350,27 @@ class CRM_Core_EntityTokens extends AbstractTokenSubscriber { if ($e->mapping->getEntity() !== $this->getExtendableTableName()) { return; } - foreach ($this->getReturnFields() as $token) { - $e->query->select('e.' . $token . ' AS ' . $this->getEntityAlias() . $token); - } + $e->query->select('e.id AS tokenContext_' . $this->getEntityIDField()); } /** - * Get tokens supporting the syntax we are migrating to. + * Get tokens to be suppressed from the widget. * - * In general these are tokens that were not previously supported - * so we can add them in the preferred way or that we have - * undertaken some, as yet to be written, db update. - * - * See https://lab.civicrm.org/dev/core/-/issues/2650 + * Note this is expected to be an interim function. Now we are no + * longer working around the parent function we can just define them once... + * with metadata, in a future refactor. + */ + protected function getHiddenTokens(): array { + return []; + } + + /** + * @todo remove this function & use the metadata that is loaded. * * @return string[] * @throws \API_Exception */ - public function getBasicTokens(): array { + protected function getBasicTokens(): array { $return = []; foreach ($this->getExposedFields() as $fieldName) { // Custom fields are still added v3 style - we want to keep v4 naming 'unpoluted' @@ -384,7 +389,7 @@ class CRM_Core_EntityTokens extends AbstractTokenSubscriber { * @return string[] * */ - public function getExposedFields(): array { + protected function getExposedFields(): array { $return = []; foreach ($this->getFieldMetadata() as $field) { if (!in_array($field['name'], $this->getSkippedFields(), TRUE)) { @@ -399,11 +404,11 @@ class CRM_Core_EntityTokens extends AbstractTokenSubscriber { * * @return string[] */ - public function getSkippedFields(): array { + protected function getSkippedFields(): array { // tags is offered in 'case' & is one of the only fields that is // 'not a real field' offered up by case - seems like an oddity // we should skip at the top level for now. - $fields = ['contact_id', 'tags']; + $fields = ['tags']; if (!CRM_Campaign_BAO_Campaign::isCampaignEnable()) { $fields[] = 'campaign_id'; } @@ -417,7 +422,7 @@ class CRM_Core_EntityTokens extends AbstractTokenSubscriber { return CRM_Core_DAO_AllCoreTables::convertEntityNameToLower($this->getApiEntityName()); } - public function getEntityIDField(): string { + protected function getEntityIDField(): string { return $this->getEntityName() . 'Id'; } @@ -437,7 +442,7 @@ class CRM_Core_EntityTokens extends AbstractTokenSubscriber { return $result; } - public function getCurrencyFieldName() { + protected function getCurrencyFieldName() { return []; } @@ -447,7 +452,7 @@ class CRM_Core_EntityTokens extends AbstractTokenSubscriber { * * @return string */ - public function getCurrency($row): string { + protected function getCurrency($row): string { if (!empty($this->getCurrencyFieldName())) { return $this->getFieldValue($row, $this->getCurrencyFieldName()[0]); } @@ -463,7 +468,7 @@ class CRM_Core_EntityTokens extends AbstractTokenSubscriber { * @throws \API_Exception */ public function getPrefetchFields(TokenValueEvent $e): array { - $allTokens = array_keys($this->getAllTokens()); + $allTokens = array_keys($this->getTokenMetadata()); $requiredFields = array_intersect($this->getActiveTokens($e), $allTokens); if (empty($requiredFields)) { return []; @@ -484,8 +489,188 @@ class CRM_Core_EntityTokens extends AbstractTokenSubscriber { * * @return array */ - public function getDependencies(): array { + protected function getDependencies(): array { + return []; + } + + /** + * Get the apiv4 style custom field name. + * + * @param int $id + * + * @return string + * + * @throws \CRM_Core_Exception + */ + protected function getCustomFieldName(int $id): string { + foreach ($this->getTokenMetadata() as $key => $field) { + if (($field['custom_field_id'] ?? NULL) === $id) { + return $key; + } + } + throw new CRM_Core_Exception( + "A custom field with the ID {$id} does not exist" + ); + } + + /** + * @param $entityID + * @param string $field eg. 'custom_1' + * + * @return array|string|void|null $mixed + * + * @throws \CRM_Core_Exception + */ + protected function getCustomFieldValue($entityID, string $field) { + $id = str_replace('custom_', '', $field); + try { + $value = $this->prefetch[$entityID][$this->getCustomFieldName($id)] ?? ''; + if ($value !== NULL) { + return CRM_Core_BAO_CustomField::displayValue($value, $id); + } + } + catch (CRM_Core_Exception $exception) { + return NULL; + } + } + + /** + * Get the metadata for the field. + * + * @param string $fieldName + * + * @return array + */ + protected function getMetadataForField($fieldName): array { + if (isset($this->getTokenMetadata()[$fieldName])) { + return $this->getTokenMetadata()[$fieldName]; + } + if (isset($this->getTokenMappingsForRelatedEntities()[$fieldName])) { + return $this->getTokenMetadata()[$this->getTokenMappingsForRelatedEntities()[$fieldName]]; + } + return $this->getTokenMetadata()[$this->getDeprecatedTokens()[$fieldName]] ?? []; + } + + /** + * Get token mappings for related entities - specifically the contact entity. + * + * This function exists to help manage the way contact tokens is structured + * of an query-object style result set that needs to be mapped to apiv4. + * + * The end goal is likely to be to advertised tokens that better map to api + * v4 and deprecate the existing ones but that is a long-term migration. + * + * @return array + */ + protected function getTokenMappingsForRelatedEntities(): array { return []; } + /** + * Get array of deprecated tokens and the new token they map to. + * + * @return array + */ + protected function getDeprecatedTokens(): array { + return []; + } + + /** + * Get any overrides for token metadata. + * + * This is most obviously used for setting the audience, which + * will affect widget-presence. + * + * @return \string[][] + */ + protected function getTokenMetadataOverrides(): array { + return []; + } + + /** + * To handle variable tokens, override this function and return the active tokens. + * + * @param \Civi\Token\Event\TokenValueEvent $e + * + * @return mixed + */ + public function getActiveTokens(TokenValueEvent $e) { + $messageTokens = $e->getTokenProcessor()->getMessageTokens(); + if (!isset($messageTokens[$this->entity])) { + return FALSE; + } + return array_intersect($messageTokens[$this->entity], array_keys($this->getTokenMetadata())); + } + + /** + * Add the token to the metadata based on the field spec. + * + * @param array $tokensMetadata + * @param array $field + * @param array $exposedFields + * @param string $prefix + */ + protected function addFieldToTokenMetadata(array &$tokensMetadata, array $field, array $exposedFields, string $prefix = ''): void { + if ($field['type'] !== 'Custom' && !in_array($field['name'], $exposedFields, TRUE)) { + return; + } + $field['audience'] = 'user'; + if ($field['name'] === 'contact_id') { + // Since {contact.id} is almost always present don't confuse users + // by also adding (e.g {participant.contact_id) + $field['audience'] = 'sysadmin'; + } + if (!empty($this->getTokenMetadataOverrides()[$field['name']])) { + $field = array_merge($field, $this->getTokenMetadataOverrides()[$field['name']]); + } + if ($field['type'] === 'Custom') { + // Convert to apiv3 style for now. Later we can add v4 with + // portable naming & support for labels/ dates etc so let's leave + // the space open for that. + // Not the existing quickform widget has handling for the custom field + // format based on the title using this syntax. + $parts = explode(': ', $field['label']); + $field['title'] = "{$parts[1]} :: {$parts[0]}"; + $tokenName = 'custom_' . $field['custom_field_id']; + $tokensMetadata[$tokenName] = $field; + return; + } + $tokenName = $prefix ? ($prefix . '.' . $field['name']) : $field['name']; + if (in_array($field['name'], $exposedFields, TRUE)) { + if ( + ($field['options'] || !empty($field['suffixes'])) + // At the time of writing currency didn't have a label option - this may have changed. + && !in_array($field['name'], $this->getCurrencyFieldName(), TRUE) + ) { + $tokensMetadata[$tokenName . ':label'] = $tokensMetadata[$tokenName . ':name'] = $field; + $fieldLabel = $field['input_attrs']['label'] ?? $field['label']; + $tokensMetadata[$tokenName . ':label']['name'] = $field['name'] . ':label'; + $tokensMetadata[$tokenName . ':name']['name'] = $field['name'] . ':name'; + $tokensMetadata[$tokenName . ':name']['audience'] = 'sysadmin'; + $tokensMetadata[$tokenName . ':label']['title'] = $fieldLabel; + $tokensMetadata[$tokenName . ':name']['title'] = ts('Machine name') . ': ' . $fieldLabel; + $field['audience'] = 'sysadmin'; + } + if ($field['data_type'] === 'Boolean') { + $tokensMetadata[$tokenName . ':label'] = $field; + $tokensMetadata[$tokenName . ':label']['name'] = $field['name'] . ':label'; + $field['audience'] = 'sysadmin'; + } + $tokensMetadata[$tokenName] = $field; + } + } + + /** + * Get a cache key appropriate to the current usage. + * + * @return string + */ + protected function getCacheKey(): string { + $cacheKey = __CLASS__ . 'token_metadata' . $this->getApiEntityName() . CRM_Core_Config::domainID() . '_' . CRM_Core_I18n::getLocale(); + if ($this->checkPermissions) { + $cacheKey .= '__' . CRM_Core_Session::getLoggedInContactID(); + } + return $cacheKey; + } + }