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