Fix contribution import mapping validation, add test
authorEileen McNaughton <emcnaughton@wikimedia.org>
Mon, 5 Sep 2022 02:14:17 +0000 (14:14 +1200)
committerEileen McNaughton <emcnaughton@wikimedia.org>
Mon, 5 Sep 2022 07:06:12 +0000 (19:06 +1200)
CRM/Contribute/Import/Form/MapField.php
CRM/Import/Parser.php
tests/phpunit/CRM/Contribute/Import/Parser/ContributionTest.php

index 4ba33999ea1316aa3f2197c96e332a15f6ef40f2..8188e3cd5ab343beefe4d6a1d46420521092b4cd 100644 (file)
  */
 class CRM_Contribute_Import_Form_MapField extends CRM_Import_Form_MapField {
 
-  /**
-   * Check if required fields are present.
-   *
-   * @param CRM_Contribute_Import_Form_MapField $self
-   * @param string $contactORContributionId
-   * @param array $importKeys
-   * @param array $errors
-   * @param int $weightSum
-   * @param int $threshold
-   * @param string $fieldMessage
-   *
-   * @return array
-   */
-  protected static function checkRequiredFields($self, string $contactORContributionId, array $importKeys, array $errors, int $weightSum, $threshold, string $fieldMessage): array {
-    // FIXME: should use the schema titles, not redeclare them
-    $requiredFields = [
-      'contribution_contact_id' => ts('Contact ID'),
-    ];
-
-    foreach ($requiredFields as $field => $title) {
-      if (!in_array($field, $importKeys)) {
-        if ($field == 'contribution_contact_id') {
-          if (!($weightSum >= $threshold || in_array('external_identifier', $importKeys))
-          ) {
-            $errors['_qf_default'] .= ts('Missing required contact matching fields.') . " $fieldMessage " . ts('(Sum of all weights should be greater than or equal to threshold: %1).', [1 => $threshold]) . '<br />';
-          }
-        }
-      }
-    }
-    return $errors;
-  }
-
   /**
    * Set variables up before form is built.
    */
@@ -162,41 +130,25 @@ class CRM_Contribute_Import_Form_MapField extends CRM_Import_Form_MapField {
    *   list of errors to be posted back to the form
    */
   public static function formRule($fields, $files, $self) {
-    $errors = [];
-    $fieldMessage = NULL;
-    $contactORContributionId = $self->isUpdateExisting() ? 'contribution_id' : 'contribution_contact_id';
-    if (!array_key_exists('savedMapping', $fields)) {
-      $importKeys = [];
-      foreach ($fields['mapper'] as $mapperPart) {
-        $importKeys[] = $mapperPart[0];
-      }
-
-      $params = [
-        'used' => 'Unsupervised',
-        'contact_type' => $self->getContactType(),
-      ];
-      [$ruleFields, $threshold] = CRM_Dedupe_BAO_DedupeRuleGroup::dedupeRuleFieldsWeight($params);
-      $weightSum = 0;
-      foreach ($importKeys as $key => $val) {
-        if (array_key_exists($val, $ruleFields)) {
-          $weightSum += $ruleFields[$val];
-        }
-      }
-      foreach ($ruleFields as $field => $weight) {
-        $fieldMessage .= ' ' . $field . '(weight ' . $weight . ')';
-      }
-      try {
-        $parser = $self->getParser();
-        $parser->validateMapping($fields['mapper']);
-      }
-      catch (CRM_Core_Exception $e) {
-        $errors['_qf_default'] = $e->getMessage();
-      }
+    $mapperError = [];
+    try {
+      $parser = $self->getParser();
+      $rule = $parser->getDedupeRule($self->getContactType());
       if (!$self->isUpdateExisting()) {
-        $errors = self::checkRequiredFields($self, $contactORContributionId, $importKeys, $errors, $weightSum, $threshold, $fieldMessage);
+        $missingDedupeFields = $self->validateDedupeFieldsSufficientInMapping($rule, $fields['mapper']);
+        if ($missingDedupeFields) {
+          $mapperError[] = $missingDedupeFields;
+        }
       }
+      $parser->validateMapping($fields['mapper']);
     }
-    return !empty($errors) ? $errors : TRUE;
+    catch (CRM_Core_Exception $e) {
+      $mapperError[] = $e->getMessage();
+    }
+    if (!empty($mapperError)) {
+      return ['_qf_default' => implode('<br/>', $mapperError)];
+    }
+    return TRUE;
   }
 
   /**
@@ -253,4 +205,33 @@ class CRM_Contribute_Import_Form_MapField extends CRM_Import_Form_MapField {
     return $defaults;
   }
 
+  /**
+   * Validate the the mapped fields contain enough to meet the dedupe rule lookup requirements.
+   *
+   * @param array $rule
+   * @param array $mapper
+   *
+   * @return string|false
+   *   Error string if insufficient.
+   */
+  protected function validateDedupeFieldsSufficientInMapping(array $rule, array $mapper): ?string {
+    $threshold = $rule['threshold'];
+    $ruleFields = $rule['fields'];
+    $weightSum = 0;
+    foreach ($mapper as $mapping) {
+      if ($mapping[0] === 'external_identifier') {
+        // It is enough to have external identifier mapped.
+        $weightSum = $threshold;
+        break;
+      }
+      if (array_key_exists($mapping[0], $ruleFields)) {
+        $weightSum += $ruleFields[$mapping[0]];
+      }
+    }
+    if ($weightSum < $threshold) {
+      return $rule['rule_message'];
+    }
+    return NULL;
+  }
+
 }
index 4512a9ccf7a117d83dad4753347ec8d5c008487b..d780929d6089a23ee6094ce0ca4a55b1ae2d0298 100644 (file)
@@ -12,6 +12,7 @@
 use Civi\Api4\Campaign;
 use Civi\Api4\Contact;
 use Civi\Api4\CustomField;
+use Civi\Api4\DedupeRule;
 use Civi\Api4\DedupeRuleGroup;
 use Civi\Api4\Event;
 use Civi\Api4\UserJob;
@@ -279,6 +280,8 @@ abstract class CRM_Import_Parser implements UserJobInterface {
    */
   private $_fields;
 
+  private $dedupeRules = [];
+
   /**
    * Metadata for all available fields, keyed by unique name.
    *
@@ -311,42 +314,24 @@ abstract class CRM_Import_Parser implements UserJobInterface {
    * @param string $contactType
    *
    * @return array[]
-   * @throws \CRM_Core_Exception
    */
   protected function getContactFields(string $contactType): array {
     $contactFields = CRM_Contact_BAO_Contact::importableFields($contactType, NULL);
+    $dedupeFields = $this->getDedupeFields($contactType);
 
-    // Using new Dedupe rule.
-    $ruleParams = [
-      'contact_type' => $contactType,
-      'used' => 'Unsupervised',
-    ];
-    $fieldsArray = CRM_Dedupe_BAO_DedupeRule::dedupeRuleFields($ruleParams);
-    $tmpContactField = [];
-    if (is_array($fieldsArray)) {
-      foreach ($fieldsArray as $value) {
-        //skip if there is no dupe rule
-        if ($value === 'none') {
-          continue;
-        }
-        $customFieldId = CRM_Core_DAO::getFieldValue('CRM_Core_DAO_CustomField',
-          $value,
-          'id',
-          'column_name'
-        );
-        $value = trim($customFieldId ? 'custom_' . $customFieldId : $value);
-        $tmpContactField[$value] = $contactFields[$value];
-        $title = $tmpContactField[$value]['title'] . ' ' . ts('(match to contact)');
-        $tmpContactField[$value]['title'] = $title;
-        // When we switch to apiv4 getfields this will already be set for
-        // all fields (including custom which it isn't yet)
-        $tmpContactField[$value]['entity'] = 'Contact';
+    $contactFieldsForContactLookup = [];
+    foreach ($dedupeFields as $fieldName => $dedupeField) {
+      if (!isset($contactFields[$fieldName])) {
+        continue;
       }
+      $contactFieldsForContactLookup[$fieldName] = $contactFields[$fieldName];
+      $contactFieldsForContactLookup[$fieldName]['title'] . ' ' . ts('(match to contact)');
+      $contactFieldsForContactLookup[$fieldName]['entity'] = 'Contact';
     }
 
-    $tmpContactField['external_identifier'] = $contactFields['external_identifier'];
-    $tmpContactField['external_identifier']['title'] = $contactFields['external_identifier']['title'] . ' ' . ts('(match to contact)');
-    return $tmpContactField;
+    $contactFieldsForContactLookup['external_identifier'] = $contactFields['external_identifier'];
+    $contactFieldsForContactLookup['external_identifier']['title'] = $contactFields['external_identifier']['title'] . ' ' . ts('(match to contact)');
+    return $contactFieldsForContactLookup;
   }
 
   /**
@@ -960,9 +945,13 @@ abstract class CRM_Import_Parser implements UserJobInterface {
    * @param string $contactType
    *
    * @return string
+   * @throws \CRM_Core_Exception
    */
   protected function getDefaultRuleForContactType(string $contactType): string {
-    return $contactType . '.Unsupervised';
+    return DedupeRuleGroup::get(FALSE)
+      ->addWhere('contact_type', '=', $contactType)
+      ->addWhere('used', '=', 'Unsupervised')
+      ->addSelect('id', 'name')->execute()->first()['name'];
   }
 
   /**
@@ -981,6 +970,72 @@ abstract class CRM_Import_Parser implements UserJobInterface {
       ->execute()->first()['name'];
   }
 
+  /**
+   * Get the dedupe rule, including an array of fields with weights.
+   *
+   * The fields are keyed according to the metadata.
+   *
+   * @param string $contactType
+   * @param string|null $name
+   *
+   * @return array
+   * @noinspection PhpUnhandledExceptionInspection
+   * @noinspection PhpDocMissingThrowsInspection
+   */
+  public function getDedupeRule(string $contactType, ?string $name = NULL): array {
+    if (!$name) {
+      $name = $this->getDefaultRuleForContactType($contactType);
+    }
+    if (empty($this->dedupeRules[$name])) {
+      $this->dedupeRules[$name] = (array) DedupeRuleGroup::get(FALSE)
+        ->addWhere('name', '=', $name)
+        ->addSelect('threshold', 'name', 'id', 'title', 'contact_type')
+        ->execute()->first();
+      $fields = [];
+      $this->dedupeRules[$name]['rule_message'] = $fieldMessage = '';
+      // Now we add the fields in a format like ['first_name' => 6, 'custom_8' => 9]
+      // The number is the weight and we add both api three & four style fields so the
+      // array can be used for converted & unconverted.
+      $ruleFields = DedupeRule::get(FALSE)
+        ->addWhere('dedupe_rule_group_id', '=', $this->dedupeRules[$name]['id'])
+        ->addSelect('id', 'rule_table', 'rule_field', 'rule_weight')->execute();
+      foreach ($ruleFields as $ruleField) {
+        $fieldMessage .= ' ' . $ruleField['rule_field'] . '(weight ' . $ruleField['rule_weight'] . ')';
+        if ($ruleField['rule_table'] === 'civicrm_contact') {
+          $fields[$ruleField['rule_field']] = $ruleField['rule_weight'];
+        }
+        // If not a contact field we add both api variants of fields.
+        elseif ($ruleField['rule_table'] === 'civicrm_phone') {
+          // Actually the dedupe rule for phone should always be phone_numeric. so checking 'phone' is probably unncessary
+          if (in_array($ruleField['rule_field'], ['phone', 'phone_numeric'], TRUE)) {
+            $fields['phone'] = $ruleField['rule_weight'];
+            $fields['phone_primary.phone'] = $ruleField['rule_weight'];
+          }
+        }
+        elseif ($ruleField['rule_field'] === 'email') {
+          $fields['email'] = $ruleField['rule_weight'];
+          $fields['email_primary.email'] = $ruleField['rule_weight'];
+        }
+        elseif ($ruleField['rule_table'] === 'civicrm_address') {
+          $fields[$ruleField['rule_field']] = $ruleField['rule_weight'];
+          $fields['address_primary' . $ruleField['rule_field']] = $ruleField['rule_weight'];
+        }
+        else {
+          // At this point it must be a custom field.
+          $customField = CustomField::get(FALSE)->addWhere('custom_group_id.table_name', '=', $ruleField['rule_table'])
+            ->addWhere('column_name', '=', $ruleField['rule_field'])
+            ->addSelect('id', 'name', 'custom_group_id.name')->execute()->first();
+          $fields['custom_' . $customField['id']] = $ruleField['rule_weight'];
+          $fields[$customField['custom_group_id.name'] . '.' . $customField['name']] = $ruleField['rule_weight'];
+        }
+      }
+      $this->dedupeRules[$name]['rule_message'] = ts('Missing required contact matching fields.') . " $fieldMessage " . ts('(Sum of all weights should be greater than or equal to threshold: %1).', [1 => $this->dedupeRules[$name]['threshold']]) . '<br />';
+
+      $this->dedupeRules[$name]['fields'] = $fields;
+    }
+    return $this->dedupeRules[$name];
+  }
+
   /**
    * This function adds the contact variable in $values to the
    * parameter list $params.  For most cases, $values should have length 1.  If
@@ -994,6 +1049,7 @@ abstract class CRM_Import_Parser implements UserJobInterface {
    *
    * @return bool|CRM_Utils_Error
    *
+   * @throws \CRM_Core_Exception
    * @deprecated
    */
   private function _civicrm_api3_deprecated_add_formatted_param(&$values, &$params) {
@@ -1695,6 +1751,9 @@ abstract class CRM_Import_Parser implements UserJobInterface {
 
   /**
    * Get the field metadata for fields to be be offered to match the contact.
+   * @todo this is very similar to getContactFields - this is called by participant and that
+   * by contribution import. They should be reconciled - but note that one is being fixed
+   * to support api4 style fields on contribution import - with this import to follow.
    *
    * @return array
    * @noinspection PhpDocMissingThrowsInspection
@@ -2275,4 +2334,16 @@ abstract class CRM_Import_Parser implements UserJobInterface {
     return $contactID;
   }
 
+  /**
+   * Get the fields for the dedupe rule.
+   *
+   * @param string $contactType
+   *
+   * @return array
+   * @throws \CRM_Core_Exception
+   */
+  protected function getDedupeFields(string $contactType): array {
+    return $this->getDedupeRule($contactType)['fields'];
+  }
+
 }
index 5e28e86c1ccb88a1586b73979a09ffb4a26adb2f..babfd41f9626dd5dc1da6861c47be812536d3949 100644 (file)
@@ -186,8 +186,6 @@ class CRM_Contribute_Import_Parser_ContributionTest extends CiviUnitTestCase {
     $this->runImport($values, CRM_Import_Parser::DUPLICATE_UPDATE);
     $contribution = $this->callAPISuccess('Contribution', 'get', ['contact_id' => $contactID, $this->getCustomFieldName('radio') => 'Red Testing']);
     $this->assertEquals(5, $contribution['values'][$contribution['id']]['custom_' . $this->ids['CustomField']['radio']]);
-    $this->callAPISuccess('CustomField', 'delete', ['id' => $this->ids['CustomField']['radio']]);
-    $this->callAPISuccess('CustomGroup', 'delete', ['id' => $this->ids['CustomGroup']['Custom Group']]);
   }
 
   /**
@@ -243,7 +241,7 @@ class CRM_Contribute_Import_Parser_ContributionTest extends CiviUnitTestCase {
     ]);
     $parser = new CRM_Contribute_Import_Parser_Contribution();
     $parser->setUserJobID($this->getUserJobID());
-    $fields = $parser->getAvailableFields();
+    $fields = $parser->getFieldsMetadata();
     $this->assertArrayHasKey('phone', $fields);
     $this->callApiSuccess('RuleGroup', 'create', [
       'id' => $unsupervisedRuleGroup['id'],
@@ -274,8 +272,8 @@ class CRM_Contribute_Import_Parser_ContributionTest extends CiviUnitTestCase {
     $this->runImport($values, CRM_Import_Parser::DUPLICATE_UPDATE, NULL);
 
     $updatedContribution = $this->callAPISuccessGetSingle('Contribution', ['id' => $initialContribution['id']]);
-    $this->assertNotContains('L', $updatedContribution[$customField], "Contribution Duplicate Update Import does not contain L");
-    $this->assertContains('V', $updatedContribution[$customField], "Contribution Duplicate Update Import contains V");
+    $this->assertNotContains('L', $updatedContribution[$customField], 'Contribution Duplicate Update Import does not contain L');
+    $this->assertContains('V', $updatedContribution[$customField], 'Contribution Duplicate Update Import contains V');
 
   }
 
@@ -350,7 +348,7 @@ class CRM_Contribute_Import_Parser_ContributionTest extends CiviUnitTestCase {
       ['name' => 'financial_type_id'],
       ['name' => 'total_amount'],
     ];
-    foreach ($data['fields']  as $field) {
+    foreach ($data['fields'] as $field) {
       $mappings[] = ['name' => $field === 'custom' ? $this->getCustomFieldName() : $field];
     }
     $this->submitDataSourceForm('contributions.csv', ['onDuplicate' => CRM_Import_Parser::DUPLICATE_SKIP]);
@@ -372,18 +370,18 @@ class CRM_Contribute_Import_Parser_ContributionTest extends CiviUnitTestCase {
    * @return array
    */
   public function validateData(): array {
-   return [
-     'email_first_name_last_name' => [['fields' => ['email', 'first_name', 'last_name'], 'valid' => TRUE]],
-     'email_last_name' => [['fields' => ['email', 'last_name'], 'valid' => TRUE]],
-     'email_first_name' => [['fields' => ['email', 'first_name',], 'valid' => TRUE]],
-     'first_name_last_name' => [['fields' => ['first_name', 'last_name'], 'valid' => TRUE]],
-     'email' => [['fields' => ['email'], 'valid' => TRUE]],
-     'first_name' => [['fields' => ['first_name'], 'valid' => FALSE]],
-     'last_name' => [['fields' => ['last_name'], 'valid' => FALSE]],
-     'last_name_custom' => [['fields' => ['last_name', 'custom'], 'valid' => TRUE]],
-     'first_name_custom' => [['fields' => ['first_name', 'custom'], 'valid' => TRUE]],
-     'custom' => [['fields' => ['first_name', 'custom'], 'valid' => FALSE]],
-   ];
+    return [
+      'email_first_name_last_name' => [['fields' => ['email', 'first_name', 'last_name'], 'valid' => TRUE]],
+      'email_last_name' => [['fields' => ['email', 'last_name'], 'valid' => TRUE]],
+      'email_first_name' => [['fields' => ['email', 'first_name'], 'valid' => TRUE]],
+      'first_name_last_name' => [['fields' => ['first_name', 'last_name'], 'valid' => TRUE]],
+      'email' => [['fields' => ['email'], 'valid' => TRUE]],
+      'first_name' => [['fields' => ['first_name'], 'valid' => FALSE]],
+      'last_name' => [['fields' => ['last_name'], 'valid' => FALSE]],
+      'last_name_custom' => [['fields' => ['last_name', 'custom'], 'valid' => TRUE]],
+      'first_name_custom' => [['fields' => ['first_name', 'custom'], 'valid' => TRUE]],
+      'custom' => [['fields' => ['custom'], 'valid' => FALSE]],
+    ];
   }
 
   /**