Merge pull request #23742 from eileenmcnaughton/import_remove
[civicrm-core.git] / CRM / Core / EntityTokens.php
index cc9350bb1374a9f9a1021c230b1c2e80d080ac68..8f7866971b5a5230c43acc062d5b08bea1896aa5 100644 (file)
  */
 
 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,14 +112,34 @@ 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)) {
-      return $row->format('text/plain')->tokens($entity, $field, \CRM_Utils_Date::customFormat($fieldValue));
+      try {
+        return $row->format('text/plain')
+          ->tokens($entity, $field, ($fieldValue ? new DateTime($fieldValue) : $fieldValue));
+      }
+      catch (Exception $e) {
+        Civi::log()->info('invalid date token');
+      }
     }
     $row->format('text/plain')->tokens($entity, $field, (string) $fieldValue);
   }
@@ -107,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.
    *
@@ -152,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';
   }
 
   /**
@@ -163,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);
   }
 
   /**
@@ -174,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;
   }
 
@@ -185,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);
   }
 
@@ -196,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';
   }
 
   /**
@@ -219,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 [];
   }
 
   /**
@@ -281,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;
   }
@@ -302,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] ?? '';
   }
 
@@ -315,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(), []);
   }
 
   /**
@@ -342,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'
@@ -378,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)) {
@@ -393,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';
     }
@@ -411,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';
   }
 
@@ -431,7 +442,7 @@ class CRM_Core_EntityTokens extends AbstractTokenSubscriber {
     return $result;
   }
 
-  public function getCurrencyFieldName() {
+  protected function getCurrencyFieldName() {
     return [];
   }
 
@@ -441,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]);
     }
@@ -457,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 [];
@@ -478,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;
+  }
+
 }