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