Add WorkflowMessage.get and WorkflowMessage.render APIs
authorTim Otten <totten@civicrm.org>
Wed, 14 Jul 2021 00:21:52 +0000 (17:21 -0700)
committerTim Otten <totten@civicrm.org>
Mon, 13 Sep 2021 22:33:59 +0000 (15:33 -0700)
CRM/Core/Permission.php
Civi/Api4/Action/WorkflowMessage/Render.php [new file with mode: 0644]
Civi/Api4/WorkflowMessage.php [new file with mode: 0644]
Civi/WorkflowMessage/WorkflowMessage.php
tests/phpunit/api/v4/Entity/WorkflowMessageTest.php [new file with mode: 0644]

index 5166b65466ae5de3ecd95d480532ca427ae925d4..2ad9e9977e069320e0af3379745bff9463071c78 100644 (file)
@@ -841,6 +841,10 @@ class CRM_Core_Permission {
         $prefix . ts('administer payment processors'),
         ts('Add, Update, or Disable Payment Processors'),
       ],
+      'render templates' => [
+        $prefix . ts('render templates'),
+        ts('Render open-ended template content. (Additional constraints may apply to autoloaded records and specific notations.)'),
+      ],
       'edit message templates' => [
         $prefix . ts('edit message templates'),
       ],
diff --git a/Civi/Api4/Action/WorkflowMessage/Render.php b/Civi/Api4/Action/WorkflowMessage/Render.php
new file mode 100644 (file)
index 0000000..8661107
--- /dev/null
@@ -0,0 +1,168 @@
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Api4\Action\WorkflowMessage;
+
+use Civi\Api4\Event\ValidateValuesEvent;
+use Civi\WorkflowMessage\WorkflowMessage;
+
+/**
+ * Render a message.
+ *
+ * @method $this setValues(array $rows) Set the list of records to be rendered.
+ * @method array getValues()
+ * @method $this setMessageTemplate(array|null $fragments) Set of messages to be rendered.
+ * @method array|null getMessageTemplate()
+ * @method $this setMessageTemplateId(int|null $id) Set of messages to be rendered.
+ * @method int|null getMessageTemplateId()
+ * @method $this setWorkflow(string $workflow)
+ * @method string getWorkflow()
+ * @method $this setErrorLevel(string $workflow)
+ * @method string getErrorLevel()
+ */
+class Render extends \Civi\Api4\Generic\AbstractAction {
+
+  /**
+   * Abort if the validator finds any issues at this error level.
+   *
+   * @var string
+   * @options error,warning,info
+   */
+  protected $errorLevel = 'error';
+
+  /**
+   * Symbolic name of the workflow step for which we need a message.
+   * @var string
+   * @required
+   */
+  protected $workflow;
+
+  /**
+   * @var array
+   */
+  protected $values = [];
+
+  /**
+   * Load and render a specific message template (by ID).
+   *
+   * @var int|null
+   */
+  protected $messageTemplateId;
+
+  /**
+   * Use a draft message template.
+   *
+   * @var array|null
+   *   - `subject`: Message template (eg `Hello {contact.first_name}!`)
+   *   - `text`: Message template (eg `Hello {contact.first_name}!`)
+   *   - `html`: Message template (eg `<p>Hello {contact.first_name}!</p>`)
+   */
+  protected $messageTemplate;
+
+  /**
+   * @var \Civi\WorkflowMessage\WorkflowMessageInterface
+   */
+  protected $_model;
+
+  public function _run(\Civi\Api4\Generic\Result $result) {
+    $this->validateValues();
+
+    $r = \CRM_Core_BAO_MessageTemplate::renderTemplate([
+      'model' => $this->_model,
+      'messageTemplate' => $this->getMessageTemplate(),
+      'messageTemplateId' => $this->getMessageTemplateId(),
+    ]);
+
+    $result[] = \CRM_Utils_Array::subset($r, ['subject', 'html', 'text']);
+  }
+
+  /**
+   * The token-processor supports a range of context parameters. We enforce different security rules for each kind of input.
+   *
+   * Broadly, this distinguishes between a few values:
+   * - Autoloaded data (e.g. 'contactId', 'activityId'). We need to ensure that the specific records are visible and extant.
+   * - Inputted data (e.g. 'contact'). We merely ensure that these are type-correct.
+   * - Prohibited/extended options, e.g. 'smarty'
+   */
+  protected function validateValues() {
+    $rows = [$this->getValues()];
+    $e = new ValidateValuesEvent($this, $rows, new \CRM_Utils_LazyArray(function () use ($rows) {
+      return array_map(
+        function ($row) {
+          return ['old' => NULL, 'new' => $row];
+        },
+        $rows
+      );
+    }));
+    $this->onValidateValues($e);
+    \Civi::dispatcher()->dispatch('civi.api4.validate', $e);
+    if (!empty($e->errors)) {
+      throw $e->toException();
+    }
+  }
+
+  protected function onValidateValues(ValidateValuesEvent $e) {
+    $errorWeightMap = \CRM_Core_Error_Log::getMap();
+    $errorWeight = $errorWeightMap[$this->getErrorLevel()];
+
+    if (count($e->records) !== 1) {
+      throw new \CRM_Core_Exception("Expected exactly one record to validate");
+    }
+    foreach ($e->records as $recordKey => $record) {
+      /** @var \Civi\WorkflowMessage\WorkflowMessageInterface $w */
+      $w = $this->_model = WorkflowMessage::create($this->getWorkflow(), [
+        'modelProps' => $record,
+      ]);
+      $fields = $w->getFields();
+
+      $unknowns = array_diff(array_keys($record), array_keys($fields));
+      foreach ($unknowns as $fieldName) {
+        $e->addError($recordKey, $fieldName, 'unknown_field', ts('Unknown field (%1). Templates may only be executed with supported fields.', [
+          1 => $fieldName,
+        ]));
+      }
+
+      // Merge intrinsic validations
+      foreach ($w->validate() as $issue) {
+        if ($errorWeightMap[$issue['severity']] < $errorWeight) {
+          $e->addError($recordKey, $issue['fields'], $issue['name'], $issue['message']);
+        }
+      }
+
+      // Add checks which don't fit in WFM::validate
+      foreach ($fields as $fieldName => $fieldSpec) {
+        $fieldValue = $record[$fieldName] ?? NULL;
+        if ($fieldSpec->getFkEntity() && !empty($fieldValue)) {
+          if (!empty($params['check_permissions']) && !\Civi\Api4\Utils\CoreUtil::checkAccessDelegated($fieldSpec->getFkEntity(), 'get', ['id' => $fieldValue], CRM_Core_Session::getLoggedInContactID() ?: 0)) {
+            $e->addError($recordKey, $fieldName, 'nonexistent_id', ts('Referenced record does not exist or is not visible (%1).', [
+              1 => $this->getWorkflow() . '::' . $fieldName,
+            ]));
+          }
+        }
+      }
+    }
+  }
+
+  public function fields() {
+    return [];
+    // We don't currently get the name of the workflow. But if we do...
+    //$item = \Civi\WorkflowMessage\WorkflowMessage::create($this->workflow);
+    ///** @var \Civi\WorkflowMessage\FieldSpec[] $fields */
+    //$fields = $item->getFields();
+    //$array = [];
+    //foreach ($fields as $name => $field) {
+    //  $array[$name] = $field->toArray();
+    //}
+    //return $array;
+  }
+
+}
diff --git a/Civi/Api4/WorkflowMessage.php b/Civi/Api4/WorkflowMessage.php
new file mode 100644 (file)
index 0000000..565bff7
--- /dev/null
@@ -0,0 +1,120 @@
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+/**
+ *
+ * @package CRM
+ * @copyright CiviCRM LLC https://civicrm.org/licensing
+ */
+
+namespace Civi\Api4;
+
+/**
+ * A WorkflowMessage describes the inputs to an automated email messages, and it
+ * allows you to render or preview the content fo automated email messages.
+ *
+ * For example, when a constituent donates online, CiviContribute uses the
+ * `contribution_online_receipt` workflow message. This expects certain inputs
+ * (eg `contactId` and `contributionId`) and supports certain tokens
+ * (eg `{contribution.total_amount}`).
+ *
+ * WorkflowMessages are related to MessageTemplates (by way of
+ * `WorkflowMessage.name`<=>`MessageTemplate.workflow_name`).
+ * The WorkflowMessage defines the _contract_ or _processing_ of the
+ * message, and the MessageTemplate defines the _literal prose_.  The prose
+ * would change frequently (eg for different deployments, locales, timeframes,
+ * and other whims), but contract would change conservatively (eg with a
+ * code-update and with some attention to backward-compatibility/change-management).
+ *
+ * @searchable none
+ * @since 5.43
+ * @package Civi\Api4
+ */
+class WorkflowMessage extends Generic\AbstractEntity {
+
+  /**
+   * @param bool $checkPermissions
+   * @return Generic\BasicGetAction
+   */
+  public static function get($checkPermissions = TRUE) {
+    return (new Generic\BasicGetAction(__CLASS__, __FUNCTION__, function ($get) {
+      return \Civi\WorkflowMessage\WorkflowMessage::getWorkflowSpecs();
+    }))->setCheckPermissions($checkPermissions);
+  }
+
+  /**
+   * @param bool $checkPermissions
+   *
+   * @return \Civi\Api4\Action\WorkflowMessage\Render
+   */
+  public static function render($checkPermissions = TRUE) {
+    return (new Action\WorkflowMessage\Render(__CLASS__, __FUNCTION__))
+      ->setCheckPermissions($checkPermissions);
+  }
+
+  /**
+   * @param bool $checkPermissions
+   * @return Generic\BasicGetFieldsAction
+   */
+  public static function getFields($checkPermissions = TRUE) {
+    return (new Generic\BasicGetFieldsAction(__CLASS__, __FUNCTION__, function() {
+      return [
+        [
+          'name' => 'name',
+          'title' => 'Name',
+          'data_type' => 'String',
+        ],
+        [
+          'name' => 'group',
+          'title' => 'Group',
+          'data_type' => 'String',
+        ],
+        [
+          'name' => 'class',
+          'title' => 'Class',
+          'data_type' => 'String',
+        ],
+        [
+          'name' => 'description',
+          'title' => 'Description',
+          'data_type' => 'String',
+        ],
+      ];
+    }))->setCheckPermissions($checkPermissions);
+  }
+
+  public static function permissions() {
+    return [
+      'meta' => ['access CiviCRM'],
+      'default' => ['administer CiviCRM'],
+      'render' => [
+        // nested array = OR
+        [
+          'edit message templates',
+          'edit user-driven message templates',
+          'edit system workflow message templates',
+          'render templates',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * @inheritDoc
+   */
+  public static function getInfo() {
+    $info = parent::getInfo();
+    $info['primary_key'] = ['name'];
+    return $info;
+  }
+
+}
index 3647286ef558ca6daca98b37e2d56bbf250d3d0f..5ad47eaed4742a130d45fd4dc60f6728df602411 100644 (file)
@@ -12,6 +12,7 @@
 
 namespace Civi\WorkflowMessage;
 
+use Civi\Api4\Utils\ReflectionUtils;
 use Civi\WorkflowMessage\Exception\WorkflowMessageException;
 
 /**
@@ -170,4 +171,38 @@ class WorkflowMessage {
     return $map;
   }
 
+  /**
+   * Get general description of available workflow-messages.
+   *
+   * @return array
+   *   Array(string $workflowName => string $className).
+   *   Ex: ["case_activity" => ["name" => "case_activity", "group" => "msg_workflow_case"]
+   * @internal
+   */
+  public static function getWorkflowSpecs() {
+    $compute = function() {
+      $keys = ['name', 'group', 'class', 'description', 'comment'];
+      $list = [];
+      foreach (self::getWorkflowNameClassMap() as $name => $class) {
+        $specs = [
+          'name' => $name,
+          'group' => \CRM_Utils_Constant::value($class . '::GROUP'),
+          'class' => $class,
+        ];
+        $list[$name] = \CRM_Utils_Array::subset(
+          array_merge(ReflectionUtils::getCodeDocs(new \ReflectionClass($class)), $specs),
+          $keys);
+      }
+      return $list;
+    };
+
+    $cache = \Civi::cache('long');
+    $cacheKey = 'WorkflowMessage-' . __FUNCTION__;
+    $list = $cache->get($cacheKey);
+    if ($list === NULL) {
+      $cache->set($cacheKey, $list = $compute());
+    }
+    return $list;
+  }
+
 }
diff --git a/tests/phpunit/api/v4/Entity/WorkflowMessageTest.php b/tests/phpunit/api/v4/Entity/WorkflowMessageTest.php
new file mode 100644 (file)
index 0000000..056dc39
--- /dev/null
@@ -0,0 +1,94 @@
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+/**
+ *
+ * @package CRM
+ * @copyright CiviCRM LLC https://civicrm.org/licensing
+ */
+
+
+namespace api\v4\Entity;
+
+use api\v4\UnitTestCase;
+use Civi\Api4\WorkflowMessage;
+
+/**
+ * @group headless
+ */
+class WorkflowMessageTest extends UnitTestCase {
+
+  public function testGet() {
+    $result = \Civi\Api4\WorkflowMessage::get(0)
+      ->addWhere('name', 'LIKE', 'case%')
+      ->execute()
+      ->indexBy('name');
+    $this->assertTrue(isset($result['case_activity']));
+  }
+
+  public function testRenderDefaultTemplate() {
+    $ex = \Civi\Api4\WorkflowMessageExample::get(0)
+      ->addWhere('name', '=', 'case_activity.class_1')
+      ->addSelect('data', 'workflow')
+      ->addChain('render', WorkflowMessage::render()
+        ->setWorkflow('$workflow')
+        ->setValues('$data.modelProps'))
+      ->execute()
+      ->single();
+    $result = $ex['render'][0];
+    $this->assertRegExp('/Case ID : 1234/', $result['text']);
+  }
+
+  public function testRenderCustomTemplate() {
+    $ex = \Civi\Api4\WorkflowMessageExample::get(0)
+      ->addWhere('name', '=', 'case_activity.class_1')
+      ->addSelect('data')
+      ->execute()
+      ->single();
+    $result = \Civi\Api4\WorkflowMessage::render(0)
+      ->setWorkflow('case_activity')
+      ->setValues($ex['data']['modelProps'])
+      ->setMessageTemplate([
+        'msg_text' => 'The role is {$contact.role}.',
+      ])
+      ->execute()
+      ->single();
+    $this->assertRegExp('/The role is myrole./', $result['text']);
+  }
+
+  public function testRenderExamples() {
+    $examples = \Civi\Api4\WorkflowMessageExample::get(0)
+      ->addWhere('tags', 'CONTAINS', 'phpunit')
+      ->addSelect('name', 'workflow', 'data', 'asserts')
+      ->execute();
+    $this->assertTrue($examples->rowCount >= 1);
+    foreach ($examples as $example) {
+      $this->assertTrue(!empty($example['data']['modelProps']), sprintf("Example (%s) is tagged phpunit. It should have modelProps.", $example['name']));
+      $this->assertTrue(!empty($example['asserts']['default']), sprintf("Example (%s) is tagged phpunit. It should have assertions.", $example['name']));
+      $result = \Civi\Api4\WorkflowMessage::render(0)
+        ->setWorkflow($example['workflow'])
+        ->setValues($example['data']['modelProps'])
+        ->execute()
+        ->single();
+      foreach ($example['asserts']['default'] as $num => $assert) {
+        $msg = sprintf('Check assertion(%s) on example (%s)', $num, $example['name']);
+        if (isset($assert['regex'])) {
+          $this->assertRegExp($assert['regex'], $result[$assert['for']], $msg);
+        }
+        else {
+          $this->fail('Unrecognized assertion: ' . json_encode($assert));
+        }
+      }
+    }
+  }
+
+}