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