From da9977bd70ddfc8354f2e7b5197194da8aad129d Mon Sep 17 00:00:00 2001 From: Aidan Saunders Date: Fri, 28 Jun 2019 14:02:26 +1200 Subject: [PATCH] Add PDF letter functionality for Activities using new token processor Create CRM_Actvity_Form_Task_PDFLetterCommon extending CRM_Core_Form_Task_PDFLetterCommon Slim down alterActionScheduleQuery() and move most data fetching to prefetch() Add tests for Activity PDF Letter --- CRM/Activity/Form/Task/PDF.php | 79 +++++++ CRM/Activity/Form/Task/PDFLetterCommon.php | 90 ++++++++ CRM/Activity/Task.php | 5 + CRM/Activity/Tokens.php | 192 +++++++++++++++--- CRM/Core/Form/Task/PDFLetterCommon.php | 48 ++++- templates/CRM/Activity/Form/Task/PDF.tpl | 30 +++ .../Form/Task/PDFLetterCommonTest.php | 98 +++++++++ tests/phpunit/CiviTest/CiviUnitTestCase.php | 3 +- 8 files changed, 511 insertions(+), 34 deletions(-) create mode 100644 CRM/Activity/Form/Task/PDF.php create mode 100644 CRM/Activity/Form/Task/PDFLetterCommon.php create mode 100644 templates/CRM/Activity/Form/Task/PDF.tpl create mode 100644 tests/phpunit/CRM/Activity/Form/Task/PDFLetterCommonTest.php diff --git a/CRM/Activity/Form/Task/PDF.php b/CRM/Activity/Form/Task/PDF.php new file mode 100644 index 0000000000..b64ed66406 --- /dev/null +++ b/CRM/Activity/Form/Task/PDF.php @@ -0,0 +1,79 @@ +_activityHolderIds; + $formValues = $form->controller->exportValues($form->getName()); + $html_message = self::processTemplate($formValues); + + // Do the rest in another function to make testing easier + self::createDocument($activityIds, $html_message, $formValues); + + $form->postProcessHook(); + + CRM_Utils_System::civiExit(1); + } + + /** + * Produce the document from the activities + * This uses the new token processor + * + * @param array $activityIds array of activity ids + * @param string $html_message message text with tokens + * @param array $formValues formValues from the form + * @return void + */ + public static function createDocument($activityIds, $html_message, $formValues) { + $tp = self::createTokenProcessor(); + $tp->addMessage('body_html', $html_message, 'text/html'); + + foreach ($activityIds as $activityId) { + $tp->addRow()->context('activityId', $activityId); + } + $tp->evaluate(); + + return self::renderFromRows($tp->getRows(), 'body_html', $formValues); + } + + /** + * Create a token processor + */ + public static function createTokenProcessor() { + return new TokenProcessor(\Civi::dispatcher(), array( + 'controller' => get_class(), + 'smarty' => FALSE, + 'schema' => ['activityId'], + )); + } + +} diff --git a/CRM/Activity/Task.php b/CRM/Activity/Task.php index 00bfcbffd9..2127258512 100644 --- a/CRM/Activity/Task.php +++ b/CRM/Activity/Task.php @@ -84,6 +84,11 @@ class CRM_Activity_Task extends CRM_Core_Task { ], 'result' => FALSE, ], + self::PDF_LETTER => [ + 'title' => ts('Print/merge Document'), + 'class' => 'CRM_Activity_Form_Task_PDF', + 'result' => FALSE, + ], self::TASK_SMS => [ 'title' => ts('SMS - send reply'), 'class' => 'CRM_Activity_Form_Task_SMS', diff --git a/CRM/Activity/Tokens.php b/CRM/Activity/Tokens.php index 3271b96592..712348e110 100644 --- a/CRM/Activity/Tokens.php +++ b/CRM/Activity/Tokens.php @@ -36,15 +36,33 @@ * * Generate "activity.*" tokens. * - * This TokenSubscriber was produced by refactoring the code from the + * This TokenSubscriber was originally produced by refactoring the code from the * scheduled-reminder system with the goal of making that system * more flexible. The current implementation is still coupled to * scheduled-reminders. It would be good to figure out a more generic * implementation which is not tied to scheduled reminders, although * that is outside the current scope. + * + * This has been enhanced to work with PDF/letter merge */ class CRM_Activity_Tokens extends \Civi\Token\AbstractTokenSubscriber { + private $basicTokens; + private $customFieldTokens; + + /** + * Mapping from tokenName to api return field + * Use lists since we might need multiple fields + * + * @var array + */ + private static $fieldMapping = [ + 'activity_id' => ['id'], + 'activity_type' => ['activity_type_id'], + 'status' => ['status_id'], + 'campaign' => ['campaign_id'], + ]; + /** * CRM_Activity_Tokens constructor. */ @@ -59,9 +77,34 @@ class CRM_Activity_Tokens extends \Civi\Token\AbstractTokenSubscriber { * @inheritDoc */ public function checkActive(\Civi\Token\TokenProcessor $processor) { - // Extracted from scheduled-reminders code. See the class description. - return !empty($processor->context['actionMapping']) - && $processor->context['actionMapping']->getEntity() === 'civicrm_activity'; + return in_array('activityId', $processor->context['schema']) || + (!empty($processor->context['actionMapping']) + && $processor->context['actionMapping']->getEntity() === 'civicrm_activity'); + } + + /** + * @inheritDoc + */ + public function getActiveTokens(\Civi\Token\Event\TokenValueEvent $e) { + $messageTokens = $e->getTokenProcessor()->getMessageTokens(); + if (!isset($messageTokens[$this->entity])) { + return NULL; + } + + $activeTokens = []; + // if message token contains '_\d+_', then treat as '_N_' + foreach ($messageTokens[$this->entity] as $msgToken) { + if (array_key_exists($msgToken, $this->tokenNames)) { + $activeTokens[] = $msgToken; + } + else { + $altToken = preg_replace('/_\d+_/', '_N_', $msgToken); + if (array_key_exists($altToken, $this->tokenNames)) { + $activeTokens[] = $msgToken; + } + } + } + return array_unique($activeTokens); } /** @@ -76,39 +119,109 @@ class CRM_Activity_Tokens extends \Civi\Token\AbstractTokenSubscriber { // Multiple revisions of the activity. // Q: Could we simplify & move the extra AND clauses into `where(...)`? $e->query->param('casEntityJoinExpr', 'e.id = reminder.entity_id AND e.is_current_revision = 1 AND e.is_deleted = 0'); + } - // FIXME: seems too broad. - $e->query->select('e.*'); - $e->query->select('ov.label as activity_type, e.id as activity_id'); + /** + * Find the fields that we need to get to construct the tokens requested. + * @param array $tokens list of tokens + * @return array list of fields needed to generate those tokens + */ + public function getReturnFields($tokens) { + // Make sure we always return something + $fields = ['id']; - $e->query->join("og", "!casMailingJoinType civicrm_option_group og ON og.name = 'activity_type'"); - $e->query->join("ov", "!casMailingJoinType civicrm_option_value ov ON e.activity_type_id = ov.value AND ov.option_group_id = og.id"); + foreach (array_intersect($tokens, + array_merge(array_keys(self::getBasicTokens()), array_keys(self::getCustomFieldTokens())) + ) as $token) { + if (isset(self::$fieldMapping[$token])) { + $fields = array_merge($fields, self::$fieldMapping[$token]); + } + else { + $fields[] = $token; + } + } + return array_unique($fields); + } - // if CiviCase component is enabled, join for caseId. - $compInfo = CRM_Core_Component::getEnabledComponents(); - if (array_key_exists('CiviCase', $compInfo)) { - $e->query->select("civicrm_case_activity.case_id as case_id"); - $e->query->join('civicrm_case_activity', "LEFT JOIN `civicrm_case_activity` ON `e`.`id` = `civicrm_case_activity`.`activity_id`"); + /** + * @inheritDoc + */ + public function prefetch(\Civi\Token\Event\TokenValueEvent $e) { + // Find all the activity IDs + $activityIds + = $e->getTokenProcessor()->getContextValues('actionSearchResult', 'entityID') + + $e->getTokenProcessor()->getContextValues('activityId'); + + if (!$activityIds) { + return; + } + + // Get data on all activities for basic and customfield tokens + $activities = civicrm_api3('Activity', 'get', [ + 'id' => ['IN' => $activityIds], + 'options' => ['limit' => 0], + 'return' => self::getReturnFields($this->activeTokens), + ]); + $prefetch['activity'] = $activities['values']; + + // Store the activity types if needed + if (in_array('activity_type', $this->activeTokens)) { + $this->activityTypes = \CRM_Core_OptionGroup::values('activity_type'); + } + + // Store the activity statuses if needed + if (in_array('status', $this->activeTokens)) { + $this->activityStatuses = \CRM_Core_OptionGroup::values('activity_status'); + } + + // Store the campaigns if needed + if (in_array('campaign', $this->activeTokens)) { + $this->campaigns = \CRM_Campaign_BAO_Campaign::getCampaigns(); } + + return $prefetch; } /** * @inheritDoc */ public function evaluateToken(\Civi\Token\TokenRow $row, $entity, $field, $prefetch = NULL) { - $actionSearchResult = $row->context['actionSearchResult']; + // maps token name to api field + $mapping = [ + 'activity_id' => 'id', + ]; + + // Get ActivityID either from actionSearchResult (for scheduled reminders) if exists + $activityId = isset($row->context['actionSearchResult']->entityID) + ? $row->context['actionSearchResult']->entityID + : $row->context['activityId']; - if (in_array($field, array('activity_date_time'))) { - $row->tokens($entity, $field, \CRM_Utils_Date::customFormat($actionSearchResult->$field)); + $activity = (object) $prefetch['activity'][$activityId]; + + if (in_array($field, ['activity_date_time', 'created_date'])) { + $row->tokens($entity, $field, \CRM_Utils_Date::customFormat($activity->$field)); + } + elseif (isset($mapping[$field]) and (isset($activity->{$mapping[$field]}))) { + $row->tokens($entity, $field, $activity->{$mapping[$field]}); } - elseif (isset($actionSearchResult->$field)) { - $row->tokens($entity, $field, $actionSearchResult->$field); + elseif (in_array($field, ['activity_type'])) { + $row->tokens($entity, $field, $this->activityTypes[$activity->activity_type_id]); } - elseif ($cfID = \CRM_Core_BAO_CustomField::getKeyID($field)) { - $row->customToken($entity, $cfID, $actionSearchResult->entity_id); + elseif (in_array($field, ['status'])) { + $row->tokens($entity, $field, $this->activityStatuses[$activity->status_id]); } - else { - $row->tokens($entity, $field, ''); + elseif (in_array($field, ['campaign'])) { + $row->tokens($entity, $field, $this->campaigns[$activity->campaign_id]); + } + elseif (array_key_exists($field, $this->customFieldTokens)) { + $row->tokens($entity, $field, + isset($activity->$field) + ? \CRM_Core_BAO_CustomField::displayValue($activity->$field, $field) + : '' + ); + } + elseif (isset($activity->$field)) { + $row->tokens($entity, $field, $activity->$field); } } @@ -118,13 +231,27 @@ class CRM_Activity_Tokens extends \Civi\Token\AbstractTokenSubscriber { * @return array token name => token label */ protected function getBasicTokens() { - return [ - 'activity_id' => ts('Activity ID'), - 'activity_type' => ts('Activity Type'), - 'subject' => ts('Activity Subject'), - 'details' => ts('Activity Details'), - 'activity_date_time' => ts('Activity Date-Time'), - ]; + if (!isset($this->basicTokens)) { + $this->basicTokens = [ + 'activity_id' => ts('Activity ID'), + 'activity_type' => ts('Activity Type'), + 'subject' => ts('Activity Subject'), + 'details' => ts('Activity Details'), + 'activity_date_time' => ts('Activity Date-Time'), + 'activity_type_id' => ts('Activity Type ID'), + 'status' => ts('Activity Status'), + 'status_id' => ts('Activity Status ID'), + 'location' => ts('Activity Location'), + 'created_date' => ts('Activity Creation Date'), + 'duration' => ts('Activity Duration'), + 'campaign' => ts('Activity Campaign'), + 'campaign_id' => ts('Activity Campaign ID'), + ]; + if (array_key_exists('CiviCase', CRM_Core_Component::getEnabledComponents())) { + $this->basicTokens['case_id'] = ts('Activity Case ID'); + } + } + return $this->basicTokens; } /** @@ -132,7 +259,10 @@ class CRM_Activity_Tokens extends \Civi\Token\AbstractTokenSubscriber { * @return array token name => token label */ protected function getCustomFieldTokens() { - return CRM_Utils_Token::getCustomFieldTokens('Activity'); + if (!isset($this->customFieldTokens)) { + $this->customFieldTokens = \CRM_Utils_Token::getCustomFieldTokens('Activity'); + } + return $this->customFieldTokens; } } diff --git a/CRM/Core/Form/Task/PDFLetterCommon.php b/CRM/Core/Form/Task/PDFLetterCommon.php index 171d5c7846..af101d838e 100644 --- a/CRM/Core/Form/Task/PDFLetterCommon.php +++ b/CRM/Core/Form/Task/PDFLetterCommon.php @@ -147,7 +147,7 @@ class CRM_Core_Form_Task_PDFLetterCommon { $form->assign('useThisPageFormat', ts('Always use this Page Format with the new template?')); $form->assign('useSelectedPageFormat', ts('Should the new template always use the selected Page Format?')); - $form->assign('totalSelectedContacts', count($form->_contactIds)); + $form->assign('totalSelectedContacts', !is_null($form->_contactIds) ? count($form->_contactIds) : 0); $form->add('select', 'document_type', ts('Document Type'), CRM_Core_SelectValues::documentFormat()); @@ -235,6 +235,10 @@ class CRM_Core_Form_Task_PDFLetterCommon { /** * Handle the template processing part of the form + * + * @param array $formValues + * + * @return string $html_message */ public static function processTemplate(&$formValues) { $html_message = CRM_Utils_Array::value('html_message', $formValues); @@ -336,4 +340,46 @@ class CRM_Core_Form_Task_PDFLetterCommon { $message = implode($newLineOperators['p']['oper'], $htmlMsg); } + /** + * Render html from rows + * @param array $rows Array of \Civi\Token\TokenRow + * @param string $msgPart The name registered with the TokenProcessor + * @param string $formValues The values submitted through the form + * @return string + * $html if formValues['is_unit_test'] is true, otherwise outputs document to browser + * + */ + public static function renderFromRows($rows, $msgPart, $formValues) { + $html = array(); + foreach ($rows as $row) { + $html[] = $row->render($msgPart); + } + + if (!empty($formValues['is_unit_test'])) { + return $html; + } + + if (!empty($html)) { + $type = $formValues['document_type']; + + if ($type == 'pdf') { + CRM_Utils_PDF_Utils::html2pdf($html, "CiviLetter.pdf", FALSE, $formValues); + } + else { + CRM_Utils_PDF_Document::html2doc($html, "CiviLetter.$type", $formValues); + } + } + } + + /** + * List the available tokens + * @return array of token name => label + */ + public static function listTokens() { + $class = get_called_class(); + if (method_exists($class, 'createTokenProcessor')) { + return $class::createTokenProcessor()->listTokens(); + } + } + } diff --git a/templates/CRM/Activity/Form/Task/PDF.tpl b/templates/CRM/Activity/Form/Task/PDF.tpl new file mode 100644 index 0000000000..ce7d0ef69f --- /dev/null +++ b/templates/CRM/Activity/Form/Task/PDF.tpl @@ -0,0 +1,30 @@ +{* + +--------------------------------------------------------------------+ + | CiviCRM version 5 | + +--------------------------------------------------------------------+ + | Copyright CiviCRM LLC (c) 2004-2019 | + +--------------------------------------------------------------------+ + | This file is a part of CiviCRM. | + | | + | CiviCRM is free software; you can copy, modify, and distribute it | + | under the terms of the GNU Affero General Public License | + | Version 3, 19 November 2007 and the CiviCRM Licensing Exception. | + | | + | CiviCRM is distributed in the hope that it will be useful, but | + | WITHOUT ANY WARRANTY; without even the implied warranty of | + | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. | + | See the GNU Affero General Public License for more details. | + | | + | You should have received a copy of the GNU Affero General Public | + | License and the CiviCRM Licensing Exception along | + | with this program; if not, contact CiviCRM LLC | + | at info[AT]civicrm[DOT]org. If you have questions about the | + | GNU Affero General Public License or the licensing of CiviCRM, | + | see the CiviCRM license FAQ at http://civicrm.org/licensing | + +--------------------------------------------------------------------+ +*} +
+
{include file="CRM/Activity/Form/Task.tpl"}
+ {include file="CRM/Contact/Form/Task/PDFLetterCommon.tpl"} +
{include file="CRM/common/formButtons.tpl" location="bottom"}
+
diff --git a/tests/phpunit/CRM/Activity/Form/Task/PDFLetterCommonTest.php b/tests/phpunit/CRM/Activity/Form/Task/PDFLetterCommonTest.php new file mode 100644 index 0000000000..2096f7f482 --- /dev/null +++ b/tests/phpunit/CRM/Activity/Form/Task/PDFLetterCommonTest.php @@ -0,0 +1,98 @@ +useTransaction(TRUE); + parent::setUp(); + } + + public function tearDown() { + parent::tearDown(); + } + + public function testCreateDocumentBasicTokens() { + $activity = $this->activityCreate(); + $data = [ + ["Subject: {activity.subject}", "Subject: Discussion on warm beer"], + ["Date: {activity.activity_date_time}", "Date: " . \CRM_Utils_Date::customFormat(date('Ymd')) . " 12:00 AM"], + ["Duration: {activity.duration}", "Duration: 90"], + ["Location: {activity.location}", "Location: Baker Street"], + ["Details: {activity.details}", "Details: Lets schedule a meeting"], + ["Status ID: {activity.status_id}", "Status ID: 1"], + ["Status: {activity.status}", "Status: Scheduled"], + ["Activity Type ID: {activity.activity_type_id}", "Activity Type ID: 1"], + ["Activity Type: {activity.activity_type}", "Activity Type: Meeting"], + ["Activity ID: {activity.activity_id}", "Activity ID: " . $activity['id']], + ]; + $html_message = "\n" . implode("\n", CRM_Utils_Array::collect('0', $data)) . "\n"; + $output = CRM_Activity_Form_Task_PDFLetterCommon::createDocument([$activity['id']], $html_message, ['is_unit_test' => TRUE]); + + // Check some basic fields + foreach ($data as $line) { + $this->assertContains("\n" . $line[1] . "\n", $output[0]); + } + } + + public function testCreateDocumentCustomFieldTokens() { + // Set up custom group, and field + // returns custom_group_id, custom_field_id, custom_field_option_group_id, custom_field_group_options + $cg = $this->entityCustomGroupWithSingleStringMultiSelectFieldCreate("MyCustomField", "ActivityTest.php"); + $cf = 'custom_' . $cg['custom_field_id']; + foreach (array_keys($cg['custom_field_group_options']) as $option) { + $activity = $this->activityCreate([$cf => $option]); + $activities[] = [ + 'id' => $activity['id'], + 'option' => $option, + ]; + } + + $html_message = "Custom: {activity.$cf}"; + $activityIds = CRM_Utils_Array::collect('id', $activities); + $output = CRM_Activity_Form_Task_PDFLetterCommon::createDocument($activityIds, $html_message, ['is_unit_test' => TRUE]); + // Should have one row of output per activity + $this->assertEquals(count($activities), count($output)); + + // Check each line has the correct substitution + foreach ($output as $key => $line) { + $this->assertEquals($line, "Custom: " . $cg['custom_field_group_options'][$activities[$key]['option']]); + } + } + + public function testCreateDocumentSpecialTokens() { + $this->markTestIncomplete('special tokens not yet merged - see https://github.com/civicrm/civicrm-core/pull/12012'); + $activity = $this->activityCreate(); + $data = [ + ["Source First Name: {activity.source_first_name}", "Source First Name: Anthony"], + ["Target N First Name: {activity.target_N_first_name}", "Target N First Name: Julia"], + ["Target 0 First Name: {activity.target_0_first_name}", "Target 0 First Name: Julia"], + ["Target 1 First Name: {activity.target_1_first_name}", "Target 1 First Name: Julia"], + ["Target 2 First Name: {activity.target_2_first_name}", "Target 2 First Name: "], + ["Assignee N First Name: {activity.target_N_first_name}", "Assignee N First Name: Julia"], + ["Assignee 0 First Name: {activity.target_0_first_name}", "Assignee 0 First Name: Julia"], + ["Assignee 1 First Name: {activity.target_1_first_name}", "Assignee 1 First Name: Julia"], + ["Assignee 2 First Name: {activity.target_2_first_name}", "Assignee 2 First Name: "], + ["Assignee Count: {activity.assignees_count}", "Assignee Count: 1"], + ["Target Count: {activity.targets_count}", "Target Count: 1"], + ]; + $html_message = "\n" . implode("\n", CRM_Utils_Array::collect('0', $data)) . "\n"; + $output = CRM_Activity_Form_Task_PDFLetterCommon::createDocument([$activity['id']], $html_message, ['is_unit_test' => TRUE]); + + foreach ($data as $line) { + $this->assertContains("\n" . $line[1] . "\n", $output[0]); + } + } + + public function testCreateDocumentUnknownTokens() { + $activity = $this->activityCreate(); + $html_message = "Unknown token: {activity.something_unknown}"; + $output = CRM_Activity_Form_Task_PDFLetterCommon::createDocument([$activity['id']], $html_message, ['is_unit_test' => TRUE]); + // Unknown tokens should be left alone + $this->assertEquals($output[0], $html_message); + } + +} diff --git a/tests/phpunit/CiviTest/CiviUnitTestCase.php b/tests/phpunit/CiviTest/CiviUnitTestCase.php index 8a6419e12e..c27b73dadd 100644 --- a/tests/phpunit/CiviTest/CiviUnitTestCase.php +++ b/tests/phpunit/CiviTest/CiviUnitTestCase.php @@ -1359,8 +1359,7 @@ class CiviUnitTestCase extends PHPUnit\Framework\TestCase { $params = array_merge(array( 'subject' => 'Discussion on warm beer', 'activity_date_time' => date('Ymd'), - 'duration_hours' => 30, - 'duration_minutes' => 20, + 'duration' => 90, 'location' => 'Baker Street', 'details' => 'Lets schedule a meeting', 'status_id' => 1, -- 2.25.1