Merge pull request #23548 from colemanw/post2
[civicrm-core.git] / CRM / Core / EntityTokens.php
1 <?php
2
3 /*
4 +--------------------------------------------------------------------+
5 | Copyright CiviCRM LLC. All rights reserved. |
6 | |
7 | This work is published under the GNU AGPLv3 license with some |
8 | permitted exceptions and without any warranty. For full license |
9 | and copyright information, see https://civicrm.org/licensing |
10 +--------------------------------------------------------------------+
11 */
12
13 use Civi\Token\AbstractTokenSubscriber;
14 use Civi\Token\Event\TokenRegisterEvent;
15 use Civi\Token\Event\TokenValueEvent;
16 use Civi\Token\TokenRow;
17 use Civi\ActionSchedule\Event\MailingQueryEvent;
18 use Civi\Token\TokenProcessor;
19 use Brick\Money\Money;
20
21 /**
22 * Class CRM_Core_EntityTokens
23 *
24 * Parent class for generic entity token functionality.
25 *
26 * WARNING - this class is highly likely to be temporary and
27 * to be consolidated with the TokenTrait and / or the
28 * AbstractTokenSubscriber in future. It is being used to clarify
29 * functionality but should NOT be used from outside of core tested code.
30 */
31 class CRM_Core_EntityTokens extends AbstractTokenSubscriber {
32
33 /**
34 * Metadata about all tokens.
35 *
36 * @var array
37 */
38 protected $tokensMetadata = [];
39 /**
40 * @var array
41 */
42 protected $prefetch = [];
43
44 /**
45 * Should permissions be checked when loading tokens.
46 *
47 * @var bool
48 */
49 protected $checkPermissions = FALSE;
50
51 /**
52 * Register the declared tokens.
53 *
54 * @param \Civi\Token\Event\TokenRegisterEvent $e
55 * The registration event. Add new tokens using register().
56 */
57 public function registerTokens(TokenRegisterEvent $e) {
58 if (!$this->checkActive($e->getTokenProcessor())) {
59 return;
60 }
61 foreach ($this->getTokenMetadata() as $tokenName => $field) {
62 if ($field['audience'] === 'user') {
63 $e->register([
64 'entity' => $this->entity,
65 'field' => $tokenName,
66 'label' => $field['title'],
67 ]);
68 }
69 }
70 }
71
72 /**
73 * Get the metadata about the available tokens
74 *
75 * @return array
76 */
77 protected function getTokenMetadata(): array {
78 $cacheKey = $this->getCacheKey();
79 if (!Civi::cache('metadata')->has($cacheKey)) {
80 $tokensMetadata = $this->getBespokeTokens();
81 foreach ($this->getFieldMetadata() as $field) {
82 $this->addFieldToTokenMetadata($tokensMetadata, $field, $this->getExposedFields());
83 }
84 foreach ($this->getHiddenTokens() as $name) {
85 $tokensMetadata[$name]['audience'] = 'hidden';
86 }
87 Civi::cache('metadata')->set($cacheKey, $tokensMetadata);
88 }
89 return Civi::cache('metadata')->get($cacheKey);
90 }
91
92 /**
93 * @inheritDoc
94 * @throws \CRM_Core_Exception
95 */
96 public function evaluateToken(TokenRow $row, $entity, $field, $prefetch = NULL) {
97 $this->prefetch = (array) $prefetch;
98 $fieldValue = $this->getFieldValue($row, $field);
99 if (is_array($fieldValue)) {
100 // eg. role_id for participant would be an array here.
101 $fieldValue = implode(',', $fieldValue);
102 }
103
104 if ($this->isPseudoField($field)) {
105 if (!empty($fieldValue)) {
106 // If it's set here it has already been loaded in pre-fetch.
107 return $row->format('text/plain')->tokens($entity, $field, (string) $fieldValue);
108 }
109 // Once prefetch is fully standardised we can remove this - as long
110 // as tests pass we should be fine as tests cover this.
111 $split = explode(':', $field);
112 return $row->tokens($entity, $field, $this->getPseudoValue($split[0], $split[1], $this->getFieldValue($row, $split[0])));
113 }
114 if ($this->isCustomField($field)) {
115 $prefetchedValue = $this->getCustomFieldValue($this->getFieldValue($row, 'id'), $field);
116 if ($prefetchedValue) {
117 return $row->format('text/html')->tokens($entity, $field, $prefetchedValue);
118 }
119 return $row->customToken($entity, \CRM_Core_BAO_CustomField::getKeyID($field), $this->getFieldValue($row, 'id'));
120 }
121 if ($this->isMoneyField($field)) {
122 $currency = $this->getCurrency($row);
123 if (empty($fieldValue) && !is_numeric($fieldValue)) {
124 $fieldValue = 0;
125 }
126 if (!$currency) {
127 // too hard basket for now - just do what we always did.
128 return $row->format('text/plain')->tokens($entity, $field,
129 \CRM_Utils_Money::format($fieldValue, $currency));
130 }
131 return $row->format('text/plain')->tokens($entity, $field,
132 Money::of($fieldValue, $currency));
133
134 }
135 if ($this->isDateField($field)) {
136 try {
137 return $row->format('text/plain')
138 ->tokens($entity, $field, ($fieldValue ? new DateTime($fieldValue) : $fieldValue));
139 }
140 catch (Exception $e) {
141 Civi::log()->info('invalid date token');
142 }
143 }
144 $row->format('text/plain')->tokens($entity, $field, (string) $fieldValue);
145 }
146
147 /**
148 * Metadata about the entity fields.
149 *
150 * @var array
151 */
152 protected $fieldMetadata = [];
153
154 /**
155 * Get the entity name for api v4 calls.
156 *
157 * @return string
158 */
159 protected function getApiEntityName(): string {
160 return '';
161 }
162
163 /**
164 * Get the entity alias to use within queries.
165 *
166 * The default has a double underscore which should prevent any
167 * ambiguity with an existing table name.
168 *
169 * @return string
170 */
171 protected function getEntityAlias(): string {
172 return $this->getApiEntityName() . '__';
173 }
174
175 /**
176 * Get the name of the table this token class can extend.
177 *
178 * The default is based on the entity but some token classes,
179 * specifically the event class, latch on to other tables - ie
180 * the participant table.
181 */
182 public function getExtendableTableName(): string {
183 return CRM_Core_DAO_AllCoreTables::getTableForEntityName($this->getApiEntityName());
184 }
185
186 /**
187 * Get an array of fields to be requested.
188 *
189 * @todo this function should look up tokenMetadata that
190 * is already loaded.
191 *
192 * @return string[]
193 */
194 protected function getReturnFields(): array {
195 return array_keys($this->getBasicTokens());
196 }
197
198 /**
199 * Is the given field a boolean field.
200 *
201 * @param string $fieldName
202 *
203 * @return bool
204 */
205 protected function isBooleanField(string $fieldName): bool {
206 return $this->getMetadataForField($fieldName)['data_type'] === 'Boolean';
207 }
208
209 /**
210 * Is the given field a date field.
211 *
212 * @param string $fieldName
213 *
214 * @return bool
215 */
216 protected function isDateField(string $fieldName): bool {
217 return in_array($this->getMetadataForField($fieldName)['data_type'], ['Timestamp', 'Date'], TRUE);
218 }
219
220 /**
221 * Is the given field a pseudo field.
222 *
223 * @param string $fieldName
224 *
225 * @return bool
226 */
227 protected function isPseudoField(string $fieldName): bool {
228 return strpos($fieldName, ':') !== FALSE;
229 }
230
231 /**
232 * Is the given field a custom field.
233 *
234 * @param string $fieldName
235 *
236 * @return bool
237 */
238 protected function isCustomField(string $fieldName) : bool {
239 return (bool) \CRM_Core_BAO_CustomField::getKeyID($fieldName);
240 }
241
242 /**
243 * Is the given field a date field.
244 *
245 * @param string $fieldName
246 *
247 * @return bool
248 */
249 protected function isMoneyField(string $fieldName): bool {
250 return $this->getMetadataForField($fieldName)['data_type'] === 'Money';
251 }
252
253 /**
254 * Get the metadata for the available fields.
255 *
256 * @return array
257 */
258 protected function getFieldMetadata(): array {
259 if (empty($this->fieldMetadata)) {
260 try {
261 // Tests fail without checkPermissions = FALSE
262 $this->fieldMetadata = (array) civicrm_api4($this->getApiEntityName(), 'getfields', ['checkPermissions' => FALSE], 'name');
263 }
264 catch (API_Exception $e) {
265 $this->fieldMetadata = [];
266 }
267 }
268 return $this->fieldMetadata;
269 }
270
271 /**
272 * Get any tokens with custom calculation.
273 */
274 protected function getBespokeTokens(): array {
275 return [];
276 }
277
278 /**
279 * Get the value for the relevant pseudo field.
280 *
281 * @param string $realField e.g contribution_status_id
282 * @param string $pseudoKey e.g name
283 * @param int|string $fieldValue e.g 1
284 *
285 * @return string
286 * Eg. 'Completed' in the example above.
287 *
288 * @internal function will likely be protected soon.
289 */
290 protected function getPseudoValue(string $realField, string $pseudoKey, $fieldValue): string {
291 $bao = CRM_Core_DAO_AllCoreTables::getFullName($this->getMetadataForField($realField)['entity']);
292 if ($pseudoKey === 'name') {
293 $fieldValue = (string) CRM_Core_PseudoConstant::getName($bao, $realField, $fieldValue);
294 }
295 if ($pseudoKey === 'label') {
296 $fieldValue = (string) CRM_Core_PseudoConstant::getLabel($bao, $realField, $fieldValue);
297 }
298 if ($pseudoKey === 'abbr' && $realField === 'state_province_id') {
299 // hack alert - currently only supported for state.
300 $fieldValue = (string) CRM_Core_PseudoConstant::stateProvinceAbbreviation($fieldValue);
301 }
302 return (string) $fieldValue;
303 }
304
305 /**
306 * @param \Civi\Token\TokenRow $row
307 * @param string $field
308 * @return string|int
309 */
310 protected function getFieldValue(TokenRow $row, string $field) {
311 $entityName = $this->getEntityName();
312 if (isset($row->context[$entityName][$field])) {
313 return $row->context[$entityName][$field];
314 }
315
316 $entityID = $row->context[$this->getEntityIDField()];
317 if ($field === 'id') {
318 return $entityID;
319 }
320 return $this->prefetch[$entityID][$field] ?? '';
321 }
322
323 /**
324 * Class constructor.
325 */
326 public function __construct() {
327 parent::__construct($this->getEntityName(), []);
328 }
329
330 /**
331 * Check if the token processor is active.
332 *
333 * @param \Civi\Token\TokenProcessor $processor
334 *
335 * @return bool
336 */
337 public function checkActive(TokenProcessor $processor) {
338 return (!empty($processor->context['actionMapping'])
339 // This makes the 'schema context compulsory - which feels accidental
340 // since recent discu
341 && $processor->context['actionMapping']->getEntity()) || in_array($this->getEntityIDField(), $processor->context['schema']);
342 }
343
344 /**
345 * Alter action schedule query.
346 *
347 * @param \Civi\ActionSchedule\Event\MailingQueryEvent $e
348 */
349 public function alterActionScheduleQuery(MailingQueryEvent $e): void {
350 if ($e->mapping->getEntity() !== $this->getExtendableTableName()) {
351 return;
352 }
353 $e->query->select('e.id AS tokenContext_' . $this->getEntityIDField());
354 }
355
356 /**
357 * Get tokens to be suppressed from the widget.
358 *
359 * Note this is expected to be an interim function. Now we are no
360 * longer working around the parent function we can just define them once...
361 * with metadata, in a future refactor.
362 */
363 protected function getHiddenTokens(): array {
364 return [];
365 }
366
367 /**
368 * @todo remove this function & use the metadata that is loaded.
369 *
370 * @return string[]
371 * @throws \API_Exception
372 */
373 protected function getBasicTokens(): array {
374 $return = [];
375 foreach ($this->getExposedFields() as $fieldName) {
376 // Custom fields are still added v3 style - we want to keep v4 naming 'unpoluted'
377 // for now to allow us to consider how to handle names vs labels vs values
378 // and other raw vs not raw options.
379 if ($this->getFieldMetadata()[$fieldName]['type'] !== 'Custom') {
380 $return[$fieldName] = $this->getFieldMetadata()[$fieldName]['title'];
381 }
382 }
383 return $return;
384 }
385
386 /**
387 * Get entity fields that should be exposed as tokens.
388 *
389 * @return string[]
390 *
391 */
392 protected function getExposedFields(): array {
393 $return = [];
394 foreach ($this->getFieldMetadata() as $field) {
395 if (!in_array($field['name'], $this->getSkippedFields(), TRUE)) {
396 $return[] = $field['name'];
397 }
398 }
399 return $return;
400 }
401
402 /**
403 * Get entity fields that should not be exposed as tokens.
404 *
405 * @return string[]
406 */
407 protected function getSkippedFields(): array {
408 // tags is offered in 'case' & is one of the only fields that is
409 // 'not a real field' offered up by case - seems like an oddity
410 // we should skip at the top level for now.
411 $fields = ['tags'];
412 if (!CRM_Campaign_BAO_Campaign::isCampaignEnable()) {
413 $fields[] = 'campaign_id';
414 }
415 return $fields;
416 }
417
418 /**
419 * @return string
420 */
421 protected function getEntityName(): string {
422 return CRM_Core_DAO_AllCoreTables::convertEntityNameToLower($this->getApiEntityName());
423 }
424
425 protected function getEntityIDField(): string {
426 return $this->getEntityName() . 'Id';
427 }
428
429 public function prefetch(TokenValueEvent $e): ?array {
430 $entityIDs = $e->getTokenProcessor()->getContextValues($this->getEntityIDField());
431 if (empty($entityIDs)) {
432 return [];
433 }
434 $select = $this->getPrefetchFields($e);
435 $result = (array) civicrm_api4($this->getApiEntityName(), 'get', [
436 'checkPermissions' => FALSE,
437 // Note custom fields are not yet added - I need to
438 // re-do the unit tests to support custom fields first.
439 'select' => $select,
440 'where' => [['id', 'IN', $entityIDs]],
441 ], 'id');
442 return $result;
443 }
444
445 protected function getCurrencyFieldName() {
446 return [];
447 }
448
449 /**
450 * Get the currency to use for formatting money.
451 * @param $row
452 *
453 * @return string
454 */
455 protected function getCurrency($row): string {
456 if (!empty($this->getCurrencyFieldName())) {
457 return $this->getFieldValue($row, $this->getCurrencyFieldName()[0]);
458 }
459 return CRM_Core_Config::singleton()->defaultCurrency;
460 }
461
462 /**
463 * Get the fields required to prefetch the entity.
464 *
465 * @param \Civi\Token\Event\TokenValueEvent $e
466 *
467 * @return array
468 * @throws \API_Exception
469 */
470 public function getPrefetchFields(TokenValueEvent $e): array {
471 $allTokens = array_keys($this->getTokenMetadata());
472 $requiredFields = array_intersect($this->getActiveTokens($e), $allTokens);
473 if (empty($requiredFields)) {
474 return [];
475 }
476 $requiredFields = array_merge($requiredFields, array_intersect($allTokens, array_merge(['id'], $this->getCurrencyFieldName())));
477 foreach ($this->getDependencies() as $field => $required) {
478 if (in_array($field, $this->getActiveTokens($e), TRUE)) {
479 foreach ((array) $required as $key) {
480 $requiredFields[] = $key;
481 }
482 }
483 }
484 return $requiredFields;
485 }
486
487 /**
488 * Get fields which need to be returned to render another token.
489 *
490 * @return array
491 */
492 protected function getDependencies(): array {
493 return [];
494 }
495
496 /**
497 * Get the apiv4 style custom field name.
498 *
499 * @param int $id
500 *
501 * @return string
502 *
503 * @throws \CRM_Core_Exception
504 */
505 protected function getCustomFieldName(int $id): string {
506 foreach ($this->getTokenMetadata() as $key => $field) {
507 if (($field['custom_field_id'] ?? NULL) === $id) {
508 return $key;
509 }
510 }
511 throw new CRM_Core_Exception(
512 "A custom field with the ID {$id} does not exist"
513 );
514 }
515
516 /**
517 * @param $entityID
518 * @param string $field eg. 'custom_1'
519 *
520 * @return array|string|void|null $mixed
521 *
522 * @throws \CRM_Core_Exception
523 */
524 protected function getCustomFieldValue($entityID, string $field) {
525 $id = str_replace('custom_', '', $field);
526 try {
527 $value = $this->prefetch[$entityID][$this->getCustomFieldName($id)] ?? '';
528 if ($value !== NULL) {
529 return CRM_Core_BAO_CustomField::displayValue($value, $id);
530 }
531 }
532 catch (CRM_Core_Exception $exception) {
533 return NULL;
534 }
535 }
536
537 /**
538 * Get the metadata for the field.
539 *
540 * @param string $fieldName
541 *
542 * @return array
543 */
544 protected function getMetadataForField($fieldName): array {
545 if (isset($this->getTokenMetadata()[$fieldName])) {
546 return $this->getTokenMetadata()[$fieldName];
547 }
548 if (isset($this->getTokenMappingsForRelatedEntities()[$fieldName])) {
549 return $this->getTokenMetadata()[$this->getTokenMappingsForRelatedEntities()[$fieldName]];
550 }
551 return $this->getTokenMetadata()[$this->getDeprecatedTokens()[$fieldName]] ?? [];
552 }
553
554 /**
555 * Get token mappings for related entities - specifically the contact entity.
556 *
557 * This function exists to help manage the way contact tokens is structured
558 * of an query-object style result set that needs to be mapped to apiv4.
559 *
560 * The end goal is likely to be to advertised tokens that better map to api
561 * v4 and deprecate the existing ones but that is a long-term migration.
562 *
563 * @return array
564 */
565 protected function getTokenMappingsForRelatedEntities(): array {
566 return [];
567 }
568
569 /**
570 * Get array of deprecated tokens and the new token they map to.
571 *
572 * @return array
573 */
574 protected function getDeprecatedTokens(): array {
575 return [];
576 }
577
578 /**
579 * Get any overrides for token metadata.
580 *
581 * This is most obviously used for setting the audience, which
582 * will affect widget-presence.
583 *
584 * @return \string[][]
585 */
586 protected function getTokenMetadataOverrides(): array {
587 return [];
588 }
589
590 /**
591 * To handle variable tokens, override this function and return the active tokens.
592 *
593 * @param \Civi\Token\Event\TokenValueEvent $e
594 *
595 * @return mixed
596 */
597 public function getActiveTokens(TokenValueEvent $e) {
598 $messageTokens = $e->getTokenProcessor()->getMessageTokens();
599 if (!isset($messageTokens[$this->entity])) {
600 return FALSE;
601 }
602 return array_intersect($messageTokens[$this->entity], array_keys($this->getTokenMetadata()));
603 }
604
605 /**
606 * Add the token to the metadata based on the field spec.
607 *
608 * @param array $tokensMetadata
609 * @param array $field
610 * @param array $exposedFields
611 * @param string $prefix
612 */
613 protected function addFieldToTokenMetadata(array &$tokensMetadata, array $field, array $exposedFields, string $prefix = ''): void {
614 if ($field['type'] !== 'Custom' && !in_array($field['name'], $exposedFields, TRUE)) {
615 return;
616 }
617 $field['audience'] = 'user';
618 if ($field['name'] === 'contact_id') {
619 // Since {contact.id} is almost always present don't confuse users
620 // by also adding (e.g {participant.contact_id)
621 $field['audience'] = 'sysadmin';
622 }
623 if (!empty($this->getTokenMetadataOverrides()[$field['name']])) {
624 $field = array_merge($field, $this->getTokenMetadataOverrides()[$field['name']]);
625 }
626 if ($field['type'] === 'Custom') {
627 // Convert to apiv3 style for now. Later we can add v4 with
628 // portable naming & support for labels/ dates etc so let's leave
629 // the space open for that.
630 // Not the existing quickform widget has handling for the custom field
631 // format based on the title using this syntax.
632 $parts = explode(': ', $field['label']);
633 $field['title'] = "{$parts[1]} :: {$parts[0]}";
634 $tokenName = 'custom_' . $field['custom_field_id'];
635 $tokensMetadata[$tokenName] = $field;
636 return;
637 }
638 $tokenName = $prefix ? ($prefix . '.' . $field['name']) : $field['name'];
639 if (in_array($field['name'], $exposedFields, TRUE)) {
640 if (
641 ($field['options'] || !empty($field['suffixes']))
642 // At the time of writing currency didn't have a label option - this may have changed.
643 && !in_array($field['name'], $this->getCurrencyFieldName(), TRUE)
644 ) {
645 $tokensMetadata[$tokenName . ':label'] = $tokensMetadata[$tokenName . ':name'] = $field;
646 $fieldLabel = $field['input_attrs']['label'] ?? $field['label'];
647 $tokensMetadata[$tokenName . ':label']['name'] = $field['name'] . ':label';
648 $tokensMetadata[$tokenName . ':name']['name'] = $field['name'] . ':name';
649 $tokensMetadata[$tokenName . ':name']['audience'] = 'sysadmin';
650 $tokensMetadata[$tokenName . ':label']['title'] = $fieldLabel;
651 $tokensMetadata[$tokenName . ':name']['title'] = ts('Machine name') . ': ' . $fieldLabel;
652 $field['audience'] = 'sysadmin';
653 }
654 if ($field['data_type'] === 'Boolean') {
655 $tokensMetadata[$tokenName . ':label'] = $field;
656 $tokensMetadata[$tokenName . ':label']['name'] = $field['name'] . ':label';
657 $field['audience'] = 'sysadmin';
658 }
659 $tokensMetadata[$tokenName] = $field;
660 }
661 }
662
663 /**
664 * Get a cache key appropriate to the current usage.
665 *
666 * @return string
667 */
668 protected function getCacheKey(): string {
669 $cacheKey = __CLASS__ . 'token_metadata' . $this->getApiEntityName() . CRM_Core_Config::domainID() . '_' . CRM_Core_I18n::getLocale();
670 if ($this->checkPermissions) {
671 $cacheKey .= '__' . CRM_Core_Session::getLoggedInContactID();
672 }
673 return $cacheKey;
674 }
675
676 }