From ad8d1ce32fc72daae9afd0816504b2ab2b516525 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Ren=C3=A9=20Olivo?= Date: Thu, 15 Mar 2018 12:00:05 -0400 Subject: [PATCH] dev/core#107 Automatically add default assignees when creating new cases This feature allows users to define default assignees for each activity in a case type. When creating a new case contacts are assigned to activities automatically by following one of these rules: * By relationship to case client * The user creating the case * A specific contact * None (default) --- CRM/Case/XMLProcessor/Process.php | 121 ++++++++++++ CRM/Core/BAO/OptionValue.php | 15 +- CRM/Upgrade/Incremental/php/FiveFour.php | 36 ++++ ang/crmCaseType.js | 113 ++++++++---- ang/crmCaseType/timelineTable.html | 42 ++++- tests/karma/unit/crmCaseTypeSpec.js | 138 ++++++++++++++ .../CRM/Case/XMLProcessor/ProcessTest.php | 173 ++++++++++++++++++ .../phpunit/CRM/Core/BAO/OptionValueTest.php | 7 +- 8 files changed, 601 insertions(+), 44 deletions(-) create mode 100644 tests/phpunit/CRM/Case/XMLProcessor/ProcessTest.php diff --git a/CRM/Case/XMLProcessor/Process.php b/CRM/Case/XMLProcessor/Process.php index f125355ec9..9bd6e0b555 100644 --- a/CRM/Case/XMLProcessor/Process.php +++ b/CRM/Case/XMLProcessor/Process.php @@ -31,6 +31,8 @@ * @copyright CiviCRM LLC (c) 2004-2018 */ class CRM_Case_XMLProcessor_Process extends CRM_Case_XMLProcessor { + protected $defaultAssigneeOptionsValues = []; + /** * Run. * @@ -314,6 +316,7 @@ class CRM_Case_XMLProcessor_Process extends CRM_Case_XMLProcessor { /** * @param SimpleXMLElement $caseTypeXML + * * @return array symbolic activity-type names */ public function getDeclaredActivityTypes($caseTypeXML) { @@ -342,6 +345,7 @@ class CRM_Case_XMLProcessor_Process extends CRM_Case_XMLProcessor { /** * @param SimpleXMLElement $caseTypeXML + * * @return array symbolic relationship-type names */ public function getDeclaredRelationshipTypes($caseTypeXML) { @@ -474,6 +478,8 @@ AND a.is_deleted = 0 ); } + $activityParams['assignee_contact_id'] = $this->getDefaultAssigneeForActivity($activityParams, $activityTypeXML); + //parsing date to default preference format $params['activity_date_time'] = CRM_Utils_Date::processDate($params['activity_date_time']); @@ -568,6 +574,119 @@ AND a.is_deleted = 0 return TRUE; } + /** + * Return the default assignee contact for the activity. + * + * @param array $activityParams + * @param object $activityTypeXML + * + * @return int|null the ID of the default assignee contact or null if none. + */ + protected function getDefaultAssigneeForActivity($activityParams, $activityTypeXML) { + if (!isset($activityTypeXML->default_assignee_type)) { + return NULL; + } + + $defaultAssigneeOptionsValues = $this->getDefaultAssigneeOptionValues(); + + switch ($activityTypeXML->default_assignee_type) { + case $defaultAssigneeOptionsValues['BY_RELATIONSHIP']: + return $this->getDefaultAssigneeByRelationship($activityParams, $activityTypeXML); + + break; + case $defaultAssigneeOptionsValues['SPECIFIC_CONTACT']: + return $this->getDefaultAssigneeBySpecificContact($activityTypeXML); + + break; + case $defaultAssigneeOptionsValues['USER_CREATING_THE_CASE']: + return $activityParams['source_contact_id']; + + break; + case $defaultAssigneeOptionsValues['NONE']: + default: + return NULL; + } + } + + /** + * Fetches and caches the activity's default assignee options. + * + * @return array + */ + protected function getDefaultAssigneeOptionValues() { + if (!empty($this->defaultAssigneeOptionsValues)) { + return $this->defaultAssigneeOptionsValues; + } + + $defaultAssigneeOptions = civicrm_api3('OptionValue', 'get', [ + 'option_group_id' => 'activity_default_assignee', + 'options' => [ 'limit' => 0 ] + ]); + + foreach ($defaultAssigneeOptions['values'] as $option) { + $this->defaultAssigneeOptionsValues[$option['name']] = $option['value']; + } + + return $this->defaultAssigneeOptionsValues; + } + + /** + * Returns the default assignee for the activity by searching for the target's + * contact relationship type defined in the activity's details. + * + * @param array $activityParams + * @param object $activityTypeXML + * + * @return int|null the ID of the default assignee contact or null if none. + */ + protected function getDefaultAssigneeByRelationship($activityParams, $activityTypeXML) { + if (!isset($activityTypeXML->default_assignee_relationship)) { + return NULL; + } + + $targetContactId = is_array($activityParams['target_contact_id']) + ? CRM_Utils_Array::first($activityParams['target_contact_id']) + : $activityParams['target_contact_id']; + + $relationships = civicrm_api3('Relationship', 'get', [ + 'contact_id_b' => $targetContactId, + 'relationship_type_id.name_b_a' => (string) $activityTypeXML->default_assignee_relationship, + 'is_active' => 1, + 'sequential' => 1, + ]); + + if ($relationships['count']) { + return $relationships['values'][0]['contact_id_a']; + } + else { + return NULL; + } + } + + /** + * Returns the activity's default assignee for a specific contact if the contact exists, + * otherwise returns null. + * + * @param object $activityTypeXML + * + * @return int|null + */ + protected function getDefaultAssigneeBySpecificContact($activityTypeXML) { + if (!$activityTypeXML->default_assignee_contact) { + return NULL; + } + + $contact = civicrm_api3('Contact', 'get', [ + 'id' => $activityTypeXML->default_assignee_contact + ]); + + if ($contact['count'] == 1) { + return $activityTypeXML->default_assignee_contact; + } + + return NULL; + } + /** * @param $activitySetsXML * @@ -617,6 +736,7 @@ AND a.is_deleted = 0 /** * @param string $caseType + * * @return array<\Civi\CCase\CaseChangeListener> */ public function getListeners($caseType) { @@ -662,6 +782,7 @@ AND a.is_deleted = 0 * @param string $settingKey * @param string $xmlTag * @param mixed $default + * * @return int */ private function getBoolSetting($settingKey, $xmlTag, $default = 0) { diff --git a/CRM/Core/BAO/OptionValue.php b/CRM/Core/BAO/OptionValue.php index 2e621cf073..cfb4b45dce 100644 --- a/CRM/Core/BAO/OptionValue.php +++ b/CRM/Core/BAO/OptionValue.php @@ -547,16 +547,23 @@ class CRM_Core_BAO_OptionValue extends CRM_Core_DAO_OptionValue { * that an option value exists, without hitting an error if it already exists. * * This is sympathetic to sites who might pre-add it. + * + * @param array $params the option value attributes. + * @return array the option value attributes. */ public static function ensureOptionValueExists($params) { - $existingValues = civicrm_api3('OptionValue', 'get', array( + $result = civicrm_api3('OptionValue', 'get', array( 'option_group_id' => $params['option_group_id'], 'name' => $params['name'], - 'return' => 'id', + 'return' => ['id', 'value'], + 'sequential' => 1, )); - if (!$existingValues['count']) { - civicrm_api3('OptionValue', 'create', $params); + + if (!$result['count']) { + $result = civicrm_api3('OptionValue', 'create', $params); } + + return CRM_Utils_Array::first($result['values']); } } diff --git a/CRM/Upgrade/Incremental/php/FiveFour.php b/CRM/Upgrade/Incremental/php/FiveFour.php index 16d3e73e96..9c4082c0d7 100644 --- a/CRM/Upgrade/Incremental/php/FiveFour.php +++ b/CRM/Upgrade/Incremental/php/FiveFour.php @@ -73,6 +73,42 @@ class CRM_Upgrade_Incremental_php_FiveFour extends CRM_Upgrade_Incremental_Base 'civicrm_uf_group', 'add_cancel_button', "tinyint DEFAULT '1' COMMENT 'Should a Cancel button be included in this Profile form.'"); $this->addTask('Add location_id if missing to group_contact table (affects some older installs CRM-20711)', 'addColumn', 'civicrm_group_contact', 'location_id', "int(10) unsigned DEFAULT NULL COMMENT 'Optional location to associate with this membership'"); + $this->addTask('dev/core#107 - Add Activity\'s default assignee options', 'addActivityDefaultAssigneeOptions'); + } + + /** + * This task adds the default assignee option values that can be selected when + * creating or editing a new workflow's activity. + * + * @return bool + */ + public static function addActivityDefaultAssigneeOptions() { + // Add option group for activity default assignees: + CRM_Core_BAO_OptionGroup::ensureOptionGroupExists(array( + 'name' => 'activity_default_assignee', + 'title' => ts('Activity default assignee'), + 'is_reserved' => 1, + )); + + // Add option values for activity default assignees: + $options = array( + array('name' => 'NONE', 'label' => ts('None'), 'is_default' => 1), + array('name' => 'BY_RELATIONSHIP', 'label' => ts('By relationship to case client')), + array('name' => 'SPECIFIC_CONTACT', 'label' => ts('Specific contact')), + array('name' => 'USER_CREATING_THE_CASE', 'label' => ts('User creating the case')), + ); + + foreach ($options as $option) { + CRM_Core_BAO_OptionValue::ensureOptionValueExists(array( + 'option_group_id' => 'activity_default_assignee', + 'name' => $option['name'], + 'label' => $option['label'], + 'is_default' => CRM_Utils_Array::value('is_default', $option, 0), + 'is_active' => TRUE, + )); + } + + return TRUE; } /* diff --git a/ang/crmCaseType.js b/ang/crmCaseType.js index 0f74d410f8..d244ba2aa0 100644 --- a/ang/crmCaseType.js +++ b/ang/crmCaseType.js @@ -67,6 +67,13 @@ limit: 0 } }]; + reqs.defaultAssigneeTypes = ['OptionValue', 'get', { + option_group_id: 'activity_default_assignee', + sequential: 1, + options: { + limit: 0 + } + }]; reqs.relTypes = ['RelationshipType', 'get', { sequential: 1, options: { @@ -230,43 +237,78 @@ }); crmCaseType.controller('CaseTypeCtrl', function($scope, crmApi, apiCalls) { - // CRM_Case_XMLProcessor::REL_TYPE_CNAME - var REL_TYPE_CNAME = CRM.crmCaseType.REL_TYPE_CNAME, - - ts = $scope.ts = CRM.ts(null); + var REL_TYPE_CNAME, defaultAssigneeDefaultValue, ts; + + (function init () { + // CRM_Case_XMLProcessor::REL_TYPE_CNAME + REL_TYPE_CNAME = CRM.crmCaseType.REL_TYPE_CNAME; + + ts = $scope.ts = CRM.ts(null); + $scope.locks = { caseTypeName: true, activitySetName: true }; + $scope.workflows = { timeline: 'Timeline', sequence: 'Sequence' }; + defaultAssigneeDefaultValue = _.find(apiCalls.defaultAssigneeTypes.values, { is_default: '1' }) || {}; + + storeApiCallsResults(); + initCaseType(); + initCaseTypeDefinition(); + initSelectedStatuses(); + })(); + + /// Stores the api calls results in the $scope object + function storeApiCallsResults() { + $scope.activityStatuses = apiCalls.actStatuses.values; + $scope.caseStatuses = _.indexBy(apiCalls.caseStatuses.values, 'name'); + $scope.activityTypes = _.indexBy(apiCalls.actTypes.values, 'name'); + $scope.activityTypeOptions = _.map(apiCalls.actTypes.values, formatActivityTypeOption); + $scope.defaultAssigneeTypes = apiCalls.defaultAssigneeTypes.values; + $scope.relationshipTypeOptions = _.map(apiCalls.relTypes.values, function(type) { + return {id: type[REL_TYPE_CNAME], text: type.label_b_a}; + }); + // stores the default assignee values indexed by their option name: + $scope.defaultAssigneeTypeValues = _.chain($scope.defaultAssigneeTypes) + .indexBy('name').mapValues('value').value(); + } - $scope.activityStatuses = apiCalls.actStatuses.values; - $scope.caseStatuses = _.indexBy(apiCalls.caseStatuses.values, 'name'); - $scope.activityTypes = _.indexBy(apiCalls.actTypes.values, 'name'); - $scope.activityTypeOptions = _.map(apiCalls.actTypes.values, formatActivityTypeOption); - $scope.relationshipTypeOptions = _.map(apiCalls.relTypes.values, function(type) { - return {id: type[REL_TYPE_CNAME], text: type.label_b_a}; - }); - $scope.locks = {caseTypeName: true, activitySetName: true}; + /// initializes the case type object + function initCaseType() { + var isNewCaseType = !apiCalls.caseType; - $scope.workflows = { - 'timeline': 'Timeline', - 'sequence': 'Sequence' - }; + if (isNewCaseType) { + $scope.caseType = _.cloneDeep(newCaseTypeTemplate); + } else { + $scope.caseType = apiCalls.caseType; + } + } - $scope.caseType = apiCalls.caseType ? apiCalls.caseType : _.cloneDeep(newCaseTypeTemplate); - $scope.caseType.definition = $scope.caseType.definition || []; - $scope.caseType.definition.activityTypes = $scope.caseType.definition.activityTypes || []; - $scope.caseType.definition.activitySets = $scope.caseType.definition.activitySets || []; - _.each($scope.caseType.definition.activitySets, function (set) { - _.each(set.activityTypes, function (type, name) { - type.label = $scope.activityTypes[type.name].label; + /// initializes the case type definition object + function initCaseTypeDefinition() { + $scope.caseType.definition = $scope.caseType.definition || []; + $scope.caseType.definition.activityTypes = $scope.caseType.definition.activityTypes || []; + $scope.caseType.definition.activitySets = $scope.caseType.definition.activitySets || []; + $scope.caseType.definition.caseRoles = $scope.caseType.definition.caseRoles || []; + $scope.caseType.definition.statuses = $scope.caseType.definition.statuses || []; + $scope.caseType.definition.timelineActivityTypes = $scope.caseType.definition.timelineActivityTypes || []; + + _.each($scope.caseType.definition.activitySets, function (set) { + _.each(set.activityTypes, function (type, name) { + var isDefaultAssigneeTypeUndefined = _.isUndefined(type.default_assignee_type); + type.label = $scope.activityTypes[type.name].label; + + if (isDefaultAssigneeTypeUndefined) { + type.default_assignee_type = defaultAssigneeDefaultValue.value; + } + }); }); - }); - $scope.caseType.definition.caseRoles = $scope.caseType.definition.caseRoles || []; - $scope.caseType.definition.statuses = $scope.caseType.definition.statuses || []; + } - $scope.caseType.definition.timelineActivityTypes = $scope.caseType.definition.timelineActivityTypes || []; + /// initializes the selected statuses + function initSelectedStatuses() { + $scope.selectedStatuses = {}; - $scope.selectedStatuses = {}; - _.each(apiCalls.caseStatuses.values, function (status) { - $scope.selectedStatuses[status.name] = !$scope.caseType.definition.statuses.length || $scope.caseType.definition.statuses.indexOf(status.name) > -1; - }); + _.each(apiCalls.caseStatuses.values, function (status) { + $scope.selectedStatuses[status.name] = !$scope.caseType.definition.statuses.length || $scope.caseType.definition.statuses.indexOf(status.name) > -1; + }); + } $scope.addActivitySet = function(workflow) { var activitySet = {}; @@ -296,7 +338,8 @@ status: 'Scheduled', reference_activity: 'Open Case', reference_offset: '1', - reference_select: 'newest' + reference_select: 'newest', + default_assignee_type: $scope.defaultAssigneeTypeValues.NONE }; activitySet.activityTypes.push(activity); if(typeof activitySet.timeline !== "undefined" && activitySet.timeline == "1") { @@ -349,6 +392,12 @@ } }; + /// Clears the activity's default assignee values for relationship and contact + $scope.clearActivityDefaultAssigneeValues = function(activity) { + activity.default_assignee_relationship = null; + activity.default_assignee_contact = null; + }; + /// Add a new role $scope.addRole = function(roles, roleName) { var names = _.pluck($scope.caseType.definition.caseRoles, 'name'); diff --git a/ang/crmCaseType/timelineTable.html b/ang/crmCaseType/timelineTable.html index 0dba5c98bd..fd24b03bfb 100644 --- a/ang/crmCaseType/timelineTable.html +++ b/ang/crmCaseType/timelineTable.html @@ -11,6 +11,7 @@ Required vars: activitySet {{ts('Reference')}} {{ts('Offset')}} {{ts('Select')}} + {{ts('Default assignee')}} @@ -21,13 +22,13 @@ Required vars: activitySet - - {{ activity.label }} + + {{activity.label}} @@ -55,12 +56,41 @@ Required vars: activitySet + + + +

+ +

+ +

+ +

+ - + defaultAssigneeOptionsValues = []; + $this->assigneeContactId = $this->individualCreate(); + $this->targetContactId = $this->individualCreate(); + + $this->setUpDefaultAssigneeOptions(); + $this->setUpRelationship(); + + $activityTypeXml = 'Open Case'; + $this->activityTypeXml = new SimpleXMLElement($activityTypeXml); + $this->params = [ + 'activity_date_time' => date('Ymd'), + 'caseID' => $this->caseTypeId, + 'clientID' => $this->targetContactId, + 'creatorID' => $this->_loggedInUser, + ]; + + $this->process = new CRM_Case_XMLProcessor_Process(); + } + + /** + * Adds the default assignee group and options to the test database. + * It also stores the IDs of the options in an index. + */ + protected function setUpDefaultAssigneeOptions() { + $options = [ + 'NONE', 'BY_RELATIONSHIP', 'SPECIFIC_CONTACT', 'USER_CREATING_THE_CASE' + ]; + + CRM_Core_BAO_OptionGroup::ensureOptionGroupExists([ + 'name' => 'activity_default_assignee' + ]); + + foreach ($options as $option) { + $optionValue = CRM_Core_BAO_OptionValue::ensureOptionValueExists([ + 'option_group_id' => 'activity_default_assignee', + 'name' => $option, + 'label' => $option + ]); + + $this->defaultAssigneeOptionsValues[$option] = $optionValue['value']; + } + } + + /** + * Adds a relationship between the activity's target contact and default assignee. + */ + protected function setUpRelationship() { + $this->assignedRelationshipType = 'Instructor of'; + $this->unassignedRelationshipType = 'Employer of'; + + $assignedRelationshipTypeId = $this->relationshipTypeCreate([ + 'contact_type_a' => 'Individual', + 'contact_type_b' => 'Individual', + 'name_a_b' => 'Pupil of', + 'name_b_a' => $this->assignedRelationshipType, + ]); + $this->relationshipTypeCreate([ + 'name_a_b' => 'Employee of', + 'name_b_a' => $this->unassignedRelationshipType, + ]); + $this->callAPISuccess('Relationship', 'create', [ + 'contact_id_a' => $this->assigneeContactId, + 'contact_id_b' => $this->targetContactId, + 'relationship_type_id' => $assignedRelationshipTypeId + ]); + } + + /** + * Tests the creation of activities with default assignee by relationship. + */ + public function testCreateActivityWithDefaultContactByRelationship() { + $this->activityTypeXml->default_assignee_type = $this->defaultAssigneeOptionsValues['BY_RELATIONSHIP']; + $this->activityTypeXml->default_assignee_relationship = $this->assignedRelationshipType; + + $this->process->createActivity($this->activityTypeXml, $this->params); + $this->assertActivityAssignedToContactExists($this->assigneeContactId); + } + + /** + * Tests the creation of activities with default assignee by relationship, + * but the target contact doesn't have any relationship of the selected type. + */ + public function testCreateActivityWithDefaultContactByRelationButTheresNoRelationship() { + $this->activityTypeXml->default_assignee_type = $this->defaultAssigneeOptionsValues['BY_RELATIONSHIP']; + $this->activityTypeXml->default_assignee_relationship = $this->unassignedRelationshipType; + + $this->process->createActivity($this->activityTypeXml, $this->params); + $this->assertActivityAssignedToContactExists(NULL); + } + + /** + * Tests the creation of activities with default assignee set to a specific contact. + */ + public function testCreateActivityAssignedToSpecificContact() { + $this->activityTypeXml->default_assignee_type = $this->defaultAssigneeOptionsValues['SPECIFIC_CONTACT']; + $this->activityTypeXml->default_assignee_contact = $this->assigneeContactId; + + $this->process->createActivity($this->activityTypeXml, $this->params); + $this->assertActivityAssignedToContactExists($this->assigneeContactId); + } + + /** + * Tests the creation of activities with default assignee set to a specific contact, + * but the contact does not exist. + */ + public function testCreateActivityAssignedToNonExistantSpecificContact() { + $this->activityTypeXml->default_assignee_type = $this->defaultAssigneeOptionsValues['SPECIFIC_CONTACT']; + $this->activityTypeXml->default_assignee_contact = 987456321; + + $this->process->createActivity($this->activityTypeXml, $this->params); + $this->assertActivityAssignedToContactExists(NULL); + } + + /** + * Tests the creation of activities with the default assignee being the one + * creating the case's activity. + */ + public function testCreateActivityAssignedToUserCreatingTheCase() { + $this->activityTypeXml->default_assignee_type = $this->defaultAssigneeOptionsValues['USER_CREATING_THE_CASE']; + + $this->process->createActivity($this->activityTypeXml, $this->params); + $this->assertActivityAssignedToContactExists($this->_loggedInUser); + } + + /** + * Tests the creation of activities when the default assignee is set to NONE. + */ + public function testCreateActivityAssignedNoUser() { + $this->activityTypeXml->default_assignee_type = $this->defaultAssigneeOptionsValues['NONE']; + + $this->process->createActivity($this->activityTypeXml, $this->params); + $this->assertActivityAssignedToContactExists(NULL); + } + + /** + * Tests the creation of activities when the default assignee is set to NONE. + */ + public function testCreateActivityWithNoDefaultAssigneeOption() { + $this->process->createActivity($this->activityTypeXml, $this->params); + $this->assertActivityAssignedToContactExists(NULL); + } + + /** + * Asserts that an activity was created where the assignee was the one related + * to the target contact. + * + * @param int|null $assigneeContactId the ID of the expected assigned contact or NULL if expected to be empty. + */ + protected function assertActivityAssignedToContactExists($assigneeContactId) { + $expectedContact = $assigneeContactId === NULL ? [] : [$assigneeContactId]; + $result = $this->callAPISuccess('Activity', 'get', [ + 'target_contact_id' => $this->targetContactId, + 'return' => ['assignee_contact_id'] + ]); + $activity = CRM_Utils_Array::first($result['values']); + + $this->assertNotNull($activity, 'Target contact has no activities assigned to them'); + $this->assertEquals($expectedContact, $activity['assignee_contact_id'], 'Activity is not assigned to expected contact'); + } + +} diff --git a/tests/phpunit/CRM/Core/BAO/OptionValueTest.php b/tests/phpunit/CRM/Core/BAO/OptionValueTest.php index 23fbb36169..c85e37be69 100644 --- a/tests/phpunit/CRM/Core/BAO/OptionValueTest.php +++ b/tests/phpunit/CRM/Core/BAO/OptionValueTest.php @@ -72,12 +72,15 @@ class CRM_Core_BAO_OptionValueTest extends CiviUnitTestCase { * decision to disable it & leaving it in that state. */ public function testEnsureOptionValueExistsDisabled() { - CRM_Core_BAO_OptionValue::ensureOptionValueExists(array('name' => 'Crashed', 'option_group_id' => 'contribution_status', 'is_active' => 0)); + $optionValue = CRM_Core_BAO_OptionValue::ensureOptionValueExists(array('name' => 'Crashed', 'option_group_id' => 'contribution_status', 'is_active' => 0)); $value = $this->callAPISuccessGetSingle('OptionValue', array('name' => 'Crashed', 'option_group_id' => 'contribution_status')); $this->assertEquals(0, $value['is_active']); - CRM_Core_BAO_OptionValue::ensureOptionValueExists(array('name' => 'Crashed', 'option_group_id' => 'contribution_status')); + $this->assertEquals($value['id'], $optionValue['id']); + + $optionValue = CRM_Core_BAO_OptionValue::ensureOptionValueExists(array('name' => 'Crashed', 'option_group_id' => 'contribution_status')); $value = $this->callAPISuccessGetSingle('OptionValue', array('name' => 'Crashed', 'option_group_id' => 'contribution_status')); $this->assertEquals(0, $value['is_active']); + $this->assertEquals($value['id'], $optionValue['id']); } } -- 2.25.1