Merge pull request #23283 from eileenmcnaughton/import_saved_map
authorTim Otten <totten@civicrm.org>
Fri, 3 Jun 2022 07:11:04 +0000 (00:11 -0700)
committerGitHub <noreply@github.com>
Fri, 3 Jun 2022 07:11:04 +0000 (00:11 -0700)
[REF] Remove handling for non-existent 'savedMapping' field

163 files changed:
CRM/Activity/Import/Form/DataSource.php
CRM/Activity/Import/Form/MapField.php
CRM/Activity/Import/Form/Preview.php
CRM/Activity/Import/Form/Summary.php
CRM/Activity/Import/Parser/Activity.php
CRM/Batch/BAO/EntityBatch.php
CRM/Case/DAO/CaseType.php
CRM/Contact/BAO/Contact.php
CRM/Contact/BAO/Contact/Utils.php
CRM/Contact/BAO/Individual.php
CRM/Contact/DAO/Contact.php
CRM/Contact/Import/Form/DataSource.php
CRM/Contact/Import/Form/MapField.php
CRM/Contact/Import/Form/Preview.php
CRM/Contact/Import/ImportJob.php
CRM/Contact/Import/Parser/Contact.php
CRM/Contribute/BAO/Contribution.php
CRM/Contribute/DAO/Contribution.php
CRM/Contribute/Form/Contribution/Confirm.php
CRM/Contribute/Form/Contribution/ThankYou.php
CRM/Contribute/Import/Form/DataSource.php
CRM/Contribute/Import/Form/MapField.php
CRM/Contribute/Import/Form/Preview.php
CRM/Contribute/Import/Form/Summary.php
CRM/Contribute/Import/Parser/Contribution.php
CRM/Core/OptionValue.php
CRM/Core/Smarty/plugins/modifier.crmCountCharacters.php [new file with mode: 0644]
CRM/Custom/Import/Form/DataSource.php
CRM/Custom/Import/Form/Preview.php
CRM/Custom/Import/Parser/Api.php
CRM/Event/Form/Registration/Confirm.php
CRM/Event/Import/Form/DataSource.php
CRM/Event/Import/Form/MapField.php
CRM/Event/Import/Form/Preview.php
CRM/Event/Import/Form/Summary.php
CRM/Event/Import/Parser/Participant.php
CRM/Import/Form/DataSource.php
CRM/Import/Forms.php
CRM/Import/ImportProcessor.php
CRM/Import/Parser.php
CRM/Mailing/BAO/Mailing.php
CRM/Mailing/BAO/MailingJob.php
CRM/Mailing/BAO/SMSJob.php [new file with mode: 0644]
CRM/Mailing/Form/Approve.php
CRM/Mailing/Page/View.php
CRM/Member/Import/Form/DataSource.php
CRM/Member/Import/Form/MapField.php
CRM/Member/Import/Form/Preview.php
CRM/Member/Import/Form/Summary.php
CRM/Member/Import/Parser/Membership.php
CRM/Member/Page/UserDashboard.php
CRM/Queue/BAO/Queue.php
CRM/Queue/DAO/Queue.php
CRM/Queue/Queue.php
CRM/Queue/Queue/BatchQueueInterface.php [new file with mode: 0644]
CRM/Queue/Queue/Memory.php
CRM/Queue/Queue/Sql.php
CRM/Queue/Queue/SqlParallel.php
CRM/Queue/Queue/SqlTrait.php
CRM/Queue/Runner.php
CRM/Queue/Service.php
CRM/Queue/TaskRunner.php [new file with mode: 0644]
CRM/Report/Form.php
CRM/SMS/Provider.php
CRM/Upgrade/Incremental/php/FiveFiftyOne.php
CRM/Upgrade/Snapshot.php
CRM/Utils/Address.php
CRM/Utils/Cache/SqlGroup.php
CRM/Utils/DeprecatedUtils.php
CRM/Utils/Geocode/Google.php
CRM/Utils/GeocodeProvider.php
CRM/Utils/Hook.php
CRM/Utils/Recent.php
CRM/Utils/System/Drupal8.php
Civi.php
Civi/API/Subscriber/ChainSubscriber.php
Civi/Api4/Action/Address/GetCoordinates.php [new file with mode: 0644]
Civi/Api4/Action/Queue/ClaimItems.php [new file with mode: 0644]
Civi/Api4/Action/Queue/RunItems.php [new file with mode: 0644]
Civi/Api4/Address.php
Civi/Api4/CaseType.php
Civi/Api4/Generic/BasicGetFieldsAction.php
Civi/Api4/Queue.php
Civi/Api4/Service/Spec/Provider/AddressGetSpecProvider.php [new file with mode: 0644]
Civi/Api4/Service/Spec/SpecGatherer.php
api/v3/Generic/Getlist.php
api/v3/Mailing.php
contributor-key.yml
ext/authx/authx.php
ext/authx/info.xml
ext/civigrant/civigrant.php
ext/flexmailer/src/API/MailingPreview.php
ext/flexmailer/src/Listener/DefaultComposer.php
ext/legacycustomsearches/CRM/Contact/Form/Search/Custom/Proximity.php
ext/search_kit/Civi/Search/Admin.php
ext/search_kit/ang/crmSearchAdmin/crmSearchClause.component.js
ext/search_kit/ang/crmSearchAdmin/crmSearchClause.html
ext/search_kit/ang/crmSearchAdmin/crmSearchCondition.html
ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputVal.component.js
ext/search_kit/ang/crmSearchTasks/crmSearchInput/location.html [new file with mode: 0644]
release-notes.md
release-notes/5.45.3.md [new file with mode: 0644]
release-notes/5.46.1.md [new file with mode: 0644]
release-notes/5.46.2.md [new file with mode: 0644]
release-notes/5.46.3.md [new file with mode: 0644]
release-notes/5.47.1.md [new file with mode: 0644]
release-notes/5.47.2.md [new file with mode: 0644]
release-notes/5.47.3.md [new file with mode: 0644]
release-notes/5.47.4.md [new file with mode: 0644]
release-notes/5.48.1.md [new file with mode: 0644]
release-notes/5.48.2.md [new file with mode: 0644]
release-notes/5.49.1.md [new file with mode: 0644]
release-notes/5.49.2.md [new file with mode: 0644]
release-notes/5.49.3.md [new file with mode: 0644]
release-notes/5.50.0.md
settings/Map.setting.php
templates/CRM/Activity/Import/Form/DataSource.tpl
templates/CRM/Block/Event.tpl
templates/CRM/Contact/Import/Form/DataSource.tpl
templates/CRM/Contact/Page/View/Note.tpl
templates/CRM/Contribute/Import/Form/MapField.tpl
templates/CRM/Contribute/Import/Form/MapTable.tpl
templates/CRM/Import/Form/DataSource.tpl
tests/phpunit/CRM/Activity/Import/Parser/ActivityTest.php
tests/phpunit/CRM/Contact/BAO/ContactTest.php
tests/phpunit/CRM/Contact/Import/Form/MapFieldTest.php
tests/phpunit/CRM/Contact/Import/Form/data/individual_country_state_county_with_related.csv [new file with mode: 0644]
tests/phpunit/CRM/Contact/Import/Form/data/individual_geocode.csv [new file with mode: 0644]
tests/phpunit/CRM/Contact/Import/Form/data/individual_import_related_extid.csv [new file with mode: 0644]
tests/phpunit/CRM/Contact/Import/Form/data/individual_locations_with_related.csv
tests/phpunit/CRM/Contact/Import/Form/data/individual_parse_failure.csv [new file with mode: 0644]
tests/phpunit/CRM/Contact/Import/Parser/ContactTest.php
tests/phpunit/CRM/Contact/SelectorTest.php
tests/phpunit/CRM/Contribute/ActionMapping/ByTypeTest.php
tests/phpunit/CRM/Contribute/Import/Parser/ContributionTest.php
tests/phpunit/CRM/Contribute/Import/Parser/data/contributions.CSV [deleted file]
tests/phpunit/CRM/Contribute/Import/Parser/data/contributions.txt [deleted file]
tests/phpunit/CRM/Dedupe/BAO/RuleGroupTest.php
tests/phpunit/CRM/Dedupe/MergerTest.php
tests/phpunit/CRM/Event/Form/Registration/ConfirmTest.php
tests/phpunit/CRM/Export/BAO/ExportTest.php
tests/phpunit/CRM/Member/Import/Parser/MembershipTest.php
tests/phpunit/CRM/Queue/QueueTest.php
tests/phpunit/CRM/SMS/PreviewTest.php
tests/phpunit/CRM/Utils/Geocode/TestProvider.php
tests/phpunit/CRM/Utils/RestTest.php
tests/phpunit/api/v3/AddressTest.php
tests/phpunit/api/v3/ContactTest.php
tests/phpunit/api/v3/CountryTest.php
tests/phpunit/api/v3/EntityBatchTest.php
tests/phpunit/api/v3/EventTest.php
tests/phpunit/api/v3/RelationshipTest.php
tests/phpunit/api/v4/Action/AddressGetCoordinatesTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Custom/BasicCustomFieldTest.php
tests/phpunit/api/v4/Custom/CustomFieldGetFieldsTest.php
tests/phpunit/api/v4/Custom/CustomValueTest.php
tests/phpunit/api/v4/Entity/AddressTest.php
tests/phpunit/api/v4/Entity/QueueTest.php [new file with mode: 0644]
xml/schema/Case/CaseType.xml
xml/schema/Contact/Contact.xml
xml/schema/Contribute/Contribution.xml
xml/schema/Queue/Queue.xml
xml/templates/schema.tpl

index a244cb7e0a86a2bb3f48ad337374d579ee51387f..103669bd61edf1b8fab2d23c91e9dd8b342df03c 100644 (file)
@@ -34,13 +34,6 @@ class CRM_Activity_Import_Form_DataSource extends CRM_Import_Form_DataSource {
    */
   public function buildQuickForm() {
     parent::buildQuickForm();
-
-    // FIXME: This 'onDuplicate' form element is never used -- copy/paste error?
-    $this->addRadio('onDuplicate', ts('On duplicate entries'), [
-      CRM_Import_Parser::DUPLICATE_SKIP => ts('Skip'),
-      CRM_Import_Parser::DUPLICATE_UPDATE => ts('Update'),
-      CRM_Import_Parser::DUPLICATE_FILL => ts('Fill'),
-    ]);
   }
 
   /**
@@ -48,7 +41,6 @@ class CRM_Activity_Import_Form_DataSource extends CRM_Import_Form_DataSource {
    */
   public function postProcess() {
     $this->storeFormValues([
-      'onDuplicate',
       'dateFormats',
       'savedMapping',
     ]);
@@ -56,4 +48,16 @@ class CRM_Activity_Import_Form_DataSource extends CRM_Import_Form_DataSource {
     $this->submitFileForMapping('CRM_Activity_Import_Parser_Activity');
   }
 
+  /**
+   * @return CRM_Activity_Import_Parser_Activity
+   */
+  protected function getParser(): CRM_Activity_Import_Parser_Activity {
+    if (!$this->parser) {
+      $this->parser = new CRM_Activity_Import_Parser_Activity();
+      $this->parser->setUserJobID($this->getUserJobID());
+      $this->parser->init();
+    }
+    return $this->parser;
+  }
+
 }
index 8008bc0412c4db0252a972b7b99f41e1c52e4191..e9b6c651063ab6558150305da44544d0318b425f 100644 (file)
@@ -324,8 +324,8 @@ class CRM_Activity_Import_Form_MapField extends CRM_Import_Form_MapField {
       $this->controller->resetPage($this->_name);
       return;
     }
+    $this->updateUserJobMetadata('submitted_values', $this->getSubmittedValues());
 
-    $mapperKeys = [];
     $mapper = [];
     $mapperKeys = $this->controller->exportValue($this->_name, 'mapper');
     $mapperKeysMain = [];
@@ -387,6 +387,7 @@ class CRM_Activity_Import_Form_MapField extends CRM_Import_Form_MapField {
     }
 
     $parser = new CRM_Activity_Import_Parser_Activity($mapperKeysMain);
+    $parser->setUserJobID($this->getUserJobID());
     $parser->run($this->getSubmittedValue('uploadFile'), $this->getSubmittedValue('fieldSeparator'), $mapper, $this->getSubmittedValue('skipColumnHeader'),
       CRM_Import_Parser::MODE_PREVIEW
     );
@@ -395,4 +396,16 @@ class CRM_Activity_Import_Form_MapField extends CRM_Import_Form_MapField {
     $parser->set($this);
   }
 
+  /**
+   * @return CRM_Activity_Import_Parser_Activity
+   */
+  protected function getParser(): CRM_Activity_Import_Parser_Activity {
+    if (!$this->parser) {
+      $this->parser = new CRM_Activity_Import_Parser_Activity();
+      $this->parser->setUserJobID($this->getUserJobID());
+      $this->parser->init();
+    }
+    return $this->parser;
+  }
+
 }
index 09e5bf2791c21f710d19414076a4d3bd9231e048..16f5087d4e91d7dcf5ede8d2c603c4fa15afb25b 100644 (file)
@@ -78,7 +78,7 @@ class CRM_Activity_Import_Form_Preview extends CRM_Import_Form_Preview {
     }
 
     $parser = new CRM_Activity_Import_Parser_Activity($mapperKeys);
-
+    $parser->setUserJobID($this->getUserJobID());
     $mapFields = $this->get('fields');
 
     foreach ($mapper as $key => $value) {
@@ -126,4 +126,16 @@ class CRM_Activity_Import_Form_Preview extends CRM_Import_Form_Preview {
     }
   }
 
+  /**
+   * @return CRM_Activity_Import_Parser_Activity
+   */
+  protected function getParser(): CRM_Activity_Import_Parser_Activity {
+    if (!$this->parser) {
+      $this->parser = new CRM_Activity_Import_Parser_Activity();
+      $this->parser->setUserJobID($this->getUserJobID());
+      $this->parser->init();
+    }
+    return $this->parser;
+  }
+
 }
index c0be01bd52429188b704150bb60b0a04ed449571..1fd39b2f8abe41bfbbabdce2eacc802de713bb20 100644 (file)
@@ -28,8 +28,6 @@ class CRM_Activity_Import_Form_Summary extends CRM_Import_Form_Summary {
     $this->assign('errorFile', $this->get('errorFile'));
 
     $totalRowCount = $this->get('totalRowCount');
-    $relatedCount = $this->get('relatedCount');
-    $totalRowCount += $relatedCount;
     $this->set('totalRowCount', $totalRowCount);
 
     $invalidRowCount = $this->get('invalidRowCount');
index 5e95f929eeb393f43ac5ce37c3c1e97b823d38e1..b09c38683e241103849aeec6676f5d2a41898efa 100644 (file)
@@ -23,8 +23,6 @@ class CRM_Activity_Import_Parser_Activity extends CRM_Import_Parser {
 
   protected $_mapperKeys;
 
-  private $_contactIdIndex;
-
   /**
    * Array of successfully imported activity id's
    *
@@ -103,20 +101,6 @@ class CRM_Activity_Import_Parser_Activity extends CRM_Import_Parser {
     $this->_newActivity = [];
 
     $this->setActiveFields($this->_mapperKeys);
-
-    // FIXME: we should do this in one place together with Form/MapField.php
-    $this->_contactIdIndex = -1;
-
-    $index = 0;
-    foreach ($this->_mapperKeys as $key) {
-      switch ($key) {
-        case 'target_contact_id':
-        case 'external_identifier':
-          $this->_contactIdIndex = $index;
-          break;
-      }
-      $index++;
-    }
   }
 
   /**
@@ -186,16 +170,15 @@ class CRM_Activity_Import_Parser_Activity extends CRM_Import_Parser {
       }
     }
 
-    if ($this->_contactIdIndex < 0) {
+    if (empty($params['external_identifier']) && empty($params['target_contact_id'])) {
 
       // Retrieve contact id using contact dedupe rule.
       // Since we are supporting only individual's activity import.
       $params['contact_type'] = 'Individual';
       $params['version'] = 3;
-      $error = _civicrm_api3_deprecated_duplicate_formatted_contact($params);
+      $matchedIDs = CRM_Contact_BAO_Contact::getDuplicateContacts($params, 'Individual');
 
-      if (CRM_Core_Error::isAPIError($error, CRM_Core_ERROR::DUPLICATE_CONTACT)) {
-        $matchedIDs = explode(',', $error['error_message']['params'][0]);
+      if (!empty($matchedIDs)) {
         if (count($matchedIDs) > 1) {
           array_unshift($values, 'Multiple matching contact records detected for this row. The activity was not imported');
           return CRM_Import_Parser::ERROR;
@@ -375,7 +358,7 @@ class CRM_Activity_Import_Parser_Activity extends CRM_Import_Parser {
     $errorMessage = NULL;
     // Checking error in custom data.
     $params['contact_type'] = 'Activity';
-    CRM_Contact_Import_Parser_Contact::isErrorInCustomData($params, $errorMessage);
+    $this->isErrorInCustomData($params, $errorMessage);
     if ($errorMessage) {
       throw new CRM_Core_Exception('Invalid value for field(s) : ' . $errorMessage);
     }
index f4c1e55a7027fc5e49ee220c13804bde98fb29c0..39db2ba2fc65f6208029900dad84cdca70561c2f 100644 (file)
@@ -45,7 +45,7 @@ class CRM_Batch_BAO_EntityBatch extends CRM_Batch_DAO_EntityBatch {
         ->execute()
         ->first();
       if ($batchCurrency && $batchCurrency !== $trxn['currency']) {
-        throw new \CRM_Core_Exception(ts('You can not add items of two different currencies to a single contribution batch.'));
+        throw new \CRM_Core_Exception(ts('You cannot add items of two different currencies to a single contribution batch. Batch id %1 currency: %2. Entity id %3 currency: %4.', [1 => $batchId, 2 => $batchCurrency, 3 => $entityId, 4 => $trxn['currency']]));
       }
       if ($batchPID && $trxn && $batchPID !== $trxn['payment_instrument_id']) {
         $paymentInstrument = CRM_Core_PseudoConstant::getLabel('CRM_Batch_BAO_Batch', 'payment_instrument_id', $batchPID);
index 3fbe29126dbb1cb04da986e0ba7443da79561ae2..53b1a88e0b34ee831ec240843f33682deb9118ec 100644 (file)
@@ -6,7 +6,7 @@
  *
  * Generated from xml/schema/CRM/Case/CaseType.xml
  * DO NOT EDIT.  Generated by CRM_Core_CodeGen
- * (GenCodeChecksum:7b3029a4b42f22a060fadb39b7b2c678)
+ * (GenCodeChecksum:92eb680369ce37591734a961f22ce831)
  */
 
 /**
@@ -159,6 +159,9 @@ class CRM_Case_DAO_CaseType extends CRM_Core_DAO {
           'entity' => 'CaseType',
           'bao' => 'CRM_Case_BAO_CaseType',
           'localizable' => 0,
+          'html' => [
+            'type' => 'Text',
+          ],
           'add' => '4.5',
         ],
         'title' => [
@@ -174,6 +177,9 @@ class CRM_Case_DAO_CaseType extends CRM_Core_DAO {
           'entity' => 'CaseType',
           'bao' => 'CRM_Case_BAO_CaseType',
           'localizable' => 1,
+          'html' => [
+            'type' => 'Text',
+          ],
           'add' => '4.5',
         ],
         'description' => [
@@ -188,6 +194,9 @@ class CRM_Case_DAO_CaseType extends CRM_Core_DAO {
           'entity' => 'CaseType',
           'bao' => 'CRM_Case_BAO_CaseType',
           'localizable' => 1,
+          'html' => [
+            'type' => 'Text',
+          ],
           'add' => '4.5',
         ],
         'is_active' => [
@@ -202,6 +211,9 @@ class CRM_Case_DAO_CaseType extends CRM_Core_DAO {
           'entity' => 'CaseType',
           'bao' => 'CRM_Case_BAO_CaseType',
           'localizable' => 0,
+          'html' => [
+            'type' => 'CheckBox',
+          ],
           'add' => '4.5',
         ],
         'is_reserved' => [
@@ -216,6 +228,9 @@ class CRM_Case_DAO_CaseType extends CRM_Core_DAO {
           'entity' => 'CaseType',
           'bao' => 'CRM_Case_BAO_CaseType',
           'localizable' => 0,
+          'html' => [
+            'type' => 'CheckBox',
+          ],
           'add' => '4.5',
         ],
         'weight' => [
@@ -230,6 +245,9 @@ class CRM_Case_DAO_CaseType extends CRM_Core_DAO {
           'entity' => 'CaseType',
           'bao' => 'CRM_Case_BAO_CaseType',
           'localizable' => 0,
+          'html' => [
+            'type' => 'Number',
+          ],
           'add' => '4.5',
         ],
         'definition' => [
index 303d8962ab4356397292fc18410dd120b56dbcaa..c0928f3e6637b074bc2378358d1ec29aac414ab9 100644 (file)
@@ -122,9 +122,12 @@ class CRM_Contact_BAO_Contact extends CRM_Contact_DAO_Contact implements Civi\Co
     }
 
     if (isset($params['preferred_communication_method']) && is_array($params['preferred_communication_method'])) {
-      CRM_Utils_Array::formatArrayKeys($params['preferred_communication_method']);
-      $contact->preferred_communication_method = CRM_Utils_Array::implodePadded($params['preferred_communication_method']);
-      unset($params['preferred_communication_method']);
+      if (!empty($params['preferred_communication_method']) && empty($params['preferred_communication_method'][0])) {
+        CRM_Core_Error::deprecatedWarning(' Form layer formatting should never get to the BAO');
+        CRM_Utils_Array::formatArrayKeys($params['preferred_communication_method']);
+        $contact->preferred_communication_method = CRM_Utils_Array::implodePadded($params['preferred_communication_method']);
+        unset($params['preferred_communication_method']);
+      }
     }
 
     $defaults = ['source' => $params['contact_source'] ?? NULL];
@@ -778,86 +781,6 @@ WHERE     civicrm_contact.id = " . CRM_Utils_Type::escape($id, 'Integer');
    *
    */
   public static function resolveDefaults(&$defaults, $reverse = FALSE) {
-
-    //lookup value of email/postal greeting, addressee, CRM-4575
-    foreach (self::$_greetingTypes as $greeting) {
-      $filterCondition = [
-        'contact_type' => $defaults['contact_type'] ?? NULL,
-        'greeting_type' => $greeting,
-      ];
-      CRM_Utils_Array::lookupValue($defaults, $greeting,
-        CRM_Core_PseudoConstant::greeting($filterCondition), $reverse
-      );
-    }
-
-    $blocks = ['address', 'im', 'phone'];
-    foreach ($blocks as $name) {
-      if (!array_key_exists($name, $defaults) || !is_array($defaults[$name])) {
-        continue;
-      }
-      foreach ($defaults[$name] as $count => & $values) {
-
-        //get location type id.
-        CRM_Utils_Array::lookupValue($values, 'location_type', CRM_Core_PseudoConstant::get('CRM_Core_DAO_Address', 'location_type_id'), $reverse);
-
-        if ($name == 'address') {
-          // FIXME: lookupValue doesn't work for vcard_name
-          if (!empty($values['location_type_id'])) {
-            $vcardNames = CRM_Core_PseudoConstant::get('CRM_Core_DAO_Address', 'location_type_id', ['labelColumn' => 'vcard_name']);
-            $values['vcard_name'] = $vcardNames[$values['location_type_id']];
-          }
-
-          if (!CRM_Utils_Array::lookupValue($values,
-              'country',
-              CRM_Core_PseudoConstant::country(),
-              $reverse
-            ) &&
-            $reverse
-          ) {
-            CRM_Utils_Array::lookupValue($values,
-              'country',
-              CRM_Core_PseudoConstant::countryIsoCode(),
-              $reverse
-            );
-          }
-          $stateProvinceID = self::resolveStateProvinceID($values, $values['country_id'] ?? NULL);
-          if ($stateProvinceID) {
-            $values['state_province_id'] = $stateProvinceID;
-          }
-
-          if (!empty($values['state_province_id'])) {
-            $countyList = CRM_Core_PseudoConstant::countyForState($values['state_province_id']);
-          }
-          else {
-            $countyList = CRM_Core_PseudoConstant::county();
-          }
-          CRM_Utils_Array::lookupValue($values,
-            'county',
-            $countyList,
-            $reverse
-          );
-        }
-
-        if ($name == 'im') {
-          CRM_Utils_Array::lookupValue($values,
-            'provider',
-            CRM_Core_PseudoConstant::get('CRM_Core_DAO_IM', 'provider_id'),
-            $reverse
-          );
-        }
-
-        if ($name == 'phone') {
-          CRM_Utils_Array::lookupValue($values,
-            'phone_type',
-            CRM_Core_PseudoConstant::get('CRM_Core_DAO_Phone', 'phone_type_id'),
-            $reverse
-          );
-        }
-
-        // Kill the reference.
-        unset($values);
-      }
-    }
   }
 
   /**
@@ -876,7 +799,7 @@ WHERE     civicrm_contact.id = " . CRM_Utils_Type::escape($id, 'Integer');
    *
    * @return CRM_Contact_BAO_Contact
    */
-  public static function &retrieve(&$params, &$defaults, $microformat = FALSE) {
+  public static function &retrieve(&$params, &$defaults = [], $microformat = FALSE) {
     if (array_key_exists('contact_id', $params)) {
       $params['id'] = $params['contact_id'];
     }
index fb8d5bacac001d3490cd8514e65ef4b8b3e0f518..da904c31b8c559c5c17fa1e85fedf2a65a3ce58b 100644 (file)
@@ -10,6 +10,7 @@
  */
 
 use Civi\Api4\Contact;
+use Civi\Api4\Relationship;
 
 /**
  *
@@ -272,17 +273,38 @@ WHERE  id IN ( $idString )
       CRM_Core_Error::deprecatedWarning('attempting to create an employer with invalid contact types is deprecated');
       return;
     }
+
     $relationshipIds = [];
-    $duplicate = CRM_Contact_BAO_Relationship::checkDuplicateRelationship(
-      [
-        'contact_id_a' => $contactID,
-        'contact_id_b' => $employerID,
-        'relationship_type_id' => $relationshipTypeID,
-      ],
-      $contactID,
-      $employerID
-    );
-    if (!$duplicate) {
+    $ids = [];
+    $action = CRM_Core_Action::ADD;
+    $existingRelationship = Relationship::get(FALSE)
+      ->setWhere([
+        ['contact_id_a', '=', $contactID],
+        ['contact_id_b', '=', $employerID],
+        ['OR', [['start_date', '<=', 'now'], ['start_date', 'IS EMPTY']]],
+        ['OR', [['end_date', '>=', 'now'], ['end_date', 'IS EMPTY']]],
+        ['relationship_type_id', '=', $relationshipTypeID],
+        ['is_active', 'IN', [0, 1]],
+      ])
+      ->setSelect(['id', 'is_active', 'start_date', 'end_date', 'contact_id_a.employer_id'])
+      ->addOrderBy('is_active', 'DESC')
+      ->setLimit(1)
+      ->execute()->first();
+
+    if (!empty($existingRelationship)) {
+      if ($existingRelationship['is_active']) {
+        // My work here is done.
+        return;
+      }
+
+      $action = CRM_Core_Action::UPDATE;
+      // No idea why we set these ids but it's either legacy cruft or used by `relatedMemberships`
+      $ids['contact'] = $contactID;
+      $ids['contactTarget'] = $employerID;
+      $ids['relationship'] = $existingRelationship['id'];
+      CRM_Contact_BAO_Relationship::setIsActive($existingRelationship['id'], TRUE);
+    }
+    else {
       $params = [
         'is_active' => TRUE,
         'contact_check' => [$employerID => TRUE],
@@ -308,25 +330,9 @@ WHERE  id IN ( $idString )
     // set current employer
     self::setCurrentEmployer([$contactID => $employerID]);
 
-    $ids = [];
-    $action = CRM_Core_Action::ADD;
-
-    //we do not know that triggered relationship record is active.
-    if ($duplicate) {
-      $relationship = new CRM_Contact_DAO_Relationship();
-      $relationship->contact_id_a = $contactID;
-      $relationship->contact_id_b = $employerID;
-      $relationship->relationship_type_id = $relationshipTypeID;
-      if ($relationship->find(TRUE)) {
-        $action = CRM_Core_Action::UPDATE;
-        $ids['contact'] = $contactID;
-        $ids['contactTarget'] = $employerID;
-        $ids['relationship'] = $relationship->id;
-        CRM_Contact_BAO_Relationship::setIsActive($relationship->id, TRUE);
-      }
-    }
-
     //need to handle related memberships. CRM-3792
+    // @todo - this probably duplicates the work done in the setIsActive
+    // for duplicates...
     if ($previousEmployerID != $employerID) {
       CRM_Contact_BAO_Relationship::relatedMemberships($contactID, [
         'relationship_ids' => $relationshipIds,
index cf18a557b88e9aff50dd6168146e0436f73b507e..409c2ee0573a17c44c4ff89cd831481cc68ad385 100644 (file)
@@ -275,9 +275,19 @@ class CRM_Contact_BAO_Individual extends CRM_Contact_DAO_Contact {
       ])) {
         $date = $date . '-01-01';
       }
-      $contact->birth_date = CRM_Utils_Date::processDate($date);
+      $processedDate = CRM_Utils_Date::processDate($date);
+      $existing = substr(str_replace('-', '', $contact->birth_date), 0, 8) . '000000';
+      // By adding this check here we can rip out this whole routine in a few
+      // months after confirming it actually does nothing, ever.
+      if ($existing !== $processedDate) {
+        CRM_Core_Error::deprecatedWarning('birth_date formatting should happen before BAO is hit');
+        $contact->birth_date = $processedDate;
+      }
     }
     elseif ($contact->birth_date) {
+      if ($contact->birth_date !== CRM_Utils_Date::isoToMysql($contact->birth_date)) {
+        CRM_Core_Error::deprecatedWarning('birth date formatting should happen before BAO is hit');
+      }
       $contact->birth_date = CRM_Utils_Date::isoToMysql($contact->birth_date);
     }
 
@@ -307,14 +317,26 @@ class CRM_Contact_BAO_Individual extends CRM_Contact_DAO_Contact {
       ])) {
         $date = $date . '-01-01';
       }
-
+      $processedDate = CRM_Utils_Date::processDate($date);
+      $existing = substr(str_replace('-', '', $contact->deceased_date), 0, 8) . '000000';
+      // By adding this check here we can rip out this whole routine in a few
+      // months after confirming it actually does nothing, ever.
+      if ($existing !== $processedDate) {
+        CRM_Core_Error::deprecatedWarning('deceased formatting should happen before BAO is hit');
+      }
       $contact->deceased_date = CRM_Utils_Date::processDate($date);
     }
     elseif ($contact->deceased_date) {
+      if ($contact->deceased_date !== CRM_Utils_Date::isoToMysql($contact->deceased_date)) {
+        CRM_Core_Error::deprecatedWarning('deceased date formatting should happen before BAO is hit');
+      }
       $contact->deceased_date = CRM_Utils_Date::isoToMysql($contact->deceased_date);
     }
 
     if ($middle_name = CRM_Utils_Array::value('middle_name', $params)) {
+      if ($middle_name !== $contact->middle_name) {
+        CRM_Core_Error::deprecatedWarning('random magic is deprecated - how could this be true');
+      }
       $contact->middle_name = $middle_name;
     }
 
index 4a327bb4b0f51a2428a2db475ce3ac8b12d930f6..790dce543b51c191fd61500adbb2af06174cba31 100644 (file)
@@ -6,7 +6,7 @@
  *
  * Generated from xml/schema/CRM/Contact/Contact.xml
  * DO NOT EDIT.  Generated by CRM_Core_CodeGen
- * (GenCodeChecksum:67196fefde2ec151c97d463869102e21)
+ * (GenCodeChecksum:6c4b31481898fef1b087265d096c65f6)
  */
 
 /**
@@ -960,10 +960,10 @@ class CRM_Contact_DAO_Contact extends CRM_Core_DAO {
           'description' => ts('What is the preferred mode of sending an email.'),
           'maxlength' => 8,
           'size' => CRM_Utils_Type::EIGHT,
-          'import' => TRUE,
+          'import' => FALSE,
           'where' => 'civicrm_contact.preferred_mail_format',
           'headerPattern' => '/^p(ref\w*\s)?m(ail\s)?f(orm\w*)$/i',
-          'export' => TRUE,
+          'export' => FALSE,
           'default' => 'Both',
           'table_name' => 'civicrm_contact',
           'entity' => 'Contact',
index 01ee130fc57be9fa1180614e40643ed69f6f92bc..fb4c8dcbf5aa7410efa4566f99a2ebbaa342cf45 100644 (file)
@@ -18,7 +18,7 @@
 /**
  * This class delegates to the chosen DataSource to grab the data to be imported.
  */
-class CRM_Contact_Import_Form_DataSource extends CRM_Import_Forms {
+class CRM_Contact_Import_Form_DataSource extends CRM_Import_Form_DataSource {
 
   /**
    * Get any smarty elements that may not be present in the form.
@@ -172,14 +172,7 @@ class CRM_Contact_Import_Form_DataSource extends CRM_Import_Forms {
    */
   public function postProcess() {
     $this->controller->resetPage('MapField');
-    if (!$this->getUserJobID()) {
-      $this->createUserJob();
-    }
-    else {
-      $this->flushDataSource();
-      $this->updateUserJobMetadata('submitted_values', $this->getSubmittedValues());
-    }
-
+    $this->processDatasource();
     // @todo - this params are being set here because they were / possibly still
     // are in some places being accessed by forms later in the flow
     // ie CRM_Contact_Import_Form_MapField, CRM_Contact_Import_Form_Preview
@@ -201,19 +194,6 @@ class CRM_Contact_Import_Form_DataSource extends CRM_Import_Forms {
     }
     CRM_Core_Session::singleton()->set('dateTypes', $storeParams['dateFormats']);
 
-    $this->instantiateDataSource();
-  }
-
-  /**
-   * Instantiate the datasource.
-   *
-   * This gives the datasource a chance to do any table creation etc.
-   *
-   * @throws \API_Exception
-   * @throws \CRM_Core_Exception
-   */
-  private function instantiateDataSource(): void {
-    $this->getDataSourceObject()->initialize();
   }
 
   /**
index 7a6663124a0fb93912acb5937d5cef82f0d74da9..9a1d3174da9c9663b2ebf2b7d920505fbd416212 100644 (file)
@@ -287,28 +287,34 @@ class CRM_Contact_Import_Form_MapField extends CRM_Import_Form_MapField {
     $processor->setMetadata($this->getContactImportMetadata());
     $processor->setContactTypeByConstant($this->getSubmittedValue('contactType'));
     $processor->setContactSubType($this->getSubmittedValue('contactSubType'));
+    $mapper = $this->getSubmittedValue('mapper');
 
     for ($i = 0; $i < $this->_columnCount; $i++) {
       $sel = &$this->addElement('hierselect', "mapper[$i]", ts('Mapper for Field %1', [1 => $i]), NULL);
+      $last_key = 0;
 
-      if ($this->getSubmittedValue('savedMapping') && $processor->getFieldName($i)) {
+      // Don't set any defaults if we are going to the next page.
+      // ... or coming back.
+      // But do add the js.
+      if (!empty($mapper)) {
+        $last_key = array_key_last($mapper[$i]);
+      }
+      elseif ($this->getSubmittedValue('savedMapping') && $processor->getFieldName($i)) {
         $defaults["mapper[$i]"] = $processor->getSavedQuickformDefaultsForColumn($i);
-        $js .= $processor->getQuickFormJSForField($i);
+        $last_key = array_key_last($defaults["mapper[$i]"]) ?? 0;
       }
       else {
-        $js .= "swapOptions($formName, 'mapper[$i]', 0, 3, 'hs_mapper_0_');\n";
         if ($hasColumnNames) {
           // do array search first to see if has mapped key
           $columnKey = array_search($this->_columnNames[$i], $this->getFieldTitles());
           if (isset($this->_fieldUsed[$columnKey])) {
-            $defaults["mapper[$i]"] = $columnKey;
+            $defaults["mapper[$i]"] = [$columnKey];
             $this->_fieldUsed[$key] = TRUE;
           }
           else {
             // Infer the default from the column names if we have them
             $defaults["mapper[$i]"] = [
               $this->defaultFromColumnName($this->_columnNames[$i]),
-              0,
             ];
           }
         }
@@ -316,10 +322,14 @@ class CRM_Contact_Import_Form_MapField extends CRM_Import_Form_MapField {
           // Otherwise guess the default from the form of the data
           $defaults["mapper[$i]"] = [
             $this->defaultFromData($this->getDataPatterns(), $i),
-            //                     $defaultLocationType->id
-            0,
           ];
         }
+        $last_key = array_key_last($defaults["mapper[$i]"]) ?? 0;
+      }
+      // Call swapOptions on the deepest select element to hide the empty select lists above it.
+      // But we don't need to hide anything above $sel4.
+      if ($last_key < 3) {
+        $js .= "swapOptions($formName, 'mapper[$i]', $last_key, 4, 'hs_mapper_0_');\n";
       }
       $sel->setOptions([$sel1, $sel2, $sel3, $sel4]);
     }
@@ -383,10 +393,7 @@ class CRM_Contact_Import_Form_MapField extends CRM_Import_Form_MapField {
   public function postProcess() {
     $params = $this->controller->exportValues('MapField');
     $this->updateUserJobMetadata('submitted_values', $this->getSubmittedValues());
-    $parser = $this->submit($params);
-
-    // add all the necessary variables to the form
-    $parser->set($this);
+    $this->submit($params);
   }
 
   /**
@@ -430,18 +437,10 @@ class CRM_Contact_Import_Form_MapField extends CRM_Import_Form_MapField {
    * @param $params
    * @param $mapperKeys
    *
-   * @return \CRM_Contact_Import_Parser_Contact
    * @throws \CiviCRM_API3_Exception
    * @throws \CRM_Core_Exception
    */
   public function submit($params) {
-    $mapperKeys = $this->getSubmittedValue('mapper');
-    $mapperKeysMain = [];
-
-    for ($i = 0; $i < $this->_columnCount; $i++) {
-      $mapperKeysMain[$i] = $mapperKeys[$i][0] ?? NULL;
-    }
-
     $this->set('columnNames', $this->_columnNames);
 
     // store mapping Id to display it in the preview page
@@ -470,14 +469,9 @@ class CRM_Contact_Import_Form_MapField extends CRM_Import_Form_MapField {
       $this->set('savedMapping', $saveMapping['id']);
     }
 
-    $parser = new CRM_Contact_Import_Parser_Contact($mapperKeysMain);
+    $parser = new CRM_Contact_Import_Parser_Contact();
     $parser->setUserJobID($this->getUserJobID());
-
-    $parser->run(
-      [],
-      CRM_Import_Parser::MODE_PREVIEW
-    );
-    return $parser;
+    $parser->validate();
   }
 
   /**
index 9f03057aee7e83923545c768ff99c22c7a2851e2..3bf15b4ea251f89694a74f98e87f10c4e73273ea 100644 (file)
@@ -241,33 +241,15 @@ class CRM_Contact_Import_Form_Preview extends CRM_Import_Form_Preview {
     $importJob->isComplete();
   }
 
-  /**
-   * Get the mapped fields as an array of labels.
-   *
-   * e.g
-   * ['First Name', 'Employee Of - First Name', 'Home - Street Address']
-   *
-   * @return array
-   * @throws \API_Exception
-   * @throws \CRM_Core_Exception
-   */
-  protected function getMappedFieldLabels(): array {
-    $mapper = [];
-    $parser = new CRM_Contact_Import_Parser_Contact();
-    $parser->setUserJobID($this->getUserJobID());
-    foreach ($this->getSubmittedValue('mapper') as $columnNumber => $mappedField) {
-      $mapper[$columnNumber] = $parser->getMappedFieldLabel($parser->getMappingFieldFromMapperInput($mappedField, 0, $columnNumber));
-    }
-    return $mapper;
-  }
-
   /**
    * @return \CRM_Contact_Import_Parser_Contact
    */
   protected function getParser(): CRM_Contact_Import_Parser_Contact {
-    $parser = new CRM_Contact_Import_Parser_Contact();
-    $parser->setUserJobID($this->getUserJobID());
-    return $parser;
+    if (!$this->parser) {
+      $this->parser = new CRM_Contact_Import_Parser_Contact();
+      $this->parser->setUserJobID($this->getUserJobID());
+    }
+    return $this->parser;
   }
 
 }
index 33599f16f6a252e60c4510682ed32c411f3630e5..035f6abe4d452e574883977d0644f17acf6ff5e6 100644 (file)
@@ -88,9 +88,6 @@ class CRM_Contact_Import_ImportJob {
     $relatedContactIds = $this->_parser->getRelatedImportedContacts();
     if ($relatedContactIds) {
       $contactIds = array_merge($contactIds, $relatedContactIds);
-      if ($form) {
-        $form->set('relatedCount', count($relatedContactIds));
-      }
     }
 
     if ($this->_newGroupName || count($this->_groups)) {
index 6b120617fd43a5efec06ab08ed4733be230d4646..5092ac1da60abebd6dcae062ecc66fdeff660917 100644 (file)
@@ -11,6 +11,7 @@
 
 use Civi\Api4\Contact;
 use Civi\Api4\RelationshipType;
+use Civi\Api4\StateProvince;
 
 require_once 'api/v3/utils.php';
 
@@ -28,29 +29,14 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
   use CRM_Contact_Import_MetadataTrait;
 
   protected $_mapperKeys = [];
-
-  /**
-   * Is update only permitted on an id match.
-   *
-   * Note this historically was true for when id or external identifier was
-   * present. However, CRM-17275 determined that a dedupe-match could over-ride
-   * external identifier.
-   *
-   * @var bool
-   */
-  protected $_updateWithId;
-  protected $_retCode;
-
-  protected $_externalIdentifierIndex;
   protected $_allExternalIdentifiers = [];
-  protected $_parseStreetAddress;
 
   /**
    * Array of successfully imported contact id's
    *
    * @var array
    */
-  protected $_newContacts;
+  protected $_newContacts = [];
 
   /**
    * Line count id.
@@ -75,34 +61,8 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
    */
   protected $_rowCount;
 
-  protected $_primaryKeyName;
-  protected $_statusFieldName;
-
   protected $fieldMetadata = [];
 
-  /**
-   * Fields which are being handled by metadata formatting & validation functions.
-   *
-   * This is intended as a temporary parameter as we phase in metadata handling.
-   *
-   * The end result is that all fields will be & this will go but for now it is
-   * opt in.
-   *
-   * @var string[]
-   */
-  protected $metadataHandledFields = [
-    'contact_type',
-    'contact_sub_type',
-    'gender_id',
-    'birth_date',
-    'deceased_date',
-    'is_deceased',
-    'prefix_id',
-    'suffix_id',
-    'communication_style',
-    'preferred_language',
-  ];
-
   /**
    * Relationship labels.
    *
@@ -128,14 +88,11 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
   public $_dedupeRuleGroupID = NULL;
 
   /**
-   * Class constructor.
+   * Addresses that failed to parse.
    *
-   * @param array $mapperKeys
+   * @var array
    */
-  public function __construct($mapperKeys = []) {
-    parent::__construct();
-    $this->_mapperKeys = $mapperKeys;
-  }
+  private $_unparsedStreetAddressContacts = [];
 
   /**
    * The initializer code, called before processing.
@@ -145,26 +102,13 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
     foreach ($this->getImportableFieldsMetadata() as $name => $field) {
       $this->addField($name, $field['title'], CRM_Utils_Array::value('type', $field), CRM_Utils_Array::value('headerPattern', $field), CRM_Utils_Array::value('dataPattern', $field), CRM_Utils_Array::value('hasLocationType', $field));
     }
-    $this->_newContacts = [];
-
-    $this->setActiveFields($this->_mapperKeys);
-
-    $this->_externalIdentifierIndex = -1;
-
-    $index = 0;
-    foreach ($this->_mapperKeys as $key) {
-      if ($key == 'external_identifier') {
-        $this->_externalIdentifierIndex = $index;
-      }
-      $index++;
-    }
-
-    $this->_updateWithId = FALSE;
-    if (in_array('id', $this->_mapperKeys) || ($this->_externalIdentifierIndex >= 0 && $this->isUpdateExistingContacts())) {
-      $this->_updateWithId = TRUE;
-    }
+  }
 
-    $this->_parseStreetAddress = CRM_Utils_Array::value('street_address_parsing', CRM_Core_BAO_Setting::valueOptions(CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'address_options'), FALSE);
+  /**
+   * Is street address parsing enabled for the site.
+   */
+  protected function isParseStreetAddress() : bool {
+    return (bool) (CRM_Core_BAO_Setting::valueOptions(CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'address_options')['street_address_parsing'] ?? FALSE);
   }
 
   /**
@@ -224,7 +168,7 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
    *   CRM_Import_Parser::ERROR or CRM_Import_Parser::VALID
    */
   public function summary(&$values): int {
-    $rowNumber = (int) ($values[count($values) - 1]);
+    $rowNumber = (int) ($values[array_key_last($values)]);
     try {
       $this->validateValues($values);
     }
@@ -264,44 +208,37 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
    * @throws \API_Exception
    */
   public function import($onDuplicate, &$values) {
+    $rowNumber = (int) $values[array_key_last($values)];
     $this->_unparsedStreetAddressContacts = [];
     if (!$this->getSubmittedValue('doGeocodeAddress')) {
       // CRM-5854, reset the geocode method to null to prevent geocoding
       CRM_Utils_GeocodeProvider::disableForSession();
     }
 
-    // first make sure this is a valid line
-    //$this->_updateWithId = false;
-    $response = $this->summary($values);
-
-    if ($response != CRM_Import_Parser::VALID) {
-      $this->setImportStatus((int) $values[count($values) - 1], 'Invalid', "Invalid (Error Code: $response)");
-      return $response;
-    }
-
-    $params = $this->getMappedRow($values);
-    $formatted = array_filter(array_intersect_key($params, array_fill_keys($this->metadataHandledFields, 1)));
-
-    $contactFields = CRM_Contact_DAO_Contact::import();
-
-    $params['contact_sub_type'] = $this->getContactSubType() ?: ($params['contact_sub_type'] ?? NULL);
-
     try {
-      $params['id'] = $formatted['id'] = $this->lookupContactID($params, ($this->isSkipDuplicates() || $this->isIgnoreDuplicates()));
-      if ($params['id'] && $params['contact_sub_type']) {
-        $contactSubType = Contact::get(FALSE)
-          ->addWhere('id', '=', $params['id'])
-          ->addSelect('contact_sub_type')
-          ->execute()
-          ->first()['contact_sub_type'];
-        if (!empty($contactSubType) && $contactSubType[0] !== $params['contact_sub_type'] && !CRM_Contact_BAO_ContactType::isAllowEdit($params['id'], $contactSubType[0])) {
-          throw new CRM_Core_Exception('Mismatched contact SubTypes :', CRM_Import_Parser::NO_MATCH);
+      $params = $this->getMappedRow($values);
+      // it is questionable whether we need to do validate here
+      // - normally it has already been done in the form flow
+      // and generally only lines that passed that will
+      // get to the import function. It might be that it
+      // is really only here cos it used to be combined with getMappedRow
+      $this->validateParams($params);
+
+      $formatted = [];
+      foreach ($params as $key => $value) {
+        if ($value !== '') {
+          $formatted[$key] = $value;
         }
       }
+
+      $contactFields = CRM_Contact_DAO_Contact::import();
+
+      $params['contact_sub_type'] = $this->getContactSubType() ?: ($params['contact_sub_type'] ?? NULL);
+
+      [$formatted, $params] = $this->processContact($params, $formatted, TRUE);
     }
     catch (CRM_Core_Exception $e) {
-      $statuses = [CRM_Import_Parser::DUPLICATE => 'DUPLICATE', CRM_Import_Parser::ERROR => 'ERROR', CRM_Import_Parser::NO_MATCH => 'invalid_no_match'];
-      $this->setImportStatus((int) $values[count($values) - 1], $statuses[$e->getErrorCode()], $e->getMessage());
+      $this->setImportStatus($rowNumber, $this->getStatus($e->getErrorCode()), $e->getMessage());
       return FALSE;
     }
 
@@ -311,54 +248,13 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
     //format common data, CRM-4062
     $this->formatCommonData($params, $formatted, $contactFields);
 
-    $relationship = FALSE;
-
     //fixed CRM-4148
     //now we create new contact in update/fill mode also.
-    $contactID = NULL;
-    if (1) {
-      //CRM-4430, don't carry if not submitted.
-      if ($this->_updateWithId && !empty($params['id'])) {
-        $contactID = $params['id'];
-      }
-      $newContact = $this->createContact($formatted, $contactFields, $onDuplicate, $contactID, TRUE, $this->_dedupeRuleGroupID);
-    }
+    $newContact = $this->createContact($formatted, $contactFields, $onDuplicate, $params['id'] ?? NULL, TRUE, $this->_dedupeRuleGroupID);
 
-    if (isset($newContact) && is_object($newContact) && ($newContact instanceof CRM_Contact_BAO_Contact)) {
-      $relationship = TRUE;
-      $newContact = clone($newContact);
+    if (!is_array($newContact)) {
       $contactID = $newContact->id;
       $this->_newContacts[] = $contactID;
-
-      //get return code if we create new contact in update mode, CRM-4148
-      if ($this->_updateWithId) {
-        $this->_retCode = CRM_Import_Parser::VALID;
-      }
-    }
-    elseif (isset($newContact) && CRM_Core_Error::isAPIError($newContact, CRM_Core_Error::DUPLICATE_CONTACT)) {
-      // if duplicate, no need of further processing
-      if ($onDuplicate == CRM_Import_Parser::DUPLICATE_SKIP) {
-        $errorMessage = "Skipping duplicate record";
-        array_unshift($values, $errorMessage);
-        $this->setImportStatus((int) $values[count($values) - 1], 'DUPLICATE', $errorMessage);
-        return CRM_Import_Parser::DUPLICATE;
-      }
-
-      $relationship = TRUE;
-      // CRM-10433/CRM-20739 - IDs could be string or array; handle accordingly
-      if (!is_array($dupeContactIDs = $newContact['error_message']['params'][0])) {
-        $dupeContactIDs = explode(',', $dupeContactIDs);
-      }
-      $dupeCount = count($dupeContactIDs);
-      $contactID = array_pop($dupeContactIDs);
-      // check to see if we had more than one duplicate contact id.
-      // if we have more than one, the record will be rejected below
-      if ($dupeCount == 1) {
-        // there was only one dupe, we will continue normally...
-        if (!in_array($contactID, $this->_newContacts)) {
-          $this->_newContacts[] = $contactID;
-        }
-      }
     }
 
     if ($contactID) {
@@ -376,254 +272,44 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
       CRM_Utils_Hook::import('Contact', 'process', $this, $hookParams);
     }
 
-    if ($relationship) {
-      $primaryContactId = NULL;
-      if (CRM_Core_Error::isAPIError($newContact, CRM_Core_ERROR::DUPLICATE_CONTACT)) {
-        if ($dupeCount == 1 && CRM_Utils_Rule::integer($contactID)) {
-          $primaryContactId = $contactID;
-        }
-      }
-      else {
-        $primaryContactId = $newContact->id;
-      }
-
-      if ((CRM_Core_Error::isAPIError($newContact, CRM_Core_ERROR::DUPLICATE_CONTACT) || is_a($newContact, 'CRM_Contact_BAO_Contact')) && $primaryContactId) {
-
-        //relationship contact insert
-        foreach ($params as $key => $field) {
-          [$id, $first, $second] = CRM_Utils_System::explode('_', $key, 3);
-          if (!($first == 'a' && $second == 'b') && !($first == 'b' && $second == 'a')) {
-            continue;
-          }
-
-          $relationType = new CRM_Contact_DAO_RelationshipType();
-          $relationType->id = $id;
-          $relationType->find(TRUE);
-          $direction = "contact_sub_type_$second";
-
-          $formatting = [
-            'contact_type' => $params[$key]['contact_type'],
-          ];
+    $primaryContactId = $newContact->id;
 
-          //set subtype for related contact CRM-5125
-          if (isset($relationType->$direction)) {
-            //validation of related contact subtype for update mode
-            if ($relCsType = CRM_Utils_Array::value('contact_sub_type', $params[$key]) && $relCsType != $relationType->$direction) {
-              $errorMessage = ts("Mismatched or Invalid contact subtype found for this related contact.");
-              array_unshift($values, $errorMessage);
-              return CRM_Import_Parser::NO_MATCH;
-            }
-            else {
-              $formatting['contact_sub_type'] = $relationType->$direction;
-            }
-          }
-
-          $contactFields = NULL;
-          $contactFields = CRM_Contact_DAO_Contact::import();
-
-          //Relation on the basis of External Identifier.
-          if (empty($params[$key]['id']) && !empty($params[$key]['external_identifier'])) {
-            $params[$key]['id'] = CRM_Core_DAO::getFieldValue('CRM_Contact_DAO_Contact', $params[$key]['external_identifier'], 'id', 'external_identifier');
-          }
-          // check for valid related contact id in update/fill mode, CRM-4424
-          if (in_array($onDuplicate, [
-            CRM_Import_Parser::DUPLICATE_UPDATE,
-            CRM_Import_Parser::DUPLICATE_FILL,
-          ]) && !empty($params[$key]['id'])) {
-            $relatedContactType = CRM_Core_DAO::getFieldValue('CRM_Contact_DAO_Contact', $params[$key]['id'], 'contact_type');
-            if (!$relatedContactType) {
-              $errorMessage = ts("No contact found for this related contact ID: %1", [1 => $params[$key]['id']]);
-              array_unshift($values, $errorMessage);
-              return CRM_Import_Parser::NO_MATCH;
-            }
+    if ($primaryContactId) {
 
-            //validation of related contact subtype for update mode
-            //CRM-5125
-            $relatedCsType = NULL;
-            if (!empty($formatting['contact_sub_type'])) {
-              $relatedCsType = CRM_Core_DAO::getFieldValue('CRM_Contact_DAO_Contact', $params[$key]['id'], 'contact_sub_type');
-            }
-
-            if (!empty($relatedCsType) && (!CRM_Contact_BAO_ContactType::isAllowEdit($params[$key]['id'], $relatedCsType) &&
-                $relatedCsType != CRM_Utils_Array::value('contact_sub_type', $formatting))
-            ) {
-              $errorMessage = ts("Mismatched or Invalid contact subtype found for this related contact.") . ' ' . ts("ID: %1", [1 => $params[$key]['id']]);
-              array_unshift($values, $errorMessage);
-              return CRM_Import_Parser::NO_MATCH;
-            }
-            // get related contact id to format data in update/fill mode,
-            //if external identifier is present, CRM-4423
-            $formatting['id'] = $params[$key]['id'];
-          }
-
-          //format common data, CRM-4062
-          $this->formatCommonData($field, $formatting, $contactFields);
-
-          //fixed for CRM-4148
-          if (!empty($params[$key]['id'])) {
-            $contact = [
-              'contact_id' => $params[$key]['id'],
-            ];
-            $defaults = [];
-            $relatedNewContact = CRM_Contact_BAO_Contact::retrieve($contact, $defaults);
-          }
-          else {
-            $relatedNewContact = $this->createContact($formatting, $contactFields, $onDuplicate, NULL, FALSE);
-          }
+      //relationship contact insert
+      foreach ($this->getRelatedContactsParams($params) as $key => $field) {
+        $formatting = $field;
+        try {
+          [$formatting, $field] = $this->processContact($field, $formatting, FALSE);
+        }
+        catch (CRM_Core_Exception $e) {
+          $statuses = [CRM_Import_Parser::DUPLICATE => 'DUPLICATE', CRM_Import_Parser::ERROR => 'ERROR', CRM_Import_Parser::NO_MATCH => 'invalid_no_match'];
+          $this->setImportStatus((int) $values[count($values) - 1], $statuses[$e->getErrorCode()], $e->getMessage());
+          return FALSE;
+        }
 
-          if (is_object($relatedNewContact) || ($relatedNewContact instanceof CRM_Contact_BAO_Contact)) {
-            $relatedNewContact = clone($relatedNewContact);
-          }
+        $contactFields = CRM_Contact_DAO_Contact::import();
 
-          $matchedIDs = [];
-          // To update/fill contact, get the matching contact Ids if duplicate contact found
-          // otherwise get contact Id from object of related contact
-          if (is_array($relatedNewContact) && civicrm_error($relatedNewContact)) {
-            if (CRM_Core_Error::isAPIError($relatedNewContact, CRM_Core_ERROR::DUPLICATE_CONTACT)) {
-              $matchedIDs = $relatedNewContact['error_message']['params'][0];
-              if (!is_array($matchedIDs)) {
-                $matchedIDs = explode(',', $matchedIDs);
-              }
-            }
-            else {
-              $errorMessage = $relatedNewContact['error_message'];
-              array_unshift($values, $errorMessage);
-              $this->setImportStatus((int) $values[count($values) - 1], 'ERROR', $errorMessage);
-              return CRM_Import_Parser::ERROR;
-            }
-          }
-          else {
-            $matchedIDs[] = $relatedNewContact->id;
-          }
-          // update/fill related contact after getting matching Contact Ids, CRM-4424
-          if (in_array($onDuplicate, [
-            CRM_Import_Parser::DUPLICATE_UPDATE,
-            CRM_Import_Parser::DUPLICATE_FILL,
-          ])) {
-            //validation of related contact subtype for update mode
-            //CRM-5125
-            $relatedCsType = NULL;
-            if (!empty($formatting['contact_sub_type'])) {
-              $relatedCsType = CRM_Core_DAO::getFieldValue('CRM_Contact_DAO_Contact', $matchedIDs[0], 'contact_sub_type');
-            }
+        //format common data, CRM-4062
+        $this->formatCommonData($field, $formatting, $contactFields);
 
-            if (!empty($relatedCsType) && (!CRM_Contact_BAO_ContactType::isAllowEdit($matchedIDs[0], $relatedCsType) && $relatedCsType != CRM_Utils_Array::value('contact_sub_type', $formatting))) {
-              $errorMessage = ts("Mismatched or Invalid contact subtype found for this related contact.");
-              array_unshift($values, $errorMessage);
-              return CRM_Import_Parser::NO_MATCH;
-            }
-            else {
-              $updatedContact = $this->createContact($formatting, $contactFields, $onDuplicate, $matchedIDs[0]);
-            }
+        if (empty($formatting['id']) || $this->isUpdateExistingContacts()) {
+          try {
+            $relatedNewContact = $this->createContact($formatting, $contactFields, $onDuplicate, $formatting['id']);
           }
-          static $relativeContact = [];
-          if (CRM_Core_Error::isAPIError($relatedNewContact, CRM_Core_ERROR::DUPLICATE_CONTACT)) {
-            if (count($matchedIDs) >= 1) {
-              $relContactId = $matchedIDs[0];
-              //add relative contact to count during update & fill mode.
-              //logic to make count distinct by contact id.
-              if ($this->_newRelatedContacts || !empty($relativeContact)) {
-                $reContact = array_keys($relativeContact, $relContactId);
-
-                if (empty($reContact)) {
-                  $this->_newRelatedContacts[] = $relativeContact[] = $relContactId;
-                }
-              }
-              else {
-                $this->_newRelatedContacts[] = $relativeContact[] = $relContactId;
-              }
-            }
-          }
-          else {
-            $relContactId = $relatedNewContact->id;
-            $this->_newRelatedContacts[] = $relativeContact[] = $relContactId;
-          }
-
-          if (CRM_Core_Error::isAPIError($relatedNewContact, CRM_Core_ERROR::DUPLICATE_CONTACT) || ($relatedNewContact instanceof CRM_Contact_BAO_Contact)) {
-            //fix for CRM-1993.Checks for duplicate related contacts
-            if (count($matchedIDs) >= 1) {
-              //if more than one duplicate contact
-              //found, create relationship with first contact
-              // now create the relationship record
-              $relationParams = [
-                'relationship_type_id' => $key,
-                'contact_check' => [
-                  $relContactId => 1,
-                ],
-                'is_active' => 1,
-                'skipRecentView' => TRUE,
-              ];
-
-              // we only handle related contact success, we ignore failures for now
-              // at some point wold be nice to have related counts as separate
-              $relationIds = [
-                'contact' => $primaryContactId,
-              ];
-
-              [$valid, $duplicate] = self::legacyCreateMultiple($relationParams, $relationIds);
-
-              if ($valid || $duplicate) {
-                $relationIds['contactTarget'] = $relContactId;
-                $action = ($duplicate) ? CRM_Core_Action::UPDATE : CRM_Core_Action::ADD;
-                CRM_Contact_BAO_Relationship::relatedMemberships($primaryContactId, $relationParams, $relationIds, $action);
-              }
-
-              //handle current employer, CRM-3532
-              if ($valid) {
-                $allRelationships = CRM_Core_PseudoConstant::relationshipType('name');
-                $relationshipTypeId = str_replace([
-                  '_a_b',
-                  '_b_a',
-                ], [
-                  '',
-                  '',
-                ], $key);
-                $relationshipType = str_replace($relationshipTypeId . '_', '', $key);
-                $orgId = $individualId = NULL;
-                if ($allRelationships[$relationshipTypeId]["name_{$relationshipType}"] == 'Employee of') {
-                  $orgId = $relContactId;
-                  $individualId = $primaryContactId;
-                }
-                elseif ($allRelationships[$relationshipTypeId]["name_{$relationshipType}"] == 'Employer of') {
-                  $orgId = $primaryContactId;
-                  $individualId = $relContactId;
-                }
-                if ($orgId && $individualId) {
-                  $currentEmpParams[$individualId] = $orgId;
-                  CRM_Contact_BAO_Contact_Utils::setCurrentEmployer($currentEmpParams);
-                }
-              }
-            }
+          catch (CiviCRM_API3_Exception $e) {
+            $this->setImportStatus($rowNumber, 'ERROR', $e->getMessage());
+            return FALSE;
           }
+          $relContactId = $relatedNewContact->id;
+          $this->_newRelatedContacts[$relContactId] = $relContactId;
         }
+        $this->createRelationship($key, $relContactId, $primaryContactId);
       }
     }
-    if ($this->_updateWithId) {
-      //return warning if street address is unparsed, CRM-5886
-      return $this->processMessage($values, $this->_retCode);
-    }
-    //dupe checking
-    if (is_array($newContact) && civicrm_error($newContact)) {
-      $code = NULL;
-
-      if (($code = CRM_Utils_Array::value('code', $newContact['error_message'])) && ($code == CRM_Core_Error::DUPLICATE_CONTACT)) {
-        return $this->handleDuplicateError($newContact, $values, $onDuplicate, $formatted, $contactFields);
-      }
-      // Not a dupe, so we had an error
-      $errorMessage = $newContact['error_message'];
-      array_unshift($values, $errorMessage);
-      $this->setImportStatus((int) $values[count($values) - 1], 'ERROR', $errorMessage);
-      return CRM_Import_Parser::ERROR;
-
-    }
 
-    if (empty($this->_unparsedStreetAddressContacts)) {
-      $this->setImportStatus((int) ($values[count($values) - 1]), 'IMPORTED', '', $contactID);
-      return CRM_Import_Parser::VALID;
-    }
-
-    // @todo - record unparsed address as 'imported' but the presence of a message is meaningful?
-    return $this->processMessage($values, CRM_Import_Parser::VALID);
+    $this->setImportStatus($rowNumber, $this->getStatus(CRM_Import_Parser::VALID), $this->getSuccessMessage(), $contactID);
+    return CRM_Import_Parser::VALID;
   }
 
   /**
@@ -693,16 +379,28 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
   }
 
   /**
-   * Format common params data to proper format to store.
+   * Format common params data to the format that was required a very long time ago.
+   *
+   * I think the only useful things this function does now are
+   *  1) calls fillPrimary
+   *  2) possibly the street address parsing.
+   *
+   * The other hundred lines do stuff that is done elsewhere. Custom fields
+   * should already be formatted by getTransformedValue and we don't need to
+   * re-rewrite them to a BAO style array since we call the api which does that.
+   *
+   * The call to formatLocationBlock just does the address custom fields which,
+   * are already formatted by this point.
+   *
+   * @deprecated
    *
    * @param array $params
    *   Contain record values.
    * @param array $formatted
    *   Array of formatted data.
-   * @param array $contactFields
-   *   Contact DAO fields.
    */
-  private function formatCommonData($params, &$formatted, $contactFields) {
+  private function formatCommonData($params, &$formatted) {
+    // @todo - remove just about everything in this function. See docblock.
     $customFields = CRM_Core_BAO_CustomField::getFields($formatted['contact_type'], FALSE, FALSE, $formatted['contact_sub_type'] ?? NULL);
 
     $addressCustomFields = CRM_Core_BAO_CustomField::getFields('Address');
@@ -732,11 +430,23 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
         }
       }
     }
-
+    $metadataBlocks = ['phone', 'im', 'openid', 'email', 'address'];
+    foreach ($metadataBlocks as $block) {
+      foreach ($formatted[$block] ?? [] as $blockKey => $blockValues) {
+        if ($blockValues['location_type_id'] === 'Primary') {
+          $this->fillPrimary($formatted[$block][$blockKey], $blockValues, $block, $formatted['id'] ?? NULL);
+        }
+      }
+    }
     //now format custom data.
     foreach ($params as $key => $field) {
+      if (in_array($key, $metadataBlocks, TRUE)) {
+        // This location block is already fully handled at this point.
+        continue;
+      }
       if (is_array($field)) {
         $isAddressCustomField = FALSE;
+
         foreach ($field as $value) {
           $break = FALSE;
           if (is_array($value)) {
@@ -745,8 +455,8 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
                 $isAddressCustomField = TRUE;
                 break;
               }
-              // check if $value does not contain IM provider or phoneType
-              if (($name !== 'phone_type_id' || $name !== 'provider_id') && ($testForEmpty === '' || $testForEmpty == NULL)) {
+
+              if (($testForEmpty === '' || $testForEmpty == NULL)) {
                 $break = TRUE;
                 break;
               }
@@ -771,13 +481,6 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
         $key => $field,
       ];
 
-      if (($key !== 'preferred_communication_method') && (array_key_exists($key, $contactFields))) {
-        // due to merging of individual table and
-        // contact table, we need to avoid
-        // preferred_communication_method forcefully
-        $formatValues['contact_type'] = $formatted['contact_type'];
-      }
-
       if ($key == 'id' && isset($field)) {
         $formatted[$key] = $field;
       }
@@ -836,21 +539,8 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
       _civicrm_api3_custom_format_params($params, $formatted, $extends);
     }
 
-    // to check if not update mode and unset the fields with empty value.
-    if (!$this->_updateWithId && array_key_exists('custom', $formatted)) {
-      foreach ($formatted['custom'] as $customKey => $customvalue) {
-        if (empty($formatted['custom'][$customKey][-1]['is_required'])) {
-          $formatted['custom'][$customKey][-1]['is_required'] = $customFields[$customKey]['is_required'];
-        }
-        $emptyValue = $customvalue[-1]['value'] ?? NULL;
-        if (!isset($emptyValue)) {
-          unset($formatted['custom'][$customKey]);
-        }
-      }
-    }
-
     // parse street address, CRM-5450
-    if ($this->_parseStreetAddress) {
+    if ($this->isParseStreetAddress()) {
       if (array_key_exists('address', $formatted) && is_array($formatted['address'])) {
         foreach ($formatted['address'] as $instance => & $address) {
           $streetAddress = $address['street_address'] ?? NULL;
@@ -892,347 +582,141 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
   }
 
   /**
-   * Check if an error in custom data.
+   * Build error-message containing error-fields
    *
-   * @param array $params
+   * Once upon a time there was a dev who hadn't heard of implode. That dev wrote this function.
+   *
+   * @todo just say no!
+   *
+   * @param string $errorName
+   *   A string containing error-field name.
    * @param string $errorMessage
-   *   A string containing all the error-fields.
+   *   A string containing all the error-fields, where the new errorName is concatenated.
    *
-   * @param null $csType
    */
-  public static function isErrorInCustomData($params, &$errorMessage, $csType = NULL) {
-    $dateType = CRM_Core_Session::singleton()->get("dateTypes");
-    $errors = [];
-
-    if (!empty($params['contact_sub_type'])) {
-      $csType = $params['contact_sub_type'] ?? NULL;
-    }
-
-    if (empty($params['contact_type'])) {
-      $params['contact_type'] = 'Individual';
-    }
-
-    // get array of subtypes - CRM-18708
-    if (in_array($csType, CRM_Contact_BAO_ContactType::basicTypes(TRUE), TRUE)) {
-      $csType = self::getSubtypes($params['contact_type']);
-    }
-
-    if (is_array($csType)) {
-      // fetch custom fields for every subtype and add it to $customFields array
-      // CRM-18708
-      $customFields = [];
-      foreach ($csType as $cType) {
-        $customFields += CRM_Core_BAO_CustomField::getFields($params['contact_type'], FALSE, FALSE, $cType);
-      }
+  public static function addToErrorMsg($errorName, &$errorMessage) {
+    if ($errorMessage) {
+      $errorMessage .= "; $errorName";
     }
     else {
-      $customFields = CRM_Core_BAO_CustomField::getFields($params['contact_type'], FALSE, FALSE, $csType);
-    }
-
-    $addressCustomFields = CRM_Core_BAO_CustomField::getFields('Address');
-    $parser = new CRM_Contact_Import_Parser_Contact();
-    foreach ($params as $key => $value) {
-      if ($customFieldID = CRM_Core_BAO_CustomField::getKeyID($key)) {
-        //For address custom fields, we do get actual custom field value as an inner array of
-        //values so need to modify
-        if (array_key_exists($customFieldID, $addressCustomFields)) {
-          $value = $value[0][$key];
-          $errors[] = $parser->validateCustomField($customFieldID, $value, $addressCustomFields[$customFieldID], $dateType);
-        }
-        else {
-          if (!array_key_exists($customFieldID, $customFields)) {
-            return ts('field ID');
-          }
-          /* check if it's a valid custom field id */
-          $errors[] = $parser->validateCustomField($customFieldID, $value, $customFields[$customFieldID], $dateType);
-        }
-      }
-      elseif (is_array($params[$key]) && isset($params[$key]["contact_type"]) && in_array(substr($key, -3), ['a_b', 'b_a'], TRUE)) {
-        //CRM-5125
-        //supporting custom data of related contact subtypes
-        $relation = $key;
-        if (!empty($relation)) {
-          [$id, $first, $second] = CRM_Utils_System::explode('_', $relation, 3);
-          $direction = "contact_sub_type_$second";
-          $relationshipType = new CRM_Contact_BAO_RelationshipType();
-          $relationshipType->id = $id;
-          if ($relationshipType->find(TRUE)) {
-            if (isset($relationshipType->$direction)) {
-              $params[$key]['contact_sub_type'] = $relationshipType->$direction;
-            }
-          }
-        }
-
-        self::isErrorInCustomData($params[$key], $errorMessage, $csType);
-      }
-    }
-    if ($errors) {
-      $errorMessage .= ($errorMessage ? '; ' : '') . implode('; ', array_filter($errors));
+      $errorMessage = $errorName;
     }
   }
 
   /**
-   * Check if an error in Core( non-custom fields ) field
-   *
    * @param array $params
-   * @param string $errorMessage
-   *   A string containing all the error-fields.
+   *
+   * @return string|null
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   * @throws \Civi\API\Exception\NotImplementedException
    */
-  public function isErrorInCoreData($params, &$errorMessage) {
+  protected function validateParams(array $params): ?string {
+    $contacts = array_merge(['0' => $params], $this->getRelatedContactsParams($params));
     $errors = [];
-    if (!empty($params['contact_sub_type']) && !CRM_Contact_BAO_ContactType::isExtendsContactType($params['contact_sub_type'], $params['contact_type'])) {
-      $errors[] = ts('Mismatched or Invalid Contact Subtype.');
-    }
-
-    foreach ($params as $key => $value) {
-      if ($value === 'invalid_import_value') {
-        $errors[] = $this->getFieldMetadata($key)['title'];
-      }
-      if ($value) {
-
-        switch ($key) {
-          case 'preferred_communication_method':
-            $preffComm = [];
-            $preffComm = explode(',', $value);
-            foreach ($preffComm as $v) {
-              if (!self::in_value(trim($v), CRM_Core_PseudoConstant::get('CRM_Contact_DAO_Contact', 'preferred_communication_method'))) {
-                $errors[] = ts('Preferred Communication Method');
-              }
-            }
-            break;
+    foreach ($contacts as $value) {
+      // If we are referencing a related contact, or are in update mode then we
+      // don't need all the required fields if we have enough to find an existing contact.
+      $useExistingMatchFields = !empty($value['relationship_type_id']) || $this->isUpdateExistingContacts();
+      $prefixString = !empty($value['relationship_label']) ? '(' . $value['relationship_label'] . ') ' : '';
+      $this->validateRequiredContactFields($value['contact_type'], $value, $useExistingMatchFields, $prefixString);
 
-          case 'preferred_mail_format':
-            if (!array_key_exists(strtolower($value), array_change_key_case(CRM_Core_SelectValues::pmf(), CASE_LOWER))) {
-              $errors[] = ts('Preferred Mail Format');
-            }
-            break;
-
-          case 'state_province':
-            if (!empty($value)) {
-              foreach ($value as $stateValue) {
-                if ($stateValue['state_province']) {
-                  if (self::in_value($stateValue['state_province'], CRM_Core_PseudoConstant::stateProvinceAbbreviation()) ||
-                    self::in_value($stateValue['state_province'], CRM_Core_PseudoConstant::stateProvince())
-                  ) {
-                    continue;
-                  }
-                  else {
-                    $errors[] = ts('State/Province');
-                  }
-                }
-              }
-            }
-            break;
-
-          case 'country':
-            if (!empty($value)) {
-              foreach ($value as $stateValue) {
-                if ($stateValue['country']) {
-                  CRM_Core_PseudoConstant::populate($countryNames, 'CRM_Core_DAO_Country', TRUE, 'name', 'is_active');
-                  CRM_Core_PseudoConstant::populate($countryIsoCodes, 'CRM_Core_DAO_Country', TRUE, 'iso_code');
-                  $limitCodes = CRM_Core_BAO_Country::countryLimit();
-                  //If no country is selected in
-                  //localization then take all countries
-                  if (empty($limitCodes)) {
-                    $limitCodes = $countryIsoCodes;
-                  }
+      $errors = array_merge($errors, $this->getInvalidValuesForContact($value, $prefixString));
+      if (!empty($value['contact_sub_type']) && !CRM_Contact_BAO_ContactType::isExtendsContactType($value['contact_sub_type'], $value['contact_type'])) {
+        $errors[] = ts('Mismatched or Invalid Contact Subtype.');
+      }
+      if (!empty($value['relationship_type_id'])) {
+        $requiredSubType = $this->getRelatedContactSubType($value['relationship_type_id'], $value['relationship_direction']);
+        if ($requiredSubType && $value['contact_sub_type'] && $requiredSubType !== $value['contact_sub_type']) {
+          throw new CRM_Core_Exception($prefixString . ts('Mismatched or Invalid contact subtype found for this related contact.'));
+        }
+      }
+    }
 
-                  if (self::in_value($stateValue['country'], $limitCodes) || self::in_value($stateValue['country'], CRM_Core_PseudoConstant::country())) {
-                    continue;
-                  }
-                  if (self::in_value($stateValue['country'], $countryIsoCodes) || self::in_value($stateValue['country'], $countryNames)) {
-                    $errors[] = ts('Country input value is in table but not "available": "This Country is valid but is NOT in the list of Available Countries currently configured for your site. This can be viewed and modifed from Administer > Localization > Languages Currency Locations." ');
-                  }
-                  else {
-                    $errors[] = ts('Country input value not in country table: "The Country value appears to be invalid. It does not match any value in CiviCRM table of countries."');
-                  }
-                }
-              }
-            }
-            break;
-
-          case 'county':
-            if (!empty($value)) {
-              foreach ($value as $county) {
-                if ($county['county']) {
-                  $countyNames = CRM_Core_PseudoConstant::county();
-                  if (!empty($county['county']) && !in_array($county['county'], $countyNames)) {
-                    $errors[] = ts('County input value not in county table: The County value appears to be invalid. It does not match any value in CiviCRM table of counties.');
-                  }
-                }
-              }
-            }
-            break;
-
-          case 'geo_code_1':
-            if (!empty($value)) {
-              foreach ($value as $codeValue) {
-                if (!empty($codeValue['geo_code_1'])) {
-                  if (CRM_Utils_Rule::numeric($codeValue['geo_code_1'])) {
-                    continue;
-                  }
-                  $errors[] = ts('Geo code 1');
-                }
-              }
-            }
-            break;
-
-          case 'geo_code_2':
-            if (!empty($value)) {
-              foreach ($value as $codeValue) {
-                if (!empty($codeValue['geo_code_2'])) {
-                  if (CRM_Utils_Rule::numeric($codeValue['geo_code_2'])) {
-                    continue;
-                  }
-                  $errors[] = ts('Geo code 2');
-                }
-              }
-            }
-            break;
-
-          //check for any error in email/postal greeting, addressee,
-          //custom email/postal greeting, custom addressee, CRM-4575
-
-          case 'email_greeting':
-            $emailGreetingFilter = [
-              'contact_type' => $this->_contactType,
-              'greeting_type' => 'email_greeting',
-            ];
-            if (!self::in_value($value, CRM_Core_PseudoConstant::greeting($emailGreetingFilter))) {
-              $errors[] = ts('Email Greeting must be one of the configured format options. Check Administer >> System Settings >> Option Groups >> Email Greetings for valid values');
-            }
-            break;
-
-          case 'postal_greeting':
-            $postalGreetingFilter = [
-              'contact_type' => $this->_contactType,
-              'greeting_type' => 'postal_greeting',
-            ];
-            if (!self::in_value($value, CRM_Core_PseudoConstant::greeting($postalGreetingFilter))) {
-              $errors[] = ts('Postal Greeting must be one of the configured format options. Check Administer >> System Settings >> Option Groups >> Postal Greetings for valid values');
-            }
-            break;
-
-          case 'addressee':
-            $addresseeFilter = [
-              'contact_type' => $this->_contactType,
-              'greeting_type' => 'addressee',
-            ];
-            if (!self::in_value($value, CRM_Core_PseudoConstant::greeting($addresseeFilter))) {
-              $errors[] = ts('Addressee must be one of the configured format options. Check Administer >> System Settings >> Option Groups >> Addressee for valid values');
-            }
-            break;
+    //check for duplicate external Identifier
+    $externalID = $params['external_identifier'] ?? NULL;
+    if ($externalID) {
+      /* If it's a dupe,external Identifier  */
 
-          case 'email_greeting_custom':
-            if (array_key_exists('email_greeting', $params)) {
-              $emailGreetingLabel = key(CRM_Core_OptionGroup::values('email_greeting', TRUE, NULL, NULL, 'AND v.name = "Customized"'));
-              if (CRM_Utils_Array::value('email_greeting', $params) != $emailGreetingLabel) {
-                $errors[] = ts('Email Greeting - Custom');
-              }
-            }
-            break;
+      if ($externalDupe = CRM_Utils_Array::value($externalID, $this->_allExternalIdentifiers)) {
+        $errorMessage = ts('External ID conflicts with record %1', [1 => $externalDupe]);
+        throw new CRM_Core_Exception($errorMessage);
+      }
+      //otherwise, count it and move on
+      $this->_allExternalIdentifiers[$externalID] = $this->_lineCount;
+    }
 
-          case 'postal_greeting_custom':
-            if (array_key_exists('postal_greeting', $params)) {
-              $postalGreetingLabel = key(CRM_Core_OptionGroup::values('postal_greeting', TRUE, NULL, NULL, 'AND v.name = "Customized"'));
-              if (CRM_Utils_Array::value('postal_greeting', $params) != $postalGreetingLabel) {
-                $errors[] = ts('Postal Greeting - Custom');
-              }
-            }
-            break;
+    //date-format part ends
 
-          case 'addressee_custom':
-            if (array_key_exists('addressee', $params)) {
-              $addresseeLabel = key(CRM_Core_OptionGroup::values('addressee', TRUE, NULL, NULL, 'AND v.name = "Customized"'));
-              if (CRM_Utils_Array::value('addressee', $params) != $addresseeLabel) {
-                $errors[] = ts('Addressee - Custom');
-              }
-            }
-            break;
-
-          case 'url':
-            if (is_array($value)) {
-              foreach ($value as $values) {
-                if (!empty($values['url']) && !CRM_Utils_Rule::url($values['url'])) {
-                  $errors[] = ts('Website');
-                  break;
-                }
-              }
-            }
-            break;
-
-          case 'do_not_email':
-          case 'do_not_phone':
-          case 'do_not_mail':
-          case 'do_not_sms':
-          case 'do_not_trade':
-            if (CRM_Utils_Rule::boolean($value) == FALSE) {
-              $key = ucwords(str_replace("_", " ", $key));
-              $errors[] = $key;
-            }
-            break;
-
-          case 'email':
-            if (is_array($value)) {
-              foreach ($value as $values) {
-                if (!empty($values['email']) && !CRM_Utils_Rule::email($values['email'])) {
-                  $errors[] = $key;
-                  break;
-                }
-              }
-            }
-            break;
+    $errorMessage = implode(', ', $errors);
 
-          default:
-            if (is_array($params[$key]) && isset($params[$key]["contact_type"])) {
-              //check for any relationship data ,FIX ME
-              self::isErrorInCoreData($params[$key], $errorMessage);
-            }
-        }
-      }
-    }
-    if ($errors) {
-      $errorMessage .= ($errorMessage ? '; ' : '') . implode('; ', $errors);
+    //checking error in core data
+    if ($errorMessage) {
+      $tempMsg = "Invalid value for field(s) : $errorMessage";
+      throw new CRM_Core_Exception($tempMsg);
     }
+    return $errorMessage;
   }
 
   /**
-   * Ckeck a value present or not in a array.
+   * @param $key
+   * @param $relContactId
+   * @param $primaryContactId
    *
-   * @param $value
-   * @param $valueArray
-   *
-   * @return bool
+   * @throws \CRM_Core_Exception
+   * @throws \CiviCRM_API3_Exception
    */
-  public static function in_value($value, $valueArray) {
-    foreach ($valueArray as $key => $v) {
-      //fix for CRM-1514
-      if (strtolower(trim($v, ".")) == strtolower(trim($value, "."))) {
-        return TRUE;
-      }
-    }
-    return FALSE;
-  }
+  protected function createRelationship($key, $relContactId, $primaryContactId): void {
+    //if more than one duplicate contact
+    //found, create relationship with first contact
+    // now create the relationship record
+    $relationParams = [
+      'relationship_type_id' => $key,
+      'contact_check' => [
+        $relContactId => 1,
+      ],
+      'is_active' => 1,
+      'skipRecentView' => TRUE,
+    ];
 
-  /**
-   * Build error-message containing error-fields
-   *
-   * Once upon a time there was a dev who hadn't heard of implode. That dev wrote this function.
-   *
-   * @todo just say no!
-   *
-   * @param string $errorName
-   *   A string containing error-field name.
-   * @param string $errorMessage
-   *   A string containing all the error-fields, where the new errorName is concatenated.
-   *
-   */
-  public static function addToErrorMsg($errorName, &$errorMessage) {
-    if ($errorMessage) {
-      $errorMessage .= "; $errorName";
+    // we only handle related contact success, we ignore failures for now
+    // at some point wold be nice to have related counts as separate
+    $relationIds = [
+      'contact' => $primaryContactId,
+    ];
+
+    [$valid, $duplicate] = self::legacyCreateMultiple($relationParams, $relationIds);
+
+    if ($valid || $duplicate) {
+      $relationIds['contactTarget'] = $relContactId;
+      $action = ($duplicate) ? CRM_Core_Action::UPDATE : CRM_Core_Action::ADD;
+      CRM_Contact_BAO_Relationship::relatedMemberships($primaryContactId, $relationParams, $relationIds, $action);
     }
-    else {
-      $errorMessage = $errorName;
+
+    //handle current employer, CRM-3532
+    if ($valid) {
+      $allRelationships = CRM_Core_PseudoConstant::relationshipType('name');
+      $relationshipTypeId = str_replace([
+        '_a_b',
+        '_b_a',
+      ], [
+        '',
+        '',
+      ], $key);
+      $relationshipType = str_replace($relationshipTypeId . '_', '', $key);
+      $orgId = $individualId = NULL;
+      if ($allRelationships[$relationshipTypeId]["name_{$relationshipType}"] == 'Employee of') {
+        $orgId = $relContactId;
+        $individualId = $primaryContactId;
+      }
+      elseif ($allRelationships[$relationshipTypeId]["name_{$relationshipType}"] == 'Employer of') {
+        $orgId = $primaryContactId;
+        $individualId = $relContactId;
+      }
+      if ($orgId && $individualId) {
+        $currentEmpParams[$individualId] = $orgId;
+        CRM_Contact_BAO_Contact_Utils::setCurrentEmployer($currentEmpParams);
+      }
     }
   }
 
@@ -1246,31 +730,10 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
    * @param bool $requiredCheck
    * @param int $dedupeRuleGroupID
    *
-   * @return array|bool|\CRM_Contact_BAO_Contact|\CRM_Core_Error|null
+   * @return \CRM_Contact_BAO_Contact
+   *   If a duplicate is found an array is returned, otherwise CRM_Contact_BAO_Contact
    */
   public function createContact(&$formatted, &$contactFields, $onDuplicate, $contactId = NULL, $requiredCheck = TRUE, $dedupeRuleGroupID = NULL) {
-    $dupeCheck = FALSE;
-    $newContact = NULL;
-
-    if (is_null($contactId) && ($onDuplicate != CRM_Import_Parser::DUPLICATE_NOCHECK)) {
-      $dupeCheck = (bool) ($onDuplicate);
-    }
-
-    //get the prefix id etc if exists
-    CRM_Contact_BAO_Contact::resolveDefaults($formatted, TRUE);
-
-    //@todo direct call to API function not supported.
-    // setting required check to false, CRM-2839
-    // plus we do our own required check in import
-    try {
-      $error = $this->deprecated_contact_check_params($formatted, $dupeCheck, $dedupeRuleGroupID);
-      if ($error) {
-        return $error;
-      }
-    }
-    catch (CRM_Core_Exception $e) {
-      return ['error_message' => $e->getMessage(), 'is_error' => 1, 'code' => $e->getCode()];
-    }
 
     if ($contactId) {
       $this->formatParams($formatted, $onDuplicate, (int) $contactId);
@@ -1321,7 +784,7 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
     $newContact = CRM_Contact_BAO_Contact::retrieve($contact, $defaults);
 
     //get the id of the contact whose street address is not parsable, CRM-5886
-    if ($this->_parseStreetAddress && is_object($newContact) && property_exists($newContact, 'address') && $newContact->address) {
+    if ($this->isParseStreetAddress() && property_exists($newContact, 'address') && $newContact->address) {
       foreach ($newContact->address as $address) {
         if (!empty($address['street_address']) && (empty($address['street_number']) || empty($address['street_name']))) {
           $this->_unparsedStreetAddressContacts[] = [
@@ -1421,32 +884,15 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
 
     $billingLocationTypeId = CRM_Core_BAO_LocationType::getBilling();
 
-    $blocks = ['email', 'phone', 'im', 'openid'];
-
     $multiplFields = ['url'];
-    // prevent overwritten of formatted array, reset all block from
-    // params if it is not in valid format (since import pass valid format)
-    foreach ($blocks as $blk) {
-      if (array_key_exists($blk, $params) &&
-        !is_array($params[$blk])
-      ) {
-        unset($params[$blk]);
-      }
-    }
 
-    $primaryPhoneLoc = NULL;
     $session = CRM_Core_Session::singleton();
     foreach ($params as $key => $value) {
       [$fieldName, $locTypeId, $typeId] = CRM_Utils_System::explode('-', $key, 3);
 
       if ($locTypeId == 'Primary') {
         if ($contactID) {
-          if (in_array($fieldName, $blocks)) {
-            $locTypeId = CRM_Contact_BAO_Contact::getPrimaryLocationType($contactID, FALSE, $fieldName);
-          }
-          else {
-            $locTypeId = CRM_Contact_BAO_Contact::getPrimaryLocationType($contactID, FALSE, 'address');
-          }
+          $locTypeId = CRM_Contact_BAO_Contact::getPrimaryLocationType($contactID, FALSE, 'address');
           $primaryLocationType = $locTypeId;
         }
         else {
@@ -1470,7 +916,7 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
 
         $loc = CRM_Utils_Array::key($index, $locationType);
 
-        $blockName = $this->getLocationEntityForKey($fieldName);
+        $blockName = strtolower($this->getFieldEntity($fieldName));
 
         $data[$blockName][$loc]['location_type_id'] = $locTypeId;
 
@@ -1489,44 +935,7 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
           $data[$blockName][$loc]['is_primary'] = 1;
         }
 
-        if (in_array($fieldName, ['phone'])) {
-          if ($typeId) {
-            $data['phone'][$loc]['phone_type_id'] = $typeId;
-          }
-          else {
-            $data['phone'][$loc]['phone_type_id'] = '';
-          }
-          $data['phone'][$loc]['phone'] = $value;
-
-          //special case to handle primary phone with different phone types
-          // in this case we make first phone type as primary
-          if (isset($data['phone'][$loc]['is_primary']) && !$primaryPhoneLoc) {
-            $primaryPhoneLoc = $loc;
-          }
-
-          if ($loc != $primaryPhoneLoc) {
-            unset($data['phone'][$loc]['is_primary']);
-          }
-        }
-        elseif ($fieldName == 'email') {
-          $data['email'][$loc]['email'] = $value;
-          if (empty($contactID)) {
-            $data['email'][$loc]['is_primary'] = 1;
-          }
-        }
-        elseif ($fieldName == 'im') {
-          if (isset($params[$key . '-provider_id'])) {
-            $data['im'][$loc]['provider_id'] = $params[$key . '-provider_id'];
-          }
-          if (strpos($key, '-provider_id') !== FALSE) {
-            $data['im'][$loc]['provider_id'] = $params[$key];
-          }
-          else {
-            $data['im'][$loc]['name'] = $value;
-          }
-        }
-        elseif ($fieldName == 'openid') {
-          $data['openid'][$loc]['openid'] = $value;
+        if (0) {
         }
         else {
           if ($fieldName === 'state_province') {
@@ -1541,18 +950,8 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
               $data['address'][$loc]['state_province'] = $value;
             }
           }
-          elseif ($fieldName === 'country') {
-            // CRM-3393
-            if (is_numeric($value) && ((int ) $value) >= 1000
-            ) {
-              $data['address'][$loc]['country_id'] = $value;
-            }
-            elseif (empty($value)) {
-              $data['address'][$loc]['country_id'] = '';
-            }
-            else {
-              $data['address'][$loc]['country'] = $value;
-            }
+          elseif ($fieldName === 'country_id') {
+            $data['address'][$loc]['country_id'] = $value;
           }
           elseif ($fieldName === 'county') {
             $data['address'][$loc]['county_id'] = $value;
@@ -1569,16 +968,7 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
         }
       }
       else {
-        if (substr($key, 0, 4) === 'url-') {
-          $websiteField = explode('-', $key);
-          $data['website'][$websiteField[1]]['website_type_id'] = $websiteField[1];
-          $data['website'][$websiteField[1]]['url'] = $value;
-        }
-        elseif (in_array($key, CRM_Contact_BAO_Contact::$_greetingTypes, TRUE)) {
-          //save email/postal greeting and addressee values if any, CRM-4575
-          $data[$key . '_id'] = $value;
-        }
-        elseif (($customFieldId = CRM_Core_BAO_CustomField::getKeyID($key))) {
+        if (($customFieldId = CRM_Core_BAO_CustomField::getKeyID($key))) {
           // for autocomplete transfer hidden value instead of label
           if ($params[$key] && isset($params[$key . '_id'])) {
             $value = $params[$key . '_id'];
@@ -1632,15 +1022,7 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
               }
             }
           }
-          if ($key === 'phone' && isset($params['phone_ext'])) {
-            $data[$key] = $value;
-            foreach ($value as $cnt => $phoneBlock) {
-              if ($params[$key][$cnt]['location_type_id'] == $params['phone_ext'][$cnt]['location_type_id']) {
-                $data[$key][$cnt]['phone_ext'] = CRM_Utils_Array::retrieveValueRecursive($params['phone_ext'][$cnt], 'phone_ext');
-              }
-            }
-          }
-          elseif (in_array($key, ['nick_name', 'job_title', 'middle_name', 'birth_date', 'gender_id', 'current_employer', 'prefix_id', 'suffix_id'])
+          if (in_array($key, ['nick_name', 'job_title', 'middle_name', 'birth_date', 'gender_id', 'current_employer', 'prefix_id', 'suffix_id'])
             && ($value == '' || !isset($value)) &&
             ($session->get('authSrc') & (CRM_Core_Permission::AUTH_SRC_CHECKSUM + CRM_Core_Permission::AUTH_SRC_LOGIN)) == 0 ||
             ($key === 'current_employer' && empty($params['current_employer']))) {
@@ -1679,27 +1061,6 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
     return [$data, $contactDetails];
   }
 
-  /**
-   * Get the relevant location entity for the array key.
-   *
-   * Based on the field name we determine which location entity
-   * we are dealing with. Apart from a few specific ones they
-   * are mostly 'address' (the default).
-   *
-   * @param string $fieldName
-   *
-   * @return string
-   */
-  private static function getLocationEntityForKey($fieldName) {
-    if (in_array($fieldName, ['email', 'phone', 'im', 'openid'])) {
-      return $fieldName;
-    }
-    if ($fieldName === 'phone_ext') {
-      return 'phone';
-    }
-    return 'address';
-  }
-
   /**
    * Format params for update and fill mode.
    *
@@ -1728,10 +1089,6 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
     CRM_Core_BAO_CustomGroup::setDefaults($groupTree, $defaults, FALSE, FALSE);
 
     $locationFields = [
-      'email' => 'email',
-      'phone' => 'phone',
-      'im' => 'name',
-      'website' => 'website',
       'address' => 'address',
     ];
 
@@ -1746,29 +1103,27 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
         continue;
       }
 
-      if (1) {
-        if ($customFieldId = CRM_Core_BAO_CustomField::getKeyID($key)) {
-          $custom_params = ['id' => $contact['id'], 'return' => $key];
-          $getValue = civicrm_api3('Contact', 'getvalue', $custom_params);
-          if (empty($getValue)) {
-            unset($getValue);
-          }
-        }
-        else {
-          $getValue = CRM_Utils_Array::retrieveValueRecursive($contact, $key);
-        }
-        if ($key == 'contact_source') {
-          $params['source'] = $params[$key];
-          unset($params[$key]);
+      if ($customFieldId = CRM_Core_BAO_CustomField::getKeyID($key)) {
+        $custom_params = ['id' => $contact['id'], 'return' => $key];
+        $getValue = civicrm_api3('Contact', 'getvalue', $custom_params);
+        if (empty($getValue)) {
+          unset($getValue);
         }
+      }
+      else {
+        $getValue = CRM_Utils_Array::retrieveValueRecursive($contact, $key);
+      }
+      if ($key == 'contact_source') {
+        $params['source'] = $params[$key];
+        unset($params[$key]);
+      }
 
-        if ($modeFill && isset($getValue)) {
-          unset($params[$key]);
-          if ($customFieldId) {
-            // Extra values must be unset to ensure the values are not
-            // imported.
-            unset($params['custom'][$customFieldId]);
-          }
+      if ($modeFill && isset($getValue)) {
+        unset($params[$key]);
+        if ($customFieldId) {
+          // Extra values must be unset to ensure the values are not
+          // imported.
+          unset($params['custom'][$customFieldId]);
         }
       }
     }
@@ -1781,15 +1136,8 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
 
             if (isset($getValue)) {
               foreach ($getValue as $cnt => $values) {
-                if ($locKeys == 'website') {
-                  if (($getValue[$cnt]['website_type_id'] == $params[$locKeys][$key]['website_type_id'])) {
-                    unset($params[$locKeys][$key]);
-                  }
-                }
-                else {
-                  if ((!empty($getValue[$cnt]['location_type_id']) && !empty($params[$locKeys][$key]['location_type_id'])) && $getValue[$cnt]['location_type_id'] == $params[$locKeys][$key]['location_type_id']) {
-                    unset($params[$locKeys][$key]);
-                  }
+                if ((!empty($getValue[$cnt]['location_type_id']) && !empty($params[$locKeys][$key]['location_type_id'])) && $getValue[$cnt]['location_type_id'] == $params[$locKeys][$key]['location_type_id']) {
+                  unset($params[$locKeys][$key]);
                 }
               }
             }
@@ -1821,47 +1169,20 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
   }
 
   /**
-   * Generate status and error message for unparsed street address records.
-   *
-   * @param array $values
-   *   The array of values belonging to each row.
-   * @param $returnCode
+   * Get the message for a successful import.
    *
-   * @return int
+   * @return string
    */
-  private function processMessage(&$values, $returnCode) {
-    if (empty($this->_unparsedStreetAddressContacts)) {
-      $this->setImportStatus((int) ($values[count($values) - 1]), 'IMPORTED', '');
-    }
-    else {
-      $errorMessage = ts("Record imported successfully but unable to parse the street address: ");
+  private function getSuccessMessage(): string {
+    if (!empty($this->_unparsedStreetAddressContacts)) {
+      $errorMessage = ts('Record imported successfully but unable to parse the street address: ');
       foreach ($this->_unparsedStreetAddressContacts as $contactInfo => $contactValue) {
         $contactUrl = CRM_Utils_System::url('civicrm/contact/add', 'reset=1&action=update&cid=' . $contactValue['id'], TRUE, NULL, FALSE);
-        $errorMessage .= "\n Contact ID:" . $contactValue['id'] . " <a href=\"$contactUrl\"> " . $contactValue['streetAddress'] . "</a>";
-      }
-      array_unshift($values, $errorMessage);
-      $returnCode = CRM_Import_Parser::UNPARSED_ADDRESS_WARNING;
-      $this->setImportStatus((int) ($values[count($values) - 1]), 'ERROR', $errorMessage);
-    }
-    return $returnCode;
-  }
-
-  /**
-   * get subtypes given the contact type
-   *
-   * @param string $contactType
-   * @return array $subTypes
-   */
-  public static function getSubtypes($contactType) {
-    $subTypes = [];
-    $types = CRM_Contact_BAO_ContactType::subTypeInfo($contactType);
-
-    if (count($types) > 0) {
-      foreach ($types as $type) {
-        $subTypes[] = $type['name'];
+        $errorMessage .= "\n Contact ID:" . $contactValue['id'] . " <a href=\"$contactUrl\"> " . $contactValue['streetAddress'] . '</a>';
       }
+      return $errorMessage;
     }
-    return $subTypes;
+    return '';
   }
 
   /**
@@ -1886,49 +1207,23 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
     $checkParams = ['check_permissions' => FALSE, 'match' => $params, 'dedupe_rule_id' => $dedupeRuleID];
     $possibleMatches = civicrm_api3('Contact', 'duplicatecheck', $checkParams);
     if (!$extIDMatch) {
-      // Historically we have used the last ID - it is not clear if this was
-      // deliberate.
-      return array_key_last($possibleMatches['values']);
+      if (count($possibleMatches['values']) === 1) {
+        return array_key_last($possibleMatches['values']);
+      }
+      if (count($possibleMatches['values']) > 1) {
+        throw new CRM_Core_Exception(ts('Record duplicates multiple contacts'));
+      }
+      return NULL;
     }
     if ($possibleMatches['count']) {
       if (array_key_exists($extIDMatch, $possibleMatches['values'])) {
         return $extIDMatch;
       }
-      throw new CRM_Core_Exception(ts(
-        'Matching this contact based on the de-dupe rule would cause an external ID conflict'));
+      throw new CRM_Core_Exception(ts('Matching this contact based on the de-dupe rule would cause an external ID conflict'));
     }
     return $extIDMatch;
   }
 
-  /**
-   * Format the form mapping parameters ready for the parser.
-   *
-   * @param int $count
-   *   Number of rows.
-   *
-   * @return array $parserParameters
-   */
-  public static function getParameterForParser($count) {
-    $baseArray = [];
-    for ($i = 0; $i < $count; $i++) {
-      $baseArray[$i] = NULL;
-    }
-    $parserParameters['mapperLocType'] = $baseArray;
-    $parserParameters['mapperPhoneType'] = $baseArray;
-    $parserParameters['mapperImProvider'] = $baseArray;
-    $parserParameters['mapperWebsiteType'] = $baseArray;
-    $parserParameters['mapperRelated'] = $baseArray;
-    $parserParameters['relatedContactType'] = $baseArray;
-    $parserParameters['relatedContactDetails'] = $baseArray;
-    $parserParameters['relatedContactLocType'] = $baseArray;
-    $parserParameters['relatedContactPhoneType'] = $baseArray;
-    $parserParameters['relatedContactImProvider'] = $baseArray;
-    $parserParameters['relatedContactWebsiteType'] = $baseArray;
-
-    return $parserParameters;
-
-  }
-
   /**
    * Set field metadata.
    */
@@ -1936,125 +1231,6 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
     $this->setImportableFieldsMetadata($this->getContactImportMetadata());
   }
 
-  /**
-   * @param array $newContact
-   * @param array $values
-   * @param int $onDuplicate
-   * @param array $formatted
-   * @param array $contactFields
-   *
-   * @return int
-   *
-   * @throws \CRM_Core_Exception
-   * @throws \CiviCRM_API3_Exception
-   * @throws \Civi\API\Exception\UnauthorizedException
-   */
-  private function handleDuplicateError(array $newContact, array $values, int $onDuplicate, array $formatted, array $contactFields): int {
-    $urls = [];
-    // need to fix at some stage and decide if the error will return an
-    // array or string, crude hack for now
-    if (is_array($newContact['error_message']['params'][0])) {
-      $cids = $newContact['error_message']['params'][0];
-    }
-    else {
-      $cids = explode(',', $newContact['error_message']['params'][0]);
-    }
-
-    foreach ($cids as $cid) {
-      $urls[] = CRM_Utils_System::url('civicrm/contact/view', 'reset=1&cid=' . $cid, TRUE);
-    }
-
-    $url_string = implode("\n", $urls);
-
-    // If we duplicate more than one record, skip no matter what
-    if (count($cids) > 1) {
-      $errorMessage = ts('Record duplicates multiple contacts');
-      //combine error msg to avoid mismatch between error file columns.
-      $errorMessage .= "\n" . $url_string;
-      array_unshift($values, $errorMessage);
-      $this->setImportStatus((int) $values[count($values) - 1], 'ERROR', $errorMessage);
-      return CRM_Import_Parser::ERROR;
-    }
-
-    // Params only had one id, so shift it out
-    $contactId = array_shift($cids);
-    $cid = NULL;
-
-    $vals = ['contact_id' => $contactId];
-    if (in_array((int) $onDuplicate, [CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::DUPLICATE_FILL], TRUE)) {
-      $newContact = $this->createContact($formatted, $contactFields, $onDuplicate, $contactId);
-    }
-    // else skip does nothing and just returns an error code.
-    if ($cid) {
-      $contact = [
-        'contact_id' => $cid,
-      ];
-      $defaults = [];
-      $newContact = CRM_Contact_BAO_Contact::retrieve($contact, $defaults);
-    }
-
-    if (civicrm_error($newContact)) {
-      if (empty($newContact['error_message']['params'])) {
-        // different kind of error other than DUPLICATE
-        $errorMessage = $newContact['error_message'];
-        array_unshift($values, $errorMessage);
-        $this->setImportStatus((int) $values[count($values) - 1], 'ERROR', $errorMessage);
-        return CRM_Import_Parser::ERROR;
-      }
-
-      $contactID = $newContact['error_message']['params'][0];
-      if (is_array($contactID)) {
-        $contactID = array_pop($contactID);
-      }
-      if (!in_array($contactID, $this->_newContacts)) {
-        $this->_newContacts[] = $contactID;
-      }
-    }
-    //CRM-262 No Duplicate Checking
-    if ($onDuplicate == CRM_Import_Parser::DUPLICATE_SKIP) {
-      array_unshift($values, $url_string);
-      $this->setImportStatus((int) $values[count($values) - 1], 'DUPLICATE', 'Skipping duplicate record');
-      return CRM_Import_Parser::DUPLICATE;
-    }
-
-    $this->setImportStatus((int) $values[count($values) - 1], 'Imported', '');
-    //return warning if street address is not parsed, CRM-5886
-    return $this->processMessage($values, CRM_Import_Parser::VALID);
-  }
-
-  /**
-   * @param array $params
-   * @param bool $dupeCheck
-   * @param null|int $dedupeRuleGroupID
-   *
-   * @return ?array
-   * @throws \CRM_Core_Exception
-   */
-  public function deprecated_contact_check_params(
-    $params,
-    $dupeCheck = TRUE,
-    $dedupeRuleGroupID = NULL) {
-
-    if ($dupeCheck) {
-      // @todo switch to using api version
-      // $dupes = civicrm_api3('Contact', 'duplicatecheck', (array('match' => $params, 'dedupe_rule_id' => $dedupeRuleGroupID)));
-      // $ids = $dupes['count'] ? implode(',', array_keys($dupes['values'])) : NULL;
-      $ids = CRM_Contact_BAO_Contact::getDuplicateContacts($params, $params['contact_type'], 'Unsupervised', [], CRM_Utils_Array::value('check_permissions', $params), $dedupeRuleGroupID);
-
-      if ($ids != NULL) {
-        return [
-          'is_error' => 1,
-          'error_message' => [
-            'code' => CRM_Core_Error::DUPLICATE_CONTACT,
-            'params' => $ids,
-            'level' => 'Fatal',
-            'message' => 'Found matching contacts: ' . implode(',', $ids),
-          ],
-        ];
-      }
-    }
-  }
-
   /**
    * Run import.
    *
@@ -2081,123 +1257,39 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
     $this->getContactType();
     $this->getContactSubType();
 
-    $this->init();
-
-    $this->_rowCount = 0;
-    $this->_totalCount = 0;
-
-    $this->_primaryKeyName = '_id';
-    $this->_statusFieldName = '_status';
-
-    if ($statusID) {
-      $this->progressImport($statusID);
-      $startTimestamp = $currTimestamp = $prevTimestamp = time();
-    }
-    $dataSource = $this->getDataSourceObject();
-    $totalRowCount = $dataSource->getRowCount(['new']);
-    if ($mode == self::MODE_IMPORT) {
-      $dataSource->setStatuses(['new']);
-    }
-
-    while ($row = $dataSource->getRow()) {
-      $values = array_values($row);
-      $this->_rowCount++;
-
-      $this->_totalCount++;
-
-      if ($mode == self::MODE_PREVIEW) {
-        $returnCode = $this->preview($values);
-      }
-      elseif ($mode == self::MODE_SUMMARY) {
-        $returnCode = $this->summary($values);
-      }
-      elseif ($mode == self::MODE_IMPORT) {
-        try {
-          $returnCode = $this->import($onDuplicate, $values);
-        }
-        catch (CiviCRM_API3_Exception $e) {
-          // When we catch errors here we are not adding to the errors array - mostly
-          // because that will become obsolete once https://github.com/civicrm/civicrm-core/pull/23292
-          // is merged and this will replace it as the main way to handle errors (ie. update the table
-          // and move on).
-          $this->setImportStatus((int) $values[count($values) - 1], 'ERROR', $e->getMessage());
-        }
-        if ($statusID && (($this->_rowCount % 50) == 0)) {
-          $prevTimestamp = $this->progressImport($statusID, FALSE, $startTimestamp, $prevTimestamp, $totalRowCount);
-        }
-      }
-      else {
-        $returnCode = self::ERROR;
-      }
-
-      if ($returnCode & self::NO_MATCH) {
-        $this->setImportStatus((int) $values[count($values) - 1], 'invalid_no_match', array_shift($values));
-      }
-
-      if ($returnCode & self::UNPARSED_ADDRESS_WARNING) {
-        $this->setImportStatus((int) $values[count($values) - 1], 'warning_unparsed_address', array_shift($values));
-      }
-    }
-  }
-
-  /**
-   * Given a list of the importable field keys that the user has selected.
-   * set the active fields array to this list
-   *
-   * @param array $fieldKeys
-   *   Mapped array of values.
-   */
-  public function setActiveFields($fieldKeys) {
-    foreach ($fieldKeys as $key) {
-      if (empty($this->_fields[$key])) {
-        $this->_activeFields[] = new CRM_Contact_Import_Field('', ts('- do not import -'));
-      }
-      else {
-        $this->_activeFields[] = clone($this->_fields[$key]);
-      }
-    }
-  }
-
-  /**
-   * Format the field values for input to the api.
-   *
-   * @param array $values
-   *   The row from the datasource.
-   *
-   * @return array
-   *   Parameters mapped as described in getMappedRow
-   *
-   * @throws \API_Exception
-   * @todo - clean this up a bit & merge back into `getMappedRow`
-   *
-   */
-  private function getParams(array $values): array {
-    $params = [];
-
-    foreach ($this->getFieldMappings() as $i => $mappedField) {
-      // The key is in the format 5_a_b where 5 is the relationship_type_id and a_b is the direction.
-      $relatedContactKey = $mappedField['relationship_type_id'] ? ($mappedField['relationship_type_id'] . '_' . $mappedField['relationship_direction']) : NULL;
-      $fieldName = $mappedField['name'];
-      $importedValue = $values[$i];
-      if ($fieldName === 'do_not_import' || $importedValue === NULL) {
-        continue;
-      }
+    $this->init();
 
-      $locationFields = ['location_type_id', 'phone_type_id', 'provider_id', 'website_type_id'];
-      $locationValues = array_filter(array_intersect_key($mappedField, array_fill_keys($locationFields, 1)));
+    $this->_rowCount = 0;
+    $this->_totalCount = 0;
 
-      if ($relatedContactKey) {
-        if (!isset($params[$relatedContactKey])) {
-          $params[$relatedContactKey] = ['contact_type' => $this->getRelatedContactType($mappedField['relationship_type_id'], $mappedField['relationship_direction'])];
-        }
-        $this->addFieldToParams($params[$relatedContactKey], $locationValues, $fieldName, $importedValue);
+    if ($statusID) {
+      $this->progressImport($statusID);
+      $startTimestamp = $currTimestamp = $prevTimestamp = time();
+    }
+    $dataSource = $this->getDataSourceObject();
+    $totalRowCount = $dataSource->getRowCount(['new']);
+    $dataSource->setStatuses(['new']);
+
+    while ($row = $dataSource->getRow()) {
+      $values = array_values($row);
+      $this->_rowCount++;
+
+      $this->_totalCount++;
+
+      try {
+        $this->import($onDuplicate, $values);
       }
-      else {
-        $this->addFieldToParams($params, $locationValues, $fieldName, $importedValue);
+      catch (CiviCRM_API3_Exception $e) {
+        // When we catch errors here we are not adding to the errors array - mostly
+        // because that will become obsolete once https://github.com/civicrm/civicrm-core/pull/23292
+        // is merged and this will replace it as the main way to handle errors (ie. update the table
+        // and move on).
+        $this->setImportStatus((int) $values[count($values) - 1], 'ERROR', $e->getMessage());
+      }
+      if ($statusID && (($this->_rowCount % 50) == 0)) {
+        $prevTimestamp = $this->progressImport($statusID, FALSE, $startTimestamp, $prevTimestamp, $totalRowCount);
       }
     }
-
-    return $params;
   }
 
   /**
@@ -2314,7 +1406,6 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
     //          Location
     //              Address
     //              Email
-    //              Phone
     //              IM
     //      Note
     //      Custom
@@ -2336,101 +1427,6 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
     // @todo - remove this after confirming this is just a compilation of other-wise-cached fields.
     static $fields = [];
 
-    if (isset($values['individual_prefix'])) {
-      if (!empty($params['prefix_id'])) {
-        $prefixes = CRM_Core_PseudoConstant::get('CRM_Contact_DAO_Contact', 'prefix_id');
-        $params['prefix'] = $prefixes[$params['prefix_id']];
-      }
-      else {
-        $params['prefix'] = $values['individual_prefix'];
-      }
-      return TRUE;
-    }
-
-    if (isset($values['individual_suffix'])) {
-      if (!empty($params['suffix_id'])) {
-        $suffixes = CRM_Core_PseudoConstant::get('CRM_Contact_DAO_Contact', 'suffix_id');
-        $params['suffix'] = $suffixes[$params['suffix_id']];
-      }
-      else {
-        $params['suffix'] = $values['individual_suffix'];
-      }
-      return TRUE;
-    }
-
-    // CRM-4575
-    if (isset($values['email_greeting'])) {
-      if (!empty($params['email_greeting_id'])) {
-        $emailGreetingFilter = [
-          'contact_type' => $params['contact_type'] ?? NULL,
-          'greeting_type' => 'email_greeting',
-        ];
-        $emailGreetings = CRM_Core_PseudoConstant::greeting($emailGreetingFilter);
-        $params['email_greeting'] = $emailGreetings[$params['email_greeting_id']];
-      }
-      else {
-        $params['email_greeting'] = $values['email_greeting'];
-      }
-
-      return TRUE;
-    }
-
-    if (isset($values['postal_greeting'])) {
-      if (!empty($params['postal_greeting_id'])) {
-        $postalGreetingFilter = [
-          'contact_type' => $params['contact_type'] ?? NULL,
-          'greeting_type' => 'postal_greeting',
-        ];
-        $postalGreetings = CRM_Core_PseudoConstant::greeting($postalGreetingFilter);
-        $params['postal_greeting'] = $postalGreetings[$params['postal_greeting_id']];
-      }
-      else {
-        $params['postal_greeting'] = $values['postal_greeting'];
-      }
-      return TRUE;
-    }
-
-    if (isset($values['addressee'])) {
-      $params['addressee'] = $values['addressee'];
-      return TRUE;
-    }
-
-    if (!empty($values['preferred_communication_method'])) {
-      $comm = [];
-      $pcm = array_change_key_case(array_flip(CRM_Core_PseudoConstant::get('CRM_Contact_DAO_Contact', 'preferred_communication_method')), CASE_LOWER);
-
-      $preffComm = explode(',', $values['preferred_communication_method']);
-      foreach ($preffComm as $v) {
-        $v = strtolower(trim($v));
-        if (array_key_exists($v, $pcm)) {
-          $comm[$pcm[$v]] = 1;
-        }
-      }
-
-      $params['preferred_communication_method'] = $comm;
-      return TRUE;
-    }
-
-    // format the website params.
-    if (!empty($values['url'])) {
-      static $websiteFields;
-      if (!is_array($websiteFields)) {
-        $websiteFields = CRM_Core_DAO_Website::fields();
-      }
-      if (!array_key_exists('website', $params) ||
-        !is_array($params['website'])
-      ) {
-        $params['website'] = [];
-      }
-
-      $websiteCount = count($params['website']);
-      _civicrm_api3_store_values($websiteFields, $values,
-        $params['website'][++$websiteCount]
-      );
-
-      return TRUE;
-    }
-
     if (isset($values['note'])) {
       // add a note field
       if (!isset($params['note'])) {
@@ -2479,59 +1475,21 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
   /**
    * Format location block ready for importing.
    *
-   * There is some test coverage for this in CRM_Contact_Import_Parser_ContactTest
-   * e.g. testImportPrimaryAddress.
+   * Note this formatting should all be by the time the code reaches this point
+   *
+   * There is some test coverage for this in
+   * CRM_Contact_Import_Parser_ContactTest e.g. testImportPrimaryAddress.
+   *
+   * @deprecated
    *
    * @param array $values
-   * @param array $params
    *
    * @return bool
+   * @throws \CiviCRM_API3_Exception
    */
-  protected function formatLocationBlock(&$values, &$params) {
-    $blockTypes = [
-      'phone' => 'Phone',
-      'email' => 'Email',
-      'im' => 'IM',
-      'openid' => 'OpenID',
-      'phone_ext' => 'Phone',
-    ];
-    foreach ($blockTypes as $blockFieldName => $block) {
-      if (!array_key_exists($blockFieldName, $values)) {
-        continue;
-      }
-      $blockIndex = $values['location_type_id'] . (!empty($values['phone_type_id']) ? '_' . $values['phone_type_id'] : '');
-
-      // block present in value array.
-      if (!array_key_exists($blockFieldName, $params) || !is_array($params[$blockFieldName])) {
-        $params[$blockFieldName] = [];
-      }
-
-      $fields[$block] = $this->getMetadataForEntity($block);
-
-      // copy value to dao field name.
-      if ($blockFieldName == 'im') {
-        $values['name'] = $values[$blockFieldName];
-      }
-
-      _civicrm_api3_store_values($fields[$block], $values,
-        $params[$blockFieldName][$blockIndex]
-      );
-
-      $this->fillPrimary($params[$blockFieldName][$blockIndex], $values, $block, CRM_Utils_Array::value('id', $params));
-
-      if (empty($params['id']) && (count($params[$blockFieldName]) == 1)) {
-        $params[$blockFieldName][$blockIndex]['is_primary'] = TRUE;
-      }
-
-      // we only process single block at a time.
-      return TRUE;
-    }
-
-    // handle address fields.
-    if (!array_key_exists('address', $params) || !is_array($params['address'])) {
-      $params['address'] = [];
-    }
-
+  protected function formatLocationBlock(&$values) {
+    // @todo - remove this function.
+    // Original explantion .....
     // Note: we doing multiple value formatting here for address custom fields, plus putting into right format.
     // The actual formatting (like date, country ..etc) for address custom fields is taken care of while saving
     // the address in CRM_Core_BAO_Address::create method
@@ -2572,33 +1530,6 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
       $values = $newValues;
     }
 
-    $fields['Address'] = $this->getMetadataForEntity('Address');
-    // @todo this is kinda replicated below....
-    _civicrm_api3_store_values($fields['Address'], $values, $params['address'][$values['location_type_id']]);
-
-    $addressFields = [
-      'county',
-      'country',
-      'state_province',
-      'supplemental_address_1',
-      'supplemental_address_2',
-      'supplemental_address_3',
-      'StateProvince.name',
-    ];
-    foreach (array_keys($customFields) as $customFieldID) {
-      $addressFields[] = 'custom_' . $customFieldID;
-    }
-
-    foreach ($addressFields as $field) {
-      if (array_key_exists($field, $values)) {
-        if (!array_key_exists('address', $params)) {
-          $params['address'] = [];
-        }
-        $params['address'][$values['location_type_id']][$field] = $values[$field];
-      }
-    }
-
-    $this->fillPrimary($params['address'][$values['location_type_id']], $values, 'address', CRM_Utils_Array::value('id', $params));
     return TRUE;
   }
 
@@ -2675,9 +1606,11 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
     $fieldName = $isRelationshipField ? $fieldMapping[1] : $fieldMapping[0];
     $locationTypeID = NULL;
     $possibleLocationField = $isRelationshipField ? 2 : 1;
-    if ($fieldName !== 'url' && is_numeric($fieldMapping[$possibleLocationField] ?? NULL)) {
+    $entity = strtolower($this->getFieldEntity($fieldName));
+    if ($entity !== 'website' && is_numeric($fieldMapping[$possibleLocationField] ?? NULL)) {
       $locationTypeID = $fieldMapping[$possibleLocationField];
     }
+
     return [
       'name' => $fieldName,
       'mapping_id' => $mappingID,
@@ -2685,9 +1618,9 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
       'relationship_direction' => $isRelationshipField ? substr($fieldMapping[0], -3) : NULL,
       'column_number' => $columnNumber,
       'contact_type' => $this->getContactType(),
-      'website_type_id' => $fieldName !== 'url' ? NULL : ($isRelationshipField ? $fieldMapping[2] : $fieldMapping[1]),
-      'phone_type_id' => $fieldName !== 'phone' ? NULL : ($isRelationshipField ? $fieldMapping[3] : $fieldMapping[2]),
-      'im_provider_id' => $fieldName !== 'im' ? NULL : ($isRelationshipField ? $fieldMapping[3] : $fieldMapping[2]),
+      'website_type_id' => $entity !== 'website' ? NULL : ($isRelationshipField ? $fieldMapping[2] : $fieldMapping[1]),
+      'phone_type_id' => $entity !== 'phone' ? NULL : ($isRelationshipField ? $fieldMapping[3] : $fieldMapping[2]),
+      'im_provider_id' => $entity !== 'im' ? NULL : ($isRelationshipField ? $fieldMapping[3] : $fieldMapping[2]),
       'location_type_id' => $locationTypeID,
     ];
   }
@@ -2763,7 +1696,37 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
    * @throws \API_Exception
    */
   public function getMappedRow(array $values): array {
-    $params = $this->getParams($values);
+    $params = ['relationship' => []];
+
+    foreach ($this->getFieldMappings() as $i => $mappedField) {
+      // The key is in the format 5_a_b where 5 is the relationship_type_id and a_b is the direction.
+      $relatedContactKey = $mappedField['relationship_type_id'] ? ($mappedField['relationship_type_id'] . '_' . $mappedField['relationship_direction']) : NULL;
+      $fieldName = $mappedField['name'];
+      $importedValue = $values[$i];
+      if ($fieldName === 'do_not_import' || $importedValue === NULL) {
+        continue;
+      }
+
+      $locationFields = ['location_type_id', 'phone_type_id', 'provider_id', 'website_type_id'];
+      $locationValues = array_filter(array_intersect_key($mappedField, array_fill_keys($locationFields, 1)));
+
+      if ($relatedContactKey) {
+        if (!isset($params['relationship'][$relatedContactKey])) {
+          $params['relationship'][$relatedContactKey] = [
+            // These will be over-written by any the importer has chosen but defaults are based on the relationship.
+            'contact_type' => $this->getRelatedContactType($mappedField['relationship_type_id'], $mappedField['relationship_direction']),
+            'contact_sub_type' => $this->getRelatedContactSubType($mappedField['relationship_type_id'], $mappedField['relationship_direction']),
+          ];
+        }
+        $this->addFieldToParams($params['relationship'][$relatedContactKey], $locationValues, $fieldName, $importedValue);
+      }
+      else {
+        $this->addFieldToParams($params, $locationValues, $fieldName, $importedValue);
+      }
+    }
+
+    $this->fillStateProvince($params);
+
     $params['contact_type'] = $this->getContactType();
     if ($this->getContactSubType()) {
       $params['contact_sub_type'] = $this->getContactSubType();
@@ -2783,39 +1746,30 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
    */
   public function validateValues(array $values): void {
     $params = $this->getMappedRow($values);
-    $contacts = array_merge(['0' => $params], $this->getRelatedContactsParams($params));
-    foreach ($contacts as $value) {
-      // If we are referencing a related contact, or are in update mode then we
-      // don't need all the required fields if we have enough to find an existing contact.
-      $useExistingMatchFields = !empty($value['relationship_type_id']) || $this->isUpdateExistingContacts();
-      $this->validateRequiredContactFields($value['contact_type'], $value, $useExistingMatchFields, !empty($value['relationship_label']) ? '(' . $value['relationship_label'] . ')' : '');
-    }
-
-    //check for duplicate external Identifier
-    $externalID = $params['external_identifier'] ?? NULL;
-    if ($externalID) {
-      /* If it's a dupe,external Identifier  */
+    $this->validateParams($params);
+  }
 
-      if ($externalDupe = CRM_Utils_Array::value($externalID, $this->_allExternalIdentifiers)) {
-        $errorMessage = ts('External ID conflicts with record %1', [1 => $externalDupe]);
-        throw new CRM_Core_Exception($errorMessage);
+  /**
+   * Get the invalid values in the params for the given contact.
+   *
+   * @param array|int|string $value
+   * @param string $prefixString
+   *
+   * @return array
+   * @throws \API_Exception
+   * @throws \Civi\API\Exception\NotImplementedException
+   */
+  protected function getInvalidValuesForContact($value, string $prefixString): array {
+    $errors = [];
+    foreach ($value as $contactKey => $contactValue) {
+      if ($contactKey !== 'relationship') {
+        $result = $this->getInvalidValues($contactValue, $contactKey, $prefixString);
+        if (!empty($result)) {
+          $errors = array_merge($errors, $result);
+        }
       }
-      //otherwise, count it and move on
-      $this->_allExternalIdentifiers[$externalID] = $this->_lineCount;
-    }
-
-    //date-format part ends
-
-    $errorMessage = NULL;
-    //checking error in custom data
-    $this->isErrorInCustomData($params, $errorMessage, $params['contact_sub_type'] ?? NULL);
-
-    //checking error in core data
-    $this->isErrorInCoreData($params, $errorMessage);
-    if ($errorMessage) {
-      $tempMsg = "Invalid value for field(s) : $errorMessage";
-      throw new CRM_Core_Exception($tempMsg);
     }
+    return $errors;
   }
 
   /**
@@ -2864,6 +1818,24 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
     return $this->getRelationshipType($relationshipTypeID)[$relationshipField];
   }
 
+  /**
+   * Get the related contact sub type.
+   *
+   * @param int|null $relationshipTypeID
+   * @param int|string $relationshipDirection
+   *
+   * @return null|string
+   *
+   * @throws \API_Exception
+   */
+  protected function getRelatedContactSubType(int $relationshipTypeID, $relationshipDirection): ?string {
+    if (!$relationshipTypeID) {
+      return NULL;
+    }
+    $relationshipField = 'contact_sub_type_' . substr($relationshipDirection, -1);
+    return $this->getRelationshipType($relationshipTypeID)[$relationshipField];
+  }
+
   /**
    * Get the related contact type.
    *
@@ -2910,12 +1882,35 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
    */
   private function addFieldToParams(array &$contactArray, array $locationValues, string $fieldName, $importedValue): void {
     if (!empty($locationValues)) {
-      $locationValues[$fieldName] = $importedValue;
-      $contactArray[$fieldName] = (array) ($contactArray[$fieldName] ?? []);
-      $contactArray[$fieldName][] = $locationValues;
+      $fieldMap = ['country' => 'country_id', 'state_province' => 'state_province_id', 'county' => 'county_id'];
+      $realFieldName = empty($fieldMap[$fieldName]) ? $fieldName : $fieldMap[$fieldName];
+      $entity = strtolower($this->getFieldEntity($fieldName));
+
+      // The entity key is either location_type_id for address, email - eg. 1, or
+      // location_type_id + '_' + phone_type_id or im_provider_id
+      // or the value for website(since websites are not historically one-per-type)
+      $entityKey = $locationValues['location_type_id'] ?? $importedValue;
+      if (!empty($locationValues['phone_type_id']) || !empty($locationValues['provider_id'])) {
+        $entityKey .= '_' . ($locationValues['phone_type_id'] ?? '' . $locationValues['provider_id'] ?? '');
+      }
+      $fieldValue = $this->getTransformedFieldValue($realFieldName, $importedValue);
+
+      if (!isset($contactArray[$entity][$entityKey])) {
+        $contactArray[$entity][$entityKey] = $locationValues;
+      }
+      // So im has really non-standard handling...
+      $reallyRealFieldName = $realFieldName === 'im' ? 'name' : $realFieldName;
+      $contactArray[$entity][$entityKey][$reallyRealFieldName] = $fieldValue;
     }
     else {
-      $contactArray[$fieldName] = $this->getTransformedFieldValue($fieldName, $importedValue);
+      $fieldName = array_search($fieldName, $this->getOddlyMappedMetadataFields(), TRUE) ?: $fieldName;
+      $importedValue = $this->getTransformedFieldValue($fieldName, $importedValue);
+      if ($importedValue === '' && !empty($contactArray[$fieldName])) {
+        // If we have already calculated contact type or subtype based on the relationship
+        // do not overwrite it with an empty value.
+        return;
+      }
+      $contactArray[$fieldName] = $importedValue;
     }
   }
 
@@ -2936,7 +1931,7 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
    */
   protected function getRelatedContactsParams(array $params): array {
     $relatedContacts = [];
-    foreach ($params as $key => $value) {
+    foreach ($params['relationship'] as $key => $value) {
       // If the key is a relationship key - eg. 5_a_b or 10_b_a
       // then the value is an array that describes an existing contact.
       // We need to check the fields are present to identify or create this
@@ -2997,22 +1992,23 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
    * Lookup the contact's contact ID.
    *
    * @param array $params
-   * @param bool $isDuplicateIfExternalIdentifierExists
+   * @param bool $isMainContact
    *
    * @return int|null
    *
    * @throws \API_Exception
    * @throws \CRM_Core_Exception
    * @throws \CiviCRM_API3_Exception
+   * @throws \Civi\API\Exception\UnauthorizedException
    */
-  protected function lookupContactID(array $params, bool $isDuplicateIfExternalIdentifierExists): ?int {
+  protected function lookupContactID(array $params, bool $isMainContact): ?int {
     $extIDMatch = $this->lookupExternalIdentifier($params['external_identifier'] ?? NULL, $params['contact_type']);
     $contactID = !empty($params['id']) ? (int) $params['id'] : NULL;
     //check if external identifier exists in database
     if ($extIDMatch && $contactID && $extIDMatch !== $contactID) {
       throw new CRM_Core_Exception(ts('Existing external ID does not match the imported contact ID.'), CRM_Import_Parser::ERROR);
     }
-    if ($extIDMatch && $isDuplicateIfExternalIdentifierExists) {
+    if ($extIDMatch && $isMainContact && ($this->isSkipDuplicates() || $this->isIgnoreDuplicates())) {
       throw new CRM_Core_Exception(ts('External ID already exists in Database.'), CRM_Import_Parser::DUPLICATE);
     }
     if ($contactID) {
@@ -3031,10 +2027,165 @@ class CRM_Contact_Import_Parser_Contact extends CRM_Import_Parser {
     }
     // Time to see if we can find an existing contact ID to make this an update
     // not a create.
-    if ($extIDMatch || $this->isUpdateExistingContacts()) {
-      return $this->getPossibleContactMatch($params, $extIDMatch, $this->getSubmittedValue('dedupe_rule_id') ?: NULL);
+    if ($extIDMatch || !$this->isIgnoreDuplicates()) {
+      if (isset($params['relationship'])) {
+        unset($params['relationship']);
+      }
+      $id = $this->getPossibleContactMatch($params, $extIDMatch, $this->getSubmittedValue('dedupe_rule_id') ?: NULL);
+      if ($id && $this->isSkipDuplicates()) {
+        throw new CRM_Core_Exception(ts('Contact matched by dedupe rule already exists in the database.'), CRM_Import_Parser::DUPLICATE);
+      }
+      return $id;
     }
     return NULL;
   }
 
+  /**
+   * @param array $params
+   * @param array $formatted
+   * @param bool $isMainContact
+   *
+   * @return array[]
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   * @throws \CiviCRM_API3_Exception
+   */
+  protected function processContact(array $params, array $formatted, bool $isMainContact): array {
+    $params['id'] = $formatted['id'] = $this->lookupContactID($params, $isMainContact);
+    if ($params['id'] && $params['contact_sub_type']) {
+      $contactSubType = Contact::get(FALSE)
+        ->addWhere('id', '=', $params['id'])
+        ->addSelect('contact_sub_type')
+        ->execute()
+        ->first()['contact_sub_type'];
+      if (!empty($contactSubType) && $contactSubType[0] !== $params['contact_sub_type'] && !CRM_Contact_BAO_ContactType::isAllowEdit($params['id'], $contactSubType[0])) {
+        throw new CRM_Core_Exception('Mismatched contact SubTypes :', CRM_Import_Parser::NO_MATCH);
+      }
+    }
+    return array($formatted, $params);
+  }
+
+  /**
+   * Try to get the correct state province using what country information we have.
+   *
+   * If the state matches more than one possibility then either the imported
+   * country of the site country should help us....
+   *
+   * @param string $stateProvince
+   * @param int|null|string $countryID
+   *
+   * @return int|string
+   * @throws \API_Exception
+   * @throws \Civi\API\Exception\UnauthorizedException
+   */
+  private function tryToResolveStateProvince(string $stateProvince, $countryID) {
+    // Try to disambiguate since we likely have the country now.
+    $possibleStates = $this->ambiguousOptions['state_province_id'][mb_strtolower($stateProvince)];
+    if ($countryID) {
+      return $this->checkStatesForCountry($countryID, $possibleStates) ?: 'invalid_import_value';
+    }
+    // Try the default country next.
+    $defaultCountryMatch = $this->checkStatesForCountry($this->getSiteDefaultCountry(), $possibleStates);
+    if ($defaultCountryMatch) {
+      return $defaultCountryMatch;
+    }
+
+    if ($this->getAvailableCountries()) {
+      $countryMatches = [];
+      foreach ($this->getAvailableCountries() as $availableCountryID) {
+        $possible = $this->checkStatesForCountry($availableCountryID, $possibleStates);
+        if ($possible) {
+          $countryMatches[] = $possible;
+        }
+      }
+      if (count($countryMatches) === 1) {
+        return reset($countryMatches);
+      }
+
+    }
+    return $stateProvince;
+  }
+
+  /**
+   * @param array $params
+   *
+   * @return array
+   * @throws \API_Exception
+   */
+  private function fillStateProvince(array &$params): array {
+    foreach ($params as $key => $value) {
+      if ($key === 'address') {
+        foreach ($value as $index => $address) {
+          $stateProvinceID = $address['state_province_id'] ?? NULL;
+          if ($stateProvinceID) {
+            if (!is_numeric($stateProvinceID)) {
+              $params['address'][$index]['state_province_id'] = $this->tryToResolveStateProvince($stateProvinceID, $address['country_id'] ?? NULL);
+            }
+            elseif (!empty($address['country_id']) && is_numeric($address['country_id'])) {
+              if (!$this->checkStatesForCountry((int) $address['country_id'], [$stateProvinceID])) {
+                $params['address'][$index]['state_province_id'] = 'invalid_import_value';
+              }
+            }
+          }
+        }
+      }
+      elseif (is_array($value) && !in_array($key, ['email', 'phone', 'im', 'website', 'openid'], TRUE)) {
+        $this->fillStateProvince($params[$key]);
+      }
+    }
+    return $params;
+  }
+
+  /**
+   * Check is any of the given states correlate to the country.
+   *
+   * @param int $countryID
+   * @param array $possibleStates
+   *
+   * @return int|null
+   * @throws \API_Exception
+   */
+  private function checkStatesForCountry(int $countryID, array $possibleStates) {
+    foreach ($possibleStates as $index => $state) {
+      if (!empty($this->statesByCountry[$state])) {
+        if ($this->statesByCountry[$state] === $countryID) {
+          return $state;
+        }
+        unset($possibleStates[$index]);
+      }
+    }
+    if (!empty($possibleStates)) {
+      $states = StateProvince::get(FALSE)
+        ->addSelect('country_id')
+        ->addWhere('id', 'IN', $possibleStates)
+        ->execute()
+        ->indexBy('country_id');
+      foreach ($states as $state) {
+        $this->statesByCountry[$state['id']] = $state['country_id'];
+      }
+      foreach ($possibleStates as $state) {
+        if ($this->statesByCountry[$state] === $countryID) {
+          return $state;
+        }
+      }
+    }
+    return FALSE;
+  }
+
+  /**
+   * @param $outcome
+   *
+   * @return string
+   */
+  protected function getStatus($outcome): string {
+    if ($outcome === CRM_Import_Parser::VALID) {
+      return empty($this->_unparsedStreetAddressContacts) ? 'IMPORTED' : 'warning_unparsed_address';
+    }
+    return [
+      CRM_Import_Parser::DUPLICATE => 'DUPLICATE',
+      CRM_Import_Parser::ERROR => 'ERROR',
+      CRM_Import_Parser::NO_MATCH => 'invalid_no_match',
+    ][$outcome];
+  }
+
 }
index 0250cb4339b64cc26fe3d8db1bb99cd8ab164684..800c28ac9437b644f500c9fbc3b9e89729ace6e1 100644 (file)
@@ -719,7 +719,6 @@ class CRM_Contribute_BAO_Contribution extends CRM_Contribute_DAO_Contribution im
       $note = CRM_Core_DAO_Note::import();
       $tmpFields = CRM_Contribute_DAO_Contribution::import();
       unset($tmpFields['option_value']);
-      $optionFields = CRM_Core_OptionValue::getFields($mode = 'contribute');
       $contactFields = CRM_Contact_BAO_Contact::importableFields($contactType, NULL);
 
       // Using new Dedupe rule.
@@ -754,12 +753,10 @@ class CRM_Contribute_BAO_Contribution extends CRM_Contribute_DAO_Contribution im
 
       $tmpContactField['external_identifier'] = $contactFields['external_identifier'];
       $tmpContactField['external_identifier']['title'] = $contactFields['external_identifier']['title'] . ' ' . ts('(match to contact)');
-      $tmpFields['contribution_contact_id']['title'] = $tmpFields['contribution_contact_id']['title'] . ' ' . ts('(match to contact)');
+      $tmpFields['contribution_contact_id']['title'] = $tmpFields['contribution_contact_id']['html']['label'] = $tmpFields['contribution_contact_id']['title'] . ' ' . ts('(match to contact)');
       $fields = array_merge($fields, $tmpContactField);
       $fields = array_merge($fields, $tmpFields);
       $fields = array_merge($fields, $note);
-      $fields = array_merge($fields, $optionFields);
-      $fields = array_merge($fields, CRM_Financial_DAO_FinancialType::export());
       $fields = array_merge($fields, CRM_Core_BAO_CustomField::getFieldsForImport('Contribution'));
       self::$_importableFields = $fields;
     }
@@ -2896,6 +2893,11 @@ INNER JOIN civicrm_activity ON civicrm_activity_contact.activity_id = civicrm_ac
         $pcpDAO->id = $softDAO->pcp_id;
         if ($pcpDAO->find(TRUE)) {
           $pcpParams['title'] = $pcpDAO->title;
+
+          // do not display PCP block in receipt if not enabled for the PCP poge
+          if (empty($pcpDAO->is_honor_roll)) {
+            $pcpParams['pcpBlock'] = FALSE;
+          }
         }
       }
     }
index 01911b8f0400ed8a50c08674038acad6e8d1a568..0630d3bc7c9fc7042fb0ec4ed217f2312ac26063 100644 (file)
@@ -6,7 +6,7 @@
  *
  * Generated from xml/schema/CRM/Contribute/Contribution.xml
  * DO NOT EDIT.  Generated by CRM_Core_CodeGen
- * (GenCodeChecksum:edd96be2e997a659ceeee0cf823c6f90)
+ * (GenCodeChecksum:0f869aa62eb1a94aedf6009a988cf01d)
  */
 
 /**
@@ -418,6 +418,7 @@ class CRM_Contribute_DAO_Contribution extends CRM_Core_DAO {
           'type' => CRM_Utils_Type::T_INT,
           'title' => ts('Financial Type ID'),
           'description' => ts('FK to Financial Type for (total_amount - non_deductible_amount).'),
+          'import' => TRUE,
           'where' => 'civicrm_contribution.financial_type_id',
           'export' => TRUE,
           'table_name' => 'civicrm_contribution',
@@ -465,6 +466,7 @@ class CRM_Contribute_DAO_Contribution extends CRM_Core_DAO {
           'type' => CRM_Utils_Type::T_INT,
           'title' => ts('Payment Method ID'),
           'description' => ts('FK to Payment Instrument'),
+          'import' => TRUE,
           'where' => 'civicrm_contribution.payment_instrument_id',
           'headerPattern' => '/^payment|(p(ayment\s)?instrument)$/i',
           'export' => TRUE,
index 2ef3a2a863c5bb0d681632adb8d24c169dcc7414..4b6fa866877456454a3c5a3d060d821923ee6292 100644 (file)
@@ -1385,22 +1385,27 @@ class CRM_Contribute_Form_Contribution_Confirm extends CRM_Contribute_Form_Contr
    */
   public static function processPcp(&$page, $params): array {
     $params['pcp_made_through_id'] = $page->_pcpId;
-    $page->assign('pcpBlock', TRUE);
-    if (!empty($params['pcp_display_in_roll']) && empty($params['pcp_roll_nickname'])) {
-      $params['pcp_roll_nickname'] = ts('Anonymous');
-      $params['pcp_is_anonymous'] = 1;
-    }
-    else {
-      $params['pcp_is_anonymous'] = 0;
-    }
-    foreach ([
-      'pcp_display_in_roll',
-      'pcp_is_anonymous',
-      'pcp_roll_nickname',
-      'pcp_personal_note',
-    ] as $val) {
-      if (!empty($params[$val])) {
-        $page->assign($val, $params[$val]);
+
+    $page->assign('pcpBlock', FALSE);
+    // display honor roll data only if it's enabled for the PCP page
+    if (!empty($page->_pcpInfo['is_honor_roll'])) {
+      $page->assign('pcpBlock', TRUE);
+      if (!empty($params['pcp_display_in_roll']) && empty($params['pcp_roll_nickname'])) {
+        $params['pcp_roll_nickname'] = ts('Anonymous');
+        $params['pcp_is_anonymous'] = 1;
+      }
+      else {
+        $params['pcp_is_anonymous'] = 0;
+      }
+      foreach ([
+        'pcp_display_in_roll',
+        'pcp_is_anonymous',
+        'pcp_roll_nickname',
+        'pcp_personal_note',
+      ] as $val) {
+        if (!empty($params[$val])) {
+          $page->assign($val, $params[$val]);
+        }
       }
     }
 
@@ -1703,7 +1708,7 @@ class CRM_Contribute_Form_Contribution_Confirm extends CRM_Contribute_Form_Contr
       // If this is a single membership-related contribution, it won't have
       // be performed yet, so do it now.
       if ($isPaidMembership && !$isProcessSeparateMembershipTransaction) {
-        $paymentActionResult = $payment->doPayment($paymentParams, 'contribute');
+        $paymentActionResult = $payment->doPayment($paymentParams);
         $paymentResults[] = ['contribution_id' => $paymentResult['contribution']->id, 'result' => $paymentActionResult];
       }
       // Do not send an email if Recurring transaction is done via Direct Mode
@@ -1711,7 +1716,26 @@ class CRM_Contribute_Form_Contribution_Confirm extends CRM_Contribute_Form_Contr
       foreach ($paymentResults as $result) {
         //CRM-18211: Fix situation where second contribution doesn't exist because it is optional.
         if ($result['contribution_id']) {
-          $this->completeTransaction($result['result'], $result['contribution_id']);
+          if (($result['result']['payment_status_id'] ?? NULL) == 1) {
+            try {
+              civicrm_api3('contribution', 'completetransaction', [
+                'id' => $result['contribution_id'],
+                'trxn_id' => $result['result']['trxn_id'] ?? NULL,
+                'payment_processor_id' => $result['result']['payment_processor_id'] ?? $this->_paymentProcessor['id'],
+                'is_transactional' => FALSE,
+                'fee_amount' => $result['result']['fee_amount'] ?? NULL,
+                'receive_date' => $result['result']['receive_date'] ?? NULL,
+                'card_type_id' => $result['result']['card_type_id'] ?? NULL,
+                'pan_truncation' => $result['result']['pan_truncation'] ?? NULL,
+              ]);
+            }
+            catch (CiviCRM_API3_Exception $e) {
+              if ($e->getErrorCode() != 'contribution_completed') {
+                \Civi::log()->error('CRM_Contribute_Form_Contribution_Confirm::completeTransaction CiviCRM_API3_Exception: ' . $e->getMessage());
+                throw new CRM_Core_Exception('Failed to update contribution in database');
+              }
+            }
+          }
         }
       }
       return;
@@ -1736,8 +1760,19 @@ class CRM_Contribute_Form_Contribution_Confirm extends CRM_Contribute_Form_Contr
           $paymentProcessorIDs = explode(CRM_Core_DAO::VALUE_SEPARATOR, $this->_values['payment_processor'] ?? NULL);
           $this->_paymentProcessor['id'] = $paymentProcessorIDs[0];
         }
-        $result = ['payment_status_id' => 1, 'contribution' => $membershipContribution];
-        $this->completeTransaction($result, $result['contribution']->id);
+        try {
+          civicrm_api3('contribution', 'completetransaction', [
+            'id' => $membershipContribution->id,
+            'payment_processor_id' => $this->_paymentProcessor['id'],
+            'is_transactional' => FALSE,
+          ]);
+        }
+        catch (CiviCRM_API3_Exception $e) {
+          if ($e->getErrorCode() != 'contribution_completed') {
+            \Civi::log()->error('CRM_Contribute_Form_Contribution_Confirm::completeTransaction CiviCRM_API3_Exception: ' . $e->getMessage());
+            throw new CRM_Core_Exception('Failed to update contribution in database');
+          }
+        }
       }
       // return as completeTransaction() already sends the receipt mail.
       return;
@@ -1859,7 +1894,7 @@ class CRM_Contribute_Form_Contribution_Confirm extends CRM_Contribute_Form_Contr
       else {
         $payment = $this->_paymentProcessor['object'];
       }
-      $result = $payment->doPayment($tempParams, 'contribute');
+      $result = $payment->doPayment($tempParams);
       $this->set('membership_trx_id', $result['trxn_id']);
       $this->assign('membership_trx_id', $result['trxn_id']);
     }
@@ -2365,7 +2400,26 @@ class CRM_Contribute_Form_Contribution_Confirm extends CRM_Contribute_Form_Contr
       }
       if (!empty($result['contribution'])) {
         // It seems this line is hit when there is a zero dollar transaction & in tests, not sure when else.
-        $this->completeTransaction($result, $result['contribution']->id);
+        if (($result['payment_status_id'] ?? NULL) == 1) {
+          try {
+            civicrm_api3('contribution', 'completetransaction', [
+              'id' => $result['contribution']->id,
+              'trxn_id' => $result['trxn_id'] ?? NULL,
+              'payment_processor_id' => $result['payment_processor_id'] ?? $this->_paymentProcessor['id'],
+              'is_transactional' => FALSE,
+              'fee_amount' => $result['fee_amount'] ?? NULL,
+              'receive_date' => $result['receive_date'] ?? NULL,
+              'card_type_id' => $result['card_type_id'] ?? NULL,
+              'pan_truncation' => $result['pan_truncation'] ?? NULL,
+            ]);
+          }
+          catch (CiviCRM_API3_Exception $e) {
+            if ($e->getErrorCode() != 'contribution_completed') {
+              \Civi::log()->error('CRM_Contribute_Form_Contribution_Confirm::completeTransaction CiviCRM_API3_Exception: ' . $e->getMessage());
+              throw new CRM_Core_Exception('Failed to update contribution in database');
+            }
+          }
+        }
       }
       return $result;
     }
@@ -2537,6 +2591,8 @@ class CRM_Contribute_Form_Contribution_Confirm extends CRM_Contribute_Form_Contr
    *
    * Completing will trigger update of related entities and emails.
    *
+   * @deprecated
+   *
    * @param array $result
    * @param int $contributionID
    *
@@ -2544,6 +2600,7 @@ class CRM_Contribute_Form_Contribution_Confirm extends CRM_Contribute_Form_Contr
    * @throws \Exception
    */
   protected function completeTransaction($result, $contributionID) {
+    CRM_Core_Error::deprecatedWarning('Use API3 Payment.create');
     if (($result['payment_status_id'] ?? NULL) == 1) {
       try {
         civicrm_api3('contribution', 'completetransaction', [
index 623c9dd26eed4f32e27b0d2f1d7d4d0fdaf9a641..91a8bcbcc0b82a196e63f2c3044cd2caee1484b9 100644 (file)
@@ -142,15 +142,20 @@ class CRM_Contribute_Form_Contribution_ThankYou extends CRM_Contribute_Form_Cont
     //pcp elements
     if ($this->_pcpId) {
       $qParams .= "&amp;pcpId={$this->_pcpId}";
-      $this->assign('pcpBlock', TRUE);
-      foreach ([
-        'pcp_display_in_roll',
-        'pcp_is_anonymous',
-        'pcp_roll_nickname',
-        'pcp_personal_note',
-      ] as $val) {
-        if (!empty($this->_params[$val])) {
-          $this->assign($val, $this->_params[$val]);
+      $this->assign('pcpBlock', FALSE);
+
+      // display honor roll data only if it's enabled for the PCP page
+      if (!empty($this->_pcpInfo['is_honor_roll'])) {
+        $this->assign('pcpBlock', TRUE);
+        foreach ([
+          'pcp_display_in_roll',
+          'pcp_is_anonymous',
+          'pcp_roll_nickname',
+          'pcp_personal_note',
+        ] as $val) {
+          if (!empty($this->_params[$val])) {
+            $this->assign($val, $this->_params[$val]);
+          }
         }
       }
     }
index 37c0191d5ef2c26b8501a783422d7b8ae8654a12..1a6c307fac9d303c441d1c2a62f1b7f9c45b3238 100644 (file)
@@ -59,4 +59,16 @@ class CRM_Contribute_Import_Form_DataSource extends CRM_Import_Form_DataSource {
     $this->submitFileForMapping('CRM_Contribute_Import_Parser_Contribution');
   }
 
+  /**
+   * @return \CRM_Contribute_Import_Parser_Contribution
+   */
+  protected function getParser(): CRM_Contribute_Import_Parser_Contribution {
+    if (!$this->parser) {
+      $this->parser = new CRM_Contribute_Import_Parser_Contribution();
+      $this->parser->setUserJobID($this->getUserJobID());
+      $this->parser->init();
+    }
+    return $this->parser;
+  }
+
 }
index 34567d0874b4709c0432ff0199d4b8fbe2da27b2..78e6a869d8b18122f47005a2dccba902d0f58baf 100644 (file)
@@ -38,7 +38,7 @@ class CRM_Contribute_Import_Form_MapField extends CRM_Import_Form_MapField {
     $requiredFields = [
       $contactORContributionId == 'contribution_id' ? 'contribution_id' : 'contribution_contact_id' => $contactORContributionId == 'contribution_id' ? ts('Contribution ID') : ts('Contact ID'),
       'total_amount' => ts('Total Amount'),
-      'financial_type' => ts('Financial Type'),
+      'financial_type_id' => ts('Financial Type'),
     ];
 
     foreach ($requiredFields as $field => $title) {
@@ -93,7 +93,7 @@ class CRM_Contribute_Import_Form_MapField extends CRM_Import_Form_MapField {
     else {
       $this->assign('rowDisplayCount', 2);
     }
-    $highlightedFields = ['financial_type', 'total_amount'];
+    $highlightedFields = ['financial_type_id', 'total_amount'];
     //CRM-2219 removing other required fields since for updation only
     //invoice id or trxn id or contribution id is required.
     if ($this->_onDuplicate == CRM_Import_Parser::DUPLICATE_UPDATE) {
@@ -166,7 +166,6 @@ class CRM_Contribute_Import_Form_MapField extends CRM_Import_Form_MapField {
     foreach ($mapperKeys as $key) {
       $this->_fieldUsed[$key] = FALSE;
     }
-    $this->_location_types = CRM_Core_PseudoConstant::get('CRM_Core_DAO_Address', 'location_type_id');
     $sel1 = $this->_mapperFields;
 
     if (!$this->get('onDuplicate')) {
@@ -420,8 +419,9 @@ class CRM_Contribute_Import_Form_MapField extends CRM_Import_Form_MapField {
       $this->controller->resetPage($this->_name);
       return;
     }
+    $this->updateUserJobMetadata('submitted_values', $this->getSubmittedValues());
 
-    $mapper = $mapperKeys = $mapperKeysMain = $mapperSoftCredit = $softCreditFields = $mapperPhoneType = $mapperSoftCreditType = [];
+    $mapper = $mapperKeysMain = $mapperSoftCredit = $softCreditFields = $mapperPhoneType = $mapperSoftCreditType = [];
     $mapperKeys = $this->controller->exportValue($this->_name, 'mapper');
 
     $softCreditTypes = CRM_Core_OptionGroup::values('soft_credit_type');
@@ -478,7 +478,8 @@ class CRM_Contribute_Import_Form_MapField extends CRM_Import_Form_MapField {
       $this->set('savedMapping', $saveMapping->id);
     }
 
-    $parser = new CRM_Contribute_Import_Parser_Contribution($mapperKeysMain, $mapperSoftCredit, $mapperPhoneType);
+    $parser = new CRM_Contribute_Import_Parser_Contribution($mapperKeysMain);
+    $parser->setUserJobID($this->getUserJobID());
     $parser->run(
       $this->getSubmittedValue('uploadFile'),
       $this->getSubmittedValue('fieldSeparator'),
@@ -496,9 +497,12 @@ class CRM_Contribute_Import_Form_MapField extends CRM_Import_Form_MapField {
    * @return \CRM_Contribute_Import_Parser_Contribution
    */
   protected function getParser(): CRM_Contribute_Import_Parser_Contribution {
-    $parser = new CRM_Contribute_Import_Parser_Contribution();
-    $parser->setUserJobID($this->getUserJobID());
-    return $parser;
+    if (!$this->parser) {
+      $this->parser = new CRM_Contribute_Import_Parser_Contribution();
+      $this->parser->setUserJobID($this->getUserJobID());
+      $this->parser->init();
+    }
+    return $this->parser;
   }
 
 }
index 97b64508818993925904002dcbe56374c52a2379..46d77feedb0e1ef8caa088807d3c8e26dbf11efd 100644 (file)
@@ -27,9 +27,6 @@ class CRM_Contribute_Import_Form_Preview extends CRM_Import_Form_Preview {
     parent::preProcess();
     //get the data from the session
     $dataValues = $this->get('dataValues');
-    $mapper = $this->get('mapper');
-    $softCreditFields = $this->get('softCreditFields');
-    $mapperSoftCreditType = $this->get('mapperSoftCreditType');
     $invalidRowCount = $this->get('invalidRowCount');
 
     //get the mapping name displayed if the mappingId is set
@@ -47,9 +44,6 @@ class CRM_Contribute_Import_Form_Preview extends CRM_Import_Form_Preview {
     }
 
     $properties = [
-      'mapper',
-      'softCreditFields',
-      'mapperSoftCreditType',
       'dataValues',
       'columnCount',
       'totalRowCount',
@@ -58,38 +52,43 @@ class CRM_Contribute_Import_Form_Preview extends CRM_Import_Form_Preview {
       'downloadErrorRecordsUrl',
     ];
     $this->setStatusUrl();
+    $this->assign('mapper', $this->getMappedFieldLabels());
 
     foreach ($properties as $property) {
       $this->assign($property, $this->get($property));
     }
   }
 
+  /**
+   * Get the mapped fields as an array of labels.
+   *
+   * e.g
+   * ['First Name', 'Employee Of - First Name', 'Home - Street Address']
+   *
+   * @return array
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   */
+  protected function getMappedFieldLabels(): array {
+    $mapper = [];
+    $parser = $this->getParser();
+    foreach ($this->getSubmittedValue('mapper') as $columnNumber => $mappedField) {
+      $mapper[$columnNumber] = $parser->getMappedFieldLabel($parser->getMappingFieldFromMapperInput($mappedField, 0, $columnNumber));
+    }
+    return $mapper;
+  }
+
   /**
    * Process the mapped fields and map it into the uploaded file preview the file and extract some summary statistics.
    */
   public function postProcess() {
     $fileName = $this->controller->exportValue('DataSource', 'uploadFile');
-    $invalidRowCount = $this->get('invalidRowCount');
     $onDuplicate = $this->get('onDuplicate');
-    $mapperSoftCreditType = $this->get('mapperSoftCreditType');
-
+    $this->updateUserJobMetadata('submitted_values', $this->getSubmittedValues());
     $mapper = $this->controller->exportValue('MapField', 'mapper');
-    $mapperKeys = [];
-    $mapperSoftCredit = [];
-    $mapperPhoneType = [];
 
-    foreach ($mapper as $key => $value) {
-      $mapperKeys[$key] = $mapper[$key][0];
-      if (isset($mapper[$key][0]) && $mapper[$key][0] == 'soft_credit' && isset($mapper[$key])) {
-        $mapperSoftCredit[$key] = $mapper[$key][1] ?? '';
-        $mapperSoftCreditType[$key] = $mapperSoftCreditType[$key]['value'];
-      }
-      else {
-        $mapperSoftCredit[$key] = $mapperSoftCreditType[$key] = NULL;
-      }
-    }
-
-    $parser = new CRM_Contribute_Import_Parser_Contribution($mapperKeys, $mapperSoftCredit, $mapperPhoneType, $mapperSoftCreditType);
+    $parser = new CRM_Contribute_Import_Parser_Contribution();
+    $parser->setUserJobID($this->getUserJobID());
 
     $mapFields = $this->get('fields');
 
@@ -139,4 +138,16 @@ class CRM_Contribute_Import_Form_Preview extends CRM_Import_Form_Preview {
     }
   }
 
+  /**
+   * @return \CRM_Contribute_Import_Parser_Contribution
+   */
+  protected function getParser(): CRM_Contribute_Import_Parser_Contribution {
+    if (!$this->parser) {
+      $this->parser = new CRM_Contribute_Import_Parser_Contribution();
+      $this->parser->setUserJobID($this->getUserJobID());
+      $this->parser->init();
+    }
+    return $this->parser;
+  }
+
 }
index 968c3ba49b54cabaa5c78698a2e9d3f25aab003e..9bf6214f8042118e0397fed00a330c1aaf04fc74 100644 (file)
@@ -28,8 +28,6 @@ class CRM_Contribute_Import_Form_Summary extends CRM_Import_Form_Summary {
     $this->assign('errorFile', $this->get('errorFile'));
 
     $totalRowCount = $this->get('totalRowCount');
-    $relatedCount = $this->get('relatedCount');
-    $totalRowCount += $relatedCount;
     $this->set('totalRowCount', $totalRowCount);
 
     $invalidRowCount = $this->get('invalidRowCount');
index d97504e9f4324752a852242682a0bce6e7a91cd2..c818b09475fd8eafab84bc184e56f51b632cb691 100644 (file)
@@ -15,6 +15,9 @@
  * @copyright CiviCRM LLC https://civicrm.org/licensing
  */
 
+use Civi\Api4\Contact;
+use Civi\Api4\Email;
+
 /**
  * Class to parse contribution csv files.
  */
@@ -22,11 +25,6 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser {
 
   protected $_mapperKeys;
 
-  private $_contactIdIndex;
-
-  protected $_mapperSoftCredit;
-  //protected $_mapperPhoneType;
-
   /**
    * Array of successfully imported contribution id's
    *
@@ -38,15 +36,10 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser {
    * Class constructor.
    *
    * @param $mapperKeys
-   * @param array $mapperSoftCredit
-   * @param null $mapperPhoneType
-   * @param array $mapperSoftCreditType
    */
-  public function __construct(&$mapperKeys = [], $mapperSoftCredit = [], $mapperPhoneType = NULL, $mapperSoftCreditType = []) {
+  public function __construct($mapperKeys = []) {
     parent::__construct();
-    $this->_mapperKeys = &$mapperKeys;
-    $this->_mapperSoftCredit = &$mapperSoftCredit;
-    $this->_mapperSoftCreditType = &$mapperSoftCreditType;
+    $this->_mapperKeys = $mapperKeys;
   }
 
   /**
@@ -164,19 +157,9 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser {
       throw new CRM_Core_Exception('Unable to determine import file');
     }
     $fileName = $fileName['name'];
-
-    switch ($contactType) {
-      case self::CONTACT_INDIVIDUAL:
-        $this->_contactType = 'Individual';
-        break;
-
-      case self::CONTACT_HOUSEHOLD:
-        $this->_contactType = 'Household';
-        break;
-
-      case self::CONTACT_ORGANIZATION:
-        $this->_contactType = 'Organization';
-    }
+    // Since $this->_contactType is still being called directly do a get call
+    // here to make sure it is instantiated.
+    $this->getContactType();
 
     $this->init();
 
@@ -405,62 +388,51 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser {
   }
 
   /**
-   * Store the soft credit field information.
+   * Get the field mappings for the import.
    *
-   * This  was perhaps done this way on the believe that a lot of code pain
-   * was worth it to avoid negligible-cost array iterations. Perhaps we could prioritise
-   * readability & maintainability next since we can just work with functions to retrieve
-   * data from the metadata.
+   * This is the same format as saved in civicrm_mapping_field except
+   * that location_type_id = 'Primary' rather than empty where relevant.
+   * Also 'im_provider_id' is mapped to the 'real' field name 'provider_id'
    *
-   * @param array $elements
+   * @return array
+   * @throws \API_Exception
    */
-  public function setActiveFieldSoftCredit($elements) {
-    foreach ((array) $elements as $i => $element) {
-      $this->_activeFields[$i]->_softCreditField = $element;
+  protected function getFieldMappings(): array {
+    $mappedFields = [];
+    foreach ($this->getSubmittedValue('mapper') as $i => $mapperRow) {
+      $mappedField = $this->getMappingFieldFromMapperInput($mapperRow, 0, $i);
+      // Just for clarity since 0 is a pseudo-value
+      unset($mappedField['mapping_id']);
+      $mappedFields[] = $mappedField;
     }
+    return $mappedFields;
   }
 
   /**
-   * Store the soft credit field type information.
-   *
-   * This  was perhaps done this way on the believe that a lot of code pain
-   * was worth it to avoid negligible-cost array iterations. Perhaps we could prioritise
-   * readability & maintainability next since we can just work with functions to retrieve
-   * data from the metadata.
+   * Transform the input parameters into the form handled by the input routine.
    *
-   * @param array $elements
-   */
-  public function setActiveFieldSoftCreditType($elements) {
-    foreach ((array) $elements as $i => $element) {
-      $this->_activeFields[$i]->_softCreditType = $element;
-    }
-  }
-
-  /**
-   * Format the field values for input to the api.
+   * @param array $values
+   *   Input parameters as they come in from the datasource
+   *   eg. ['Bob', 'Smith', 'bob@example.org', '123-456']
    *
    * @return array
-   *   (reference ) associative array of name/value pairs
+   *   Parameters mapped to CiviCRM fields based on the mapping. eg.
+   *   [
+   *     'total_amount' => '1230.99',
+   *     'financial_type_id' => 1,
+   *     'external_identifier' => 'abcd',
+   *     'soft_credit' => [3 => ['external_identifier' => '123', 'soft_credit_type_id' => 1]]
+   *
+   * @throws \API_Exception
    */
-  public function &getActiveFieldParams() {
+  public function getMappedRow(array $values): array {
     $params = [];
-    for ($i = 0; $i < $this->_activeFieldCount; $i++) {
-      if (isset($this->_activeFields[$i]->_value)) {
-        if (isset($this->_activeFields[$i]->_softCreditField)) {
-          if (!isset($params[$this->_activeFields[$i]->_name])) {
-            $params[$this->_activeFields[$i]->_name] = [];
-          }
-          $params[$this->_activeFields[$i]->_name][$i][$this->_activeFields[$i]->_softCreditField] = $this->_activeFields[$i]->_value;
-          if (isset($this->_activeFields[$i]->_softCreditType)) {
-            $params[$this->_activeFields[$i]->_name][$i]['soft_credit_type_id'] = $this->_activeFields[$i]->_softCreditType;
-          }
-        }
-
-        if (!isset($params[$this->_activeFields[$i]->_name])) {
-          if (!isset($this->_activeFields[$i]->_softCreditField)) {
-            $params[$this->_activeFields[$i]->_name] = $this->_activeFields[$i]->_value;
-          }
-        }
+    foreach ($this->getFieldMappings() as $i => $mappedField) {
+      if (!empty($mappedField['soft_credit_match_field'])) {
+        $params['soft_credit'][$i] = ['soft_credit_type_id' => $mappedField['soft_credit_type_id'], $mappedField['soft_credit_match_field'] => $values[$i]];
+      }
+      else {
+        $params[$this->getFieldMetadata($mappedField['name'])['name']] = $values[$i];
       }
     }
     return $params;
@@ -665,22 +637,6 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser {
     $this->_newContributions = [];
 
     $this->setActiveFields($this->_mapperKeys);
-    $this->setActiveFieldSoftCredit($this->_mapperSoftCredit);
-    $this->setActiveFieldSoftCreditType($this->_mapperSoftCreditType);
-
-    // FIXME: we should do this in one place together with Form/MapField.php
-    $this->_contactIdIndex = -1;
-
-    $index = 0;
-    foreach ($this->_mapperKeys as $key) {
-      switch ($key) {
-        case 'contribution_contact_id':
-          $this->_contactIdIndex = $index;
-          break;
-
-      }
-      $index++;
-    }
   }
 
   /**
@@ -736,9 +692,7 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser {
    *   CRM_Import_Parser::VALID or CRM_Import_Parser::ERROR
    */
   public function summary(&$values) {
-    $this->setActiveFieldValues($values);
-
-    $params = $this->getActiveFieldParams();
+    $params = $this->getMappedRow($values);
 
     //for date-Formats
     $errorMessage = implode('; ', $this->formatDateFields($params));
@@ -747,7 +701,7 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser {
     $params['contact_type'] = 'Contribution';
 
     //checking error in custom data
-    CRM_Contact_Import_Parser_Contact::isErrorInCustomData($params, $errorMessage);
+    $this->isErrorInCustomData($params, $errorMessage);
 
     if ($errorMessage) {
       $tempMsg = "Invalid value for field(s) : $errorMessage";
@@ -784,21 +738,14 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser {
       return CRM_Import_Parser::ERROR;
     }
 
-    $params = &$this->getActiveFieldParams();
-    $formatted = ['version' => 3, 'skipRecentView' => TRUE, 'skipCleanMoney' => FALSE];
-
+    $params = $this->getMappedRow($values);
+    $formatted = ['version' => 3, 'skipRecentView' => TRUE, 'skipCleanMoney' => FALSE, 'contribution_id' => $params['id'] ?? NULL];
     //CRM-10994
     if (isset($params['total_amount']) && $params['total_amount'] == 0) {
       $params['total_amount'] = '0.00';
     }
     $this->formatInput($params, $formatted);
 
-    static $indieFields = NULL;
-    if ($indieFields == NULL) {
-      $tempIndieFields = CRM_Contribute_DAO_Contribution::import();
-      $indieFields = $tempIndieFields;
-    }
-
     $paramValues = [];
     foreach ($params as $key => $field) {
       if ($field == NULL || $field === '') {
@@ -818,9 +765,6 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser {
     ) {
       $paramValues['contact_type'] = $this->_contactType;
     }
-    elseif (!empty($params['soft_credit'])) {
-      $paramValues['contact_type'] = $this->_contactType;
-    }
     elseif (!empty($paramValues['pledge_payment'])) {
       $paramValues['contact_type'] = $this->_contactType;
     }
@@ -829,7 +773,14 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser {
     if (!empty($paramValues['pledge_payment'])) {
       $paramValues['onDuplicate'] = $onDuplicate;
     }
-    $formatError = $this->deprecatedFormatParams($paramValues, $formatted, TRUE, $onDuplicate);
+    try {
+      $formatError = $this->deprecatedFormatParams($paramValues, $formatted, TRUE, $onDuplicate);
+    }
+    catch (CRM_Core_Exception $e) {
+      array_unshift($values, $e->getMessage());
+      $errorMapping = ['soft_credit' => self::SOFT_CREDIT_ERROR, 'pledge_payment' => self::PLEDGE_PAYMENT_ERROR];
+      return $errorMapping[$e->getErrorCode()] ?? CRM_Import_Parser::ERROR;
+    }
 
     if ($formatError) {
       array_unshift($values, $formatError['error_message']);
@@ -926,7 +877,7 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser {
       }
     }
 
-    if ($this->_contactIdIndex < 0) {
+    if (empty($formatted['contact_id'])) {
 
       $error = $this->checkContactDuplicate($paramValues);
 
@@ -1184,6 +1135,7 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser {
    * @param int $onDuplicate
    *
    * @return array|CRM_Error
+   * @throws \CRM_Core_Exception
    */
   private function deprecatedFormatParams($params, &$values, $create = FALSE, $onDuplicate = NULL) {
     require_once 'CRM/Utils/DeprecatedUtils.php';
@@ -1228,7 +1180,7 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser {
       }
 
       switch ($key) {
-        case 'contribution_contact_id':
+        case 'contact_id':
           if (!CRM_Utils_Rule::integer($value)) {
             return civicrm_api3_create_error("contact_id not valid: $value");
           }
@@ -1243,9 +1195,7 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser {
           elseif ($svq == 1) {
             return civicrm_api3_create_error("Invalid Contact ID: contact_id $value is a soft-deleted contact.");
           }
-
-          $values['contact_id'] = $values['contribution_contact_id'];
-          unset($values['contribution_contact_id']);
+          $values['contact_id'] = $value;
           break;
 
         case 'contact_type':
@@ -1342,76 +1292,14 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser {
           }
           break;
 
-        case 'financial_type':
-          // @todo add test like testPaymentTypeLabel & remove these lines in favour of 'default' part of switch.
-          require_once 'CRM/Contribute/PseudoConstant.php';
-          $contriTypes = CRM_Contribute_PseudoConstant::financialType();
-          foreach ($contriTypes as $val => $type) {
-            if (strtolower($value) == strtolower($type)) {
-              $values['financial_type_id'] = $val;
-              break;
-            }
-          }
-          if (empty($values['financial_type_id'])) {
-            return civicrm_api3_create_error("Financial Type is not valid: $value");
-          }
-          break;
-
         case 'soft_credit':
           // import contribution record according to select contact type
           // validate contact id and external identifier.
-          $value[$key] = $mismatchContactType = $softCreditContactIds = '';
-          if (isset($params[$key]) && is_array($params[$key])) {
-            foreach ($params[$key] as $softKey => $softParam) {
-              $contactId = $softParam['contact_id'] ?? NULL;
-              $externalId = $softParam['external_identifier'] ?? NULL;
-              $email = $softParam['email'] ?? NULL;
-              if ($contactId || $externalId) {
-                require_once 'CRM/Contact/DAO/Contact.php';
-                $contact = new CRM_Contact_DAO_Contact();
-                $contact->id = $contactId;
-                $contact->external_identifier = $externalId;
-                $errorMsg = NULL;
-                if (!$contact->find(TRUE)) {
-                  $field = $contactId ? ts('Contact ID') : ts('External ID');
-                  $errorMsg = ts("Soft Credit %1 - %2 doesn't exist. Row was skipped.",
-                    [1 => $field, 2 => $contactId ? $contactId : $externalId]);
-                }
-
-                if ($errorMsg) {
-                  return civicrm_api3_create_error($errorMsg);
-                }
-
-                // finally get soft credit contact id.
-                $values[$key][$softKey] = $softParam;
-                $values[$key][$softKey]['contact_id'] = $contact->id;
-              }
-              elseif ($email) {
-                if (!CRM_Utils_Rule::email($email)) {
-                  return civicrm_api3_create_error("Invalid email address $email provided for Soft Credit. Row was skipped");
-                }
-
-                // get the contact id from duplicate contact rule, if more than one contact is returned
-                // we should return error, since current interface allows only one-one mapping
-                $emailParams = [
-                  'email' => $email,
-                  'contact_type' => $params['contact_type'],
-                ];
-                $checkDedupe = _civicrm_api3_deprecated_duplicate_formatted_contact($emailParams);
-                if (!$checkDedupe['is_error']) {
-                  return civicrm_api3_create_error("Invalid email address(doesn't exist) $email for Soft Credit. Row was skipped");
-                }
-                $matchingContactIds = explode(',', $checkDedupe['error_message']['params'][0]);
-                if (count($matchingContactIds) > 1) {
-                  return civicrm_api3_create_error("Invalid email address(duplicate) $email for Soft Credit. Row was skipped");
-                }
-                if (count($matchingContactIds) == 1) {
-                  $contactId = $matchingContactIds[0];
-                  unset($softParam['email']);
-                  $values[$key][$softKey] = $softParam + ['contact_id' => $contactId];
-                }
-              }
-            }
+          foreach ($value as $softKey => $softParam) {
+            $values['soft_credit'][$softKey] = [
+              'contact_id' => $this->lookupMatchingContact($softParam),
+              'soft_credit_type_id' => $softParam['soft_credit_type_id'],
+            ];
           }
           break;
 
@@ -1537,6 +1425,8 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser {
           if (isset($fields[$key]) &&
             // Yay - just for a surprise we are inconsistent on whether we pass the pseudofield (payment_instrument)
             // or the field name (contribution_status_id)
+            // @todo - payment_instrument is goneburger - now payment_instrument_id - how
+            // can we simplify.
             (!empty($fields[$key]['is_pseudofield_for']) || !empty($fields[$key]['pseudoconstant']))
           ) {
             $realField = $fields[$key]['is_pseudofield_for'] ?? $key;
@@ -1582,8 +1472,6 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser {
    * @throws \API_Exception
    */
   public function getMappingFieldFromMapperInput(array $fieldMapping, int $mappingID, int $columnNumber): array {
-    $isRelationshipField = preg_match('/\d*_a_b|b_a$/', $fieldMapping[0]);
-    $fieldName = $isRelationshipField ? $fieldMapping[1] : $fieldMapping[0];
     return [
       'name' => $fieldMapping[0],
       'mapping_id' => $mappingID,
@@ -1598,4 +1486,80 @@ class CRM_Contribute_Import_Parser_Contribution extends CRM_Import_Parser {
     ];
   }
 
+  /**
+   * Lookup matching contact.
+   *
+   * This looks up the matching contact from the contact id, external identifier
+   * or email. For the email a straight email search is done - this is equivalent
+   * to what happens on a dedupe rule lookup when the only field is 'email' - but
+   * we can't be sure the rule is 'just email' - and we are not collecting the
+   * fields for any other lookup in the case of soft credits (if we
+   * extend this function to main-contact-lookup we can handle full dedupe
+   * lookups - but note the error messages will need tweaking.
+   *
+   * @param array $params
+   *
+   * @return int
+   *   Contact ID
+   *
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   */
+  private function lookupMatchingContact(array $params): int {
+    $lookupField = !empty($params['contact_id']) ? 'contact_id' : (!empty($params['external_identifier']) ? 'external_identifier' : 'email');
+    if (empty($params['email'])) {
+      $contact = Contact::get(FALSE)->addSelect('id')
+        ->addWhere($lookupField, '=', $params[$lookupField])
+        ->execute();
+      if (count($contact) !== 1) {
+        throw new CRM_Core_Exception(ts("Soft Credit %1 - %2 doesn't exist. Row was skipped.",
+          [
+            1 => $this->getFieldMetadata($lookupField),
+            2 => $params['contact_id'] ?? $params['external_identifier'],
+          ]));
+      }
+      return $contact->first()['id'];
+    }
+
+    if (!CRM_Utils_Rule::email($params['email'])) {
+      throw new CRM_Core_Exception(ts('Invalid email address %1 provided for Soft Credit. Row was skipped'), [1 => $params['email']]);
+    }
+    $emails = Email::get(FALSE)
+      ->addWhere('contact_id.is_deleted', '=', 0)
+      ->addWhere('contact_id.contact_type', '=', $this->getContactType())
+      ->addWhere('email', '=', $params['email'])
+      ->addSelect('contact_id')->execute();
+    if (count($emails) === 0) {
+      throw new CRM_Core_Exception(ts("Invalid email address(doesn't exist) %1 for Soft Credit. Row was skipped", [1 => $params['email']]));
+    }
+    if (count($emails) > 1) {
+      throw new CRM_Core_Exception(ts('Invalid email address(duplicate) %1 for Soft Credit. Row was skipped', [1 => $params['email']]));
+    }
+    return $emails->first()['contact_id'];
+  }
+
+  /**
+   * @param array $mappedField
+   *   Field detail as would be saved in field_mapping table
+   *   or as returned from getMappingFieldFromMapperInput
+   *
+   * @return string
+   * @throws \API_Exception
+   */
+  public function getMappedFieldLabel(array $mappedField): string {
+    if (empty($this->importableFieldsMetadata)) {
+      $this->setFieldMetadata();
+    }
+    $title = [];
+    $title[] = $this->getFieldMetadata($mappedField['name'])['title'];
+    if ($mappedField['soft_credit_match_field']) {
+      $title[] = $this->getFieldMetadata($mappedField['soft_credit_match_field'])['title'];
+    }
+    if ($mappedField['soft_credit_type_id']) {
+      $title[] = CRM_Core_PseudoConstant::getLabel('CRM_Contribute_BAO_ContributionSoft', 'soft_credit_type_id', $mappedField['soft_credit_type_id']);
+    }
+
+    return implode(' - ', $title);
+  }
+
 }
index e1219e982e8485ff5aa576b5d0f6ec29f2818b74..89b566282d2acad91073d33e2fe31ba5442385e1 100644 (file)
@@ -289,6 +289,8 @@ class CRM_Core_OptionValue {
 
       $nameTitle = [];
       if ($mode == 'contribute') {
+        // @todo - remove this - the only code place that calls
+        // this function in a way that would hit this is commented 'remove this'
         // This is part of a move towards standardising option values but we
         // should derive them from the fields array so am deprecating it again...
         // note that the reason this was needed was that payment_instrument_id was
diff --git a/CRM/Core/Smarty/plugins/modifier.crmCountCharacters.php b/CRM/Core/Smarty/plugins/modifier.crmCountCharacters.php
new file mode 100644 (file)
index 0000000..2428043
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+/**
+ * Smarty plugin
+ * @package Smarty
+ * @subpackage plugins
+ */
+
+/**
+ * Smarty count_characters modifier plugin
+ *
+ * Type:     modifier<br>
+ * Name:     crmCountCharacteres<br>
+ * Purpose:  count the number of characters in a text with handling for NULL values
+ * @link http://smarty.php.net/manual/en/language.modifier.count.characters.php
+ *          count_characters (Smarty online manual)
+ * @author   Monte Ohrt <monte at ohrt dot com>
+ * @param string $string
+ * @param boolean $include_spaces include whitespace in the character count
+ * @return integer
+ */
+function smarty_modifier_crmCountCharacters($string, $include_spaces = FALSE) {
+  if (is_null($string)) {
+    return 0;
+  }
+
+  if ($include_spaces) {
+    return(strlen($string));
+  }
+
+  return preg_match_all("/[^\s]/", $string, $match);
+}
+
+/* vim: set expandtab: */
index 92514fc07bdef087f213e6ab81a141277d310f22..71b2eb52b96d0f9ab6fbac567e79cb2a31c5c061 100644 (file)
@@ -96,4 +96,16 @@ class CRM_Custom_Import_Form_DataSource extends CRM_Import_Form_DataSource {
     $this->submitFileForMapping('CRM_Custom_Import_Parser_Api', 'multipleCustomData');
   }
 
+  /**
+   * @return CRM_Custom_Import_Parser_Api
+   */
+  protected function getParser(): CRM_Custom_Import_Parser_Api {
+    if (!$this->parser) {
+      $this->parser = new CRM_Custom_Import_Parser_Api();
+      $this->parser->setUserJobID($this->getUserJobID());
+      $this->parser->init();
+    }
+    return $this->parser;
+  }
+
 }
index 6bee78ad9f2eb1785300eb43cbf90fd6a7962497..a86abbb7fee9d0e9ec16536bba9db6c6f8e8089f 100644 (file)
@@ -69,6 +69,7 @@ class CRM_Custom_Import_Form_Preview extends CRM_Import_Form_Preview {
     }
 
     $parser = new $this->_parser($mapperKeys);
+    $parser->setUserJobID($this->getUserJobID());
     $parser->setEntity($entity);
 
     $mapFields = $this->get('fields');
@@ -115,4 +116,16 @@ class CRM_Custom_Import_Form_Preview extends CRM_Import_Form_Preview {
     }
   }
 
+  /**
+   * @return CRM_Custom_Import_Parser_Api
+   */
+  protected function getParser(): CRM_Custom_Import_Parser_Api {
+    if (!$this->parser) {
+      $this->parser = new CRM_Custom_Import_Parser_Api();
+      $this->parser->setUserJobID($this->getUserJobID());
+      $this->parser->init();
+    }
+    return $this->parser;
+  }
+
 }
index 80485e71118d79f6f3ced9792226e0d8b9be816b..2532614ca0980afde257a56ad3862da2fe91060a 100644 (file)
@@ -135,7 +135,7 @@ class CRM_Custom_Import_Parser_Api extends CRM_Import_Parser {
     $errorMessage = NULL;
 
     $contactType = $this->_contactType ? $this->_contactType : 'Organization';
-    CRM_Contact_Import_Parser_Contact::isErrorInCustomData($this->_params + ['contact_type' => $contactType], $errorMessage, $this->_contactSubType, NULL);
+    $this->isErrorInCustomData($this->_params + ['contact_type' => $contactType], $errorMessage, $this->_contactSubType, NULL);
 
     // pseudoconstants
     if ($errorMessage) {
index f07e2597b6943de1928e250978f5e6b493b9fff2..c91e89a443d53abeee2625a9957b9552cc93484b 100644 (file)
@@ -192,6 +192,7 @@ class CRM_Event_Form_Registration_Confirm extends CRM_Event_Form_Registration {
           continue;
         }
         $individualTaxAmount = 0;
+        $append = '';
         //display tax amount on confirmation page
         $taxAmount += $v['tax_amount'];
         if (is_array($v)) {
@@ -524,6 +525,12 @@ class CRM_Event_Form_Registration_Confirm extends CRM_Event_Form_Registration {
             $value['email'] = CRM_Utils_Array::valueByRegexKey('/^email-/', $value);
           }
 
+          // If registering from waitlist participant_id is set but contact_id is not.
+          // We need a contact ID to process the payment so set the "primary" contact ID.
+          if (empty($value['contact_id'])) {
+            $value['contact_id'] = $contactID;
+          }
+
           if (is_object($payment)) {
             // Not quite sure why we don't just user $value since it contains the data
             // from result
@@ -1249,6 +1256,7 @@ class CRM_Event_Form_Registration_Confirm extends CRM_Event_Form_Registration {
       $form->_paymentProcessor = $params['paymentProcessorObj'];
     }
     $form->postProcess();
+    return $form;
   }
 
   /**
index c97c9d7fdbe9f080b5c38cf6666251de15648b18..93f8812909948dd9ba83b9cc2df5d662dea223a0 100644 (file)
@@ -59,4 +59,16 @@ class CRM_Event_Import_Form_DataSource extends CRM_Import_Form_DataSource {
     $this->submitFileForMapping('CRM_Event_Import_Parser_Participant');
   }
 
+  /**
+   * @return CRM_Event_Import_Parser_Participant
+   */
+  protected function getParser(): CRM_Event_Import_Parser_Participant {
+    if (!$this->parser) {
+      $this->parser = new CRM_Event_Import_Parser_Participant();
+      $this->parser->setUserJobID($this->getUserJobID());
+      $this->parser->init();
+    }
+    return $this->parser;
+  }
+
 }
index c4907fd2a6eea11df4b05255172abbd38f4faaa7..33228af045114bdd77a9a466fe5263a2e36140d0 100644 (file)
@@ -430,4 +430,16 @@ class CRM_Event_Import_Form_MapField extends CRM_Import_Form_MapField {
     $parser->set($this);
   }
 
+  /**
+   * @return CRM_Event_Import_Parser_Participant
+   */
+  protected function getParser(): CRM_Event_Import_Parser_Participant {
+    if (!$this->parser) {
+      $this->parser = new CRM_Event_Import_Parser_Participant();
+      $this->parser->setUserJobID($this->getUserJobID());
+      $this->parser->init();
+    }
+    return $this->parser;
+  }
+
 }
index b5e0320d1ff5987fed01af4713781645133c3c0f..f099472742e0891d31f7558da34e81ce8e6b4a8b 100644 (file)
@@ -83,7 +83,7 @@ class CRM_Event_Import_Form_Preview extends CRM_Import_Form_Preview {
     }
 
     $parser = new CRM_Event_Import_Parser_Participant($mapperKeys);
-
+    $parser->setUserJobID($this->getUserJobID());
     $mapFields = $this->get('fields');
 
     foreach ($mapper as $key => $value) {
@@ -128,4 +128,16 @@ class CRM_Event_Import_Form_Preview extends CRM_Import_Form_Preview {
     }
   }
 
+  /**
+   * @return CRM_Event_Import_Parser_Participant
+   */
+  protected function getParser(): CRM_Event_Import_Parser_Participant {
+    if (!$this->parser) {
+      $this->parser = new CRM_Event_Import_Parser_Participant();
+      $this->parser->setUserJobID($this->getUserJobID());
+      $this->parser->init();
+    }
+    return $this->parser;
+  }
+
 }
index 6bb1aec88806a0fd5d442d2696840787db516ff2..3d26c21fbeac8f892af11c417e3f57764a8107db 100644 (file)
@@ -30,8 +30,6 @@ class CRM_Event_Import_Form_Summary extends CRM_Import_Form_Summary {
     $this->assign('errorFile', $this->get('errorFile'));
 
     $totalRowCount = $this->get('totalRowCount');
-    $relatedCount = $this->get('relatedCount');
-    $totalRowCount += $relatedCount;
     $this->set('totalRowCount', $totalRowCount);
 
     $invalidRowCount = $this->get('invalidRowCount');
index 261235dda7b45a9b6739ba677c9ae2a3e9bb84f5..476f0b8ccd49de2912c9d44e189b2bcb8ae316b7 100644 (file)
@@ -223,7 +223,7 @@ class CRM_Event_Import_Parser_Participant extends CRM_Import_Parser {
         }
         else {
           foreach ($val as $role) {
-            if (!CRM_Contact_Import_Parser_Contact::in_value(trim($role), $roleIDs)) {
+            if (!$this->in_value(trim($role), $roleIDs)) {
               CRM_Contact_Import_Parser_Contact::addToErrorMsg('Participant Role', $errorMessage);
               break;
             }
@@ -238,7 +238,7 @@ class CRM_Event_Import_Parser_Participant extends CRM_Import_Parser {
             break;
           }
         }
-        elseif (!CRM_Contact_Import_Parser_Contact::in_value($val, $statusIDs)) {
+        elseif (!$this->in_value($val, $statusIDs)) {
           CRM_Contact_Import_Parser_Contact::addToErrorMsg('Participant Status', $errorMessage);
           break;
         }
@@ -248,7 +248,7 @@ class CRM_Event_Import_Parser_Participant extends CRM_Import_Parser {
 
     $params['contact_type'] = 'Participant';
     //checking error in custom data
-    CRM_Contact_Import_Parser_Contact::isErrorInCustomData($params, $errorMessage);
+    $this->isErrorInCustomData($params, $errorMessage);
 
     if ($errorMessage) {
       $tempMsg = "Invalid value for field(s) : $errorMessage";
@@ -316,12 +316,6 @@ class CRM_Event_Import_Parser_Participant extends CRM_Import_Parser {
       }
     }
 
-    //date-Format part ends
-    static $indieFields = NULL;
-    if ($indieFields == NULL) {
-      $indieFields = CRM_Event_BAO_Participant::import();
-    }
-
     $formatValues = [];
     foreach ($params as $key => $field) {
       if ($field == NULL || $field === '') {
@@ -1025,4 +1019,22 @@ class CRM_Event_Import_Parser_Participant extends CRM_Import_Parser {
     fclose($fd);
   }
 
+  /**
+   * Check a value present or not in a array.
+   *
+   * @param $value
+   * @param $valueArray
+   *
+   * @return bool
+   */
+  protected function in_value($value, $valueArray) {
+    foreach ($valueArray as $key => $v) {
+      //fix for CRM-1514
+      if (strtolower(trim($v, ".")) == strtolower(trim($value, "."))) {
+        return TRUE;
+      }
+    }
+    return FALSE;
+  }
+
 }
index 558104298af3bc912be86ed7cc107857df2b5353..19fea5947c19202fc3ed50c75719d0774a8b1bd8 100644 (file)
@@ -157,12 +157,7 @@ abstract class CRM_Import_Form_DataSource extends CRM_Import_Forms {
   protected function submitFileForMapping($parserClassName, $entity = NULL) {
     $this->controller->resetPage('MapField');
     CRM_Core_Session::singleton()->set('dateTypes', $this->getSubmittedValue('dateFormats'));
-    if (!$this->getUserJobID()) {
-      $this->createUserJob();
-    }
-    else {
-      $this->updateUserJobMetadata('submitted_values', $this->getSubmittedValues());
-    }
+    $this->processDatasource();
 
     $mapper = [];
 
@@ -194,4 +189,33 @@ abstract class CRM_Import_Form_DataSource extends CRM_Import_Forms {
     return ts('Upload Data');
   }
 
+  /**
+   * Process the datasource submission - setting up the job and data source.
+   *
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   */
+  protected function processDatasource(): void {
+    if (!$this->getUserJobID()) {
+      $this->createUserJob();
+    }
+    else {
+      $this->flushDataSource();
+      $this->updateUserJobMetadata('submitted_values', $this->getSubmittedValues());
+    }
+    $this->instantiateDataSource();
+  }
+
+  /**
+   * Instantiate the datasource.
+   *
+   * This gives the datasource a chance to do any table creation etc.
+   *
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   */
+  private function instantiateDataSource(): void {
+    $this->getDataSourceObject()->initialize();
+  }
+
 }
index e752dc6c830495415ab421bf787afbcf68eaeb34..6c61172567b3ea50fd81b406cfa7f9dab2a3b854 100644 (file)
@@ -63,6 +63,11 @@ class CRM_Import_Forms extends CRM_Core_Form {
    */
   protected $userJob;
 
+  /**
+   * @var \CRM_Import_Parser
+   */
+  protected $parser;
+
   /**
    * Get User Job.
    *
@@ -562,4 +567,23 @@ class CRM_Import_Forms extends CRM_Core_Form {
     return NULL;
   }
 
+  /**
+   * Get the mapped fields as an array of labels.
+   *
+   * e.g
+   * ['First Name', 'Employee Of - First Name', 'Home - Street Address']
+   *
+   * @return array
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   */
+  protected function getMappedFieldLabels(): array {
+    $mapper = [];
+    $parser = $this->getParser();
+    foreach ($this->getSubmittedValue('mapper') as $columnNumber => $mappedField) {
+      $mapper[$columnNumber] = $parser->getMappedFieldLabel($parser->getMappingFieldFromMapperInput($mappedField, 0, $columnNumber));
+    }
+    return $mapper;
+  }
+
 }
index 9175a72ba3b5ac6d6324bee1efcb7e5ab80a9da7..fcdb58255bd968fd38681ab870d55d0ce3350c84 100644 (file)
@@ -440,7 +440,7 @@ class CRM_Import_ImportProcessor {
       'options' => ['limit' => 0],
     ])['values'];
     foreach ($fields as $index => $field) {
-      $fieldSpec = $this->getMetadata()[$fields[$index]['name']];
+      $fieldSpec = $this->getFieldMetadata($field['name']);
       $fields[$index]['label'] = $fieldSpec['title'];
       if (empty($field['location_type_id']) && !empty($fieldSpec['hasLocationType'])) {
         $fields[$index]['location_type_id'] = 'Primary';
@@ -449,6 +449,17 @@ class CRM_Import_ImportProcessor {
     $this->mappingFields = $this->rekeyBySortedColumnNumbers($fields);
   }
 
+  /**
+   * Get the metadata for the field.
+   *
+   * @param string $fieldName
+   *
+   * @return array
+   */
+  protected function getFieldMetadata(string $fieldName): array {
+    return $this->getMetadata()[$fieldName] ?? CRM_Contact_BAO_Contact::importableFields('All')[$fieldName];
+  }
+
   /**
    * Load the mapping from the database into the pre-5.50 format.
    *
@@ -535,47 +546,6 @@ class CRM_Import_ImportProcessor {
     return !empty($this->getValidRelationships()[$key]);
   }
 
-  /**
-   * Get the relevant js for quickform.
-   *
-   * @param int $column
-   *
-   * @return string
-   * @throws \CiviCRM_API3_Exception
-   */
-  public function getQuickFormJSForField($column) {
-    $columnNumbersToHide = [];
-    if ($this->getFieldName($column) === 'do_not_import') {
-      $columnNumbersToHide = [1, 2, 3];
-    }
-    elseif ($this->getRelationshipKey($column)) {
-      if (!$this->getWebsiteTypeID($column) && !$this->getLocationTypeID($column)) {
-        $columnNumbersToHide[] = 2;
-      }
-      if (!$this->getFieldName($column)) {
-        $columnNumbersToHide[] = 1;
-      }
-      if (!$this->getPhoneOrIMTypeID($column)) {
-        $columnNumbersToHide[] = 3;
-      }
-    }
-    else {
-      if (!$this->getLocationTypeID($column) && !$this->getWebsiteTypeID($column)) {
-        $columnNumbersToHide[] = 1;
-      }
-      if (!$this->getPhoneOrIMTypeID($column)) {
-        $columnNumbersToHide[] = 2;
-      }
-      $columnNumbersToHide[] = 3;
-    }
-
-    $jsClauses = [];
-    foreach ($columnNumbersToHide as $columnNumber) {
-      $jsClauses[] = $this->getFormName() . "['mapper[$column][" . $columnNumber . "]'].style.display = 'none';";
-    }
-    return empty($jsClauses) ? '' : implode("\n", $jsClauses) . "\n";
-  }
-
   /**
    * Get the defaults for the column from the saved mapping.
    *
@@ -585,19 +555,33 @@ class CRM_Import_ImportProcessor {
    * @throws \CiviCRM_API3_Exception
    */
   public function getSavedQuickformDefaultsForColumn($column) {
+    $fieldMapping = [];
+
+    // $sel1 is either unmapped, a relationship or a target field.
     if ($this->getFieldName($column) === 'do_not_import') {
-      return [];
+      return $fieldMapping;
     }
+
     if ($this->getValidRelationshipKey($column)) {
-      if ($this->getWebsiteTypeID($column)) {
-        return [$this->getValidRelationshipKey($column), $this->getFieldName($column), $this->getWebsiteTypeID($column)];
-      }
-      return [$this->getValidRelationshipKey($column), $this->getFieldName($column), $this->getLocationTypeID($column), $this->getPhoneOrIMTypeID($column)];
+      $fieldMapping[] = $this->getValidRelationshipKey($column);
     }
+
+    // $sel1
+    $fieldMapping[] = $this->getFieldName($column);
+
+    // $sel2
     if ($this->getWebsiteTypeID($column)) {
-      return [$this->getFieldName($column), $this->getWebsiteTypeID($column)];
+      $fieldMapping[] = $this->getWebsiteTypeID($column);
+    }
+    elseif ($this->getLocationTypeID($column)) {
+      $fieldMapping[] = $this->getLocationTypeID($column);
+    }
+
+    // $sel3
+    if ($this->getPhoneOrIMTypeID($column)) {
+      $fieldMapping[] = $this->getPhoneOrIMTypeID($column);
     }
-    return [(string) $this->getFieldName($column), $this->getLocationTypeID($column), $this->getPhoneOrIMTypeID($column)];
+    return $fieldMapping;
   }
 
   /**
index c8651ea4d23a52e68633b70ee2dd5d97cabd17b8..d25e5e1a55b1e6c09a5d2f4c285412d036ba0dfb 100644 (file)
@@ -9,6 +9,7 @@
  +--------------------------------------------------------------------+
  */
 
+use Civi\Api4\CustomField;
 use Civi\Api4\UserJob;
 
 /**
@@ -53,16 +54,20 @@ abstract class CRM_Import_Parser {
   protected $userJobID;
 
   /**
-   * Fields which are being handled by metadata formatting & validation functions.
+   * Potentially ambiguous options.
    *
-   * This is intended as a temporary parameter as we phase in metadata handling.
+   * For example 'UT' is a state in more than one country.
    *
-   * The end result is that all fields will be & this will go but for now it is
-   * opt in.
+   * @var array
+   */
+  protected $ambiguousOptions = [];
+
+  /**
+   * States to country mapping.
    *
    * @var array
    */
-  protected $metadataHandledFields = [];
+  protected $statesByCountry = [];
 
   /**
    * @return int|null
@@ -83,6 +88,13 @@ abstract class CRM_Import_Parser {
     return $this;
   }
 
+  /**
+   * Countries that the site is restricted to
+   *
+   * @var array|false
+   */
+  private $availableCountries;
+
   /**
    * Get User Job.
    *
@@ -284,7 +296,7 @@ abstract class CRM_Import_Parser {
         // Duplicates are being skipped so id matching is not availble.
         continue;
       }
-      $return[$name] = $field['title'];
+      $return[$name] = $field['html']['label'] ?? $field['title'];
     }
     return $return;
   }
@@ -306,7 +318,7 @@ abstract class CRM_Import_Parser {
    * file
    * @var array
    */
-  protected $_activeFields;
+  protected $_activeFields = [];
 
   /**
    * Cache the count of active fields
@@ -670,7 +682,7 @@ abstract class CRM_Import_Parser {
    */
   protected function checkContactDuplicate(&$formatValues) {
     //retrieve contact id using contact dedupe rule
-    $formatValues['contact_type'] = $formatValues['contact_type'] ?? $this->_contactType;
+    $formatValues['contact_type'] = $formatValues['contact_type'] ?? $this->getContactType();
     $formatValues['version'] = 3;
     require_once 'CRM/Utils/DeprecatedUtils.php';
     $params = $formatValues;
@@ -699,7 +711,7 @@ abstract class CRM_Import_Parser {
       }
       // CRM-17040, Considering only primary contact when importing contributions. So contribution inserts into primary contact
       // instead of soft credit contact.
-      if (is_array($field) && $key != "soft_credit") {
+      if (is_array($field) && $key !== "soft_credit") {
         foreach ($field as $value) {
           $break = FALSE;
           if (is_array($value)) {
@@ -811,43 +823,6 @@ abstract class CRM_Import_Parser {
       return TRUE;
     }
 
-    // CRM-4575
-    if (isset($values['email_greeting'])) {
-      if (!empty($params['email_greeting_id'])) {
-        $emailGreetingFilter = [
-          'contact_type' => $params['contact_type'] ?? NULL,
-          'greeting_type' => 'email_greeting',
-        ];
-        $emailGreetings = CRM_Core_PseudoConstant::greeting($emailGreetingFilter);
-        $params['email_greeting'] = $emailGreetings[$params['email_greeting_id']];
-      }
-      else {
-        $params['email_greeting'] = $values['email_greeting'];
-      }
-
-      return TRUE;
-    }
-
-    if (isset($values['postal_greeting'])) {
-      if (!empty($params['postal_greeting_id'])) {
-        $postalGreetingFilter = [
-          'contact_type' => $params['contact_type'] ?? NULL,
-          'greeting_type' => 'postal_greeting',
-        ];
-        $postalGreetings = CRM_Core_PseudoConstant::greeting($postalGreetingFilter);
-        $params['postal_greeting'] = $postalGreetings[$params['postal_greeting_id']];
-      }
-      else {
-        $params['postal_greeting'] = $values['postal_greeting'];
-      }
-      return TRUE;
-    }
-
-    if (isset($values['addressee'])) {
-      $params['addressee'] = $values['addressee'];
-      return TRUE;
-    }
-
     if (isset($values['gender'])) {
       if (!empty($params['gender_id'])) {
         $genders = CRM_Core_PseudoConstant::get('CRM_Contact_DAO_Contact', 'gender_id');
@@ -859,22 +834,6 @@ abstract class CRM_Import_Parser {
       return TRUE;
     }
 
-    if (!empty($values['preferred_communication_method'])) {
-      $comm = [];
-      $pcm = array_change_key_case(array_flip(CRM_Core_PseudoConstant::get('CRM_Contact_DAO_Contact', 'preferred_communication_method')), CASE_LOWER);
-
-      $preffComm = explode(',', $values['preferred_communication_method']);
-      foreach ($preffComm as $v) {
-        $v = strtolower(trim($v));
-        if (array_key_exists($v, $pcm)) {
-          $comm[$pcm[$v]] = 1;
-        }
-      }
-
-      $params['preferred_communication_method'] = $comm;
-      return TRUE;
-    }
-
     // format the website params.
     if (!empty($values['url'])) {
       static $websiteFields;
@@ -1196,7 +1155,7 @@ abstract class CRM_Import_Parser {
         $missingFields[$key] = implode(' ' . ts('and') . ' ', $missing);
       }
     }
-    throw new CRM_Core_Exception(($prefixString ? ($prefixString . ' ') : '') . ts('Missing required fields:') . ' ' . implode(' ' . ts('OR') . ' ', $missingFields));
+    throw new CRM_Core_Exception($prefixString . ts('Missing required fields:') . ' ' . implode(' ' . ts('OR') . ' ', $missingFields));
   }
 
   /**
@@ -1210,11 +1169,28 @@ abstract class CRM_Import_Parser {
    * @throws \API_Exception
    */
   protected function getTransformedFieldValue(string $fieldName, $importedValue) {
-    // For now only do gender_id etc as we need to work through removing duplicate handling
-    if (empty($importedValue) || !in_array($fieldName, $this->metadataHandledFields, TRUE)) {
+    if (empty($importedValue)) {
       return $importedValue;
     }
     $fieldMetadata = $this->getFieldMetadata($fieldName);
+    if (!empty($fieldMetadata['serialize']) && count(explode(',', $importedValue)) > 1) {
+      $values = [];
+      foreach (explode(',', $importedValue) as $value) {
+        $values[] = $this->getTransformedFieldValue($fieldName, $value);
+      }
+      return $values;
+    }
+    if ($fieldName === 'url') {
+      return CRM_Utils_Rule::url($importedValue) ? $importedValue : 'invalid_import_value';
+    }
+
+    if ($fieldName === 'email') {
+      return CRM_Utils_Rule::email($importedValue) ? $importedValue : 'invalid_import_value';
+    }
+
+    if ($fieldMetadata['type'] === CRM_Utils_Type::T_FLOAT) {
+      return CRM_Utils_Rule::numeric($importedValue) ? $importedValue : 'invalid_import_value';
+    }
     if ($fieldMetadata['type'] === CRM_Utils_Type::T_BOOLEAN) {
       $value = CRM_Utils_String::strtoboolstr($importedValue);
       if ($value !== FALSE) {
@@ -1226,7 +1202,18 @@ abstract class CRM_Import_Parser {
       $value = CRM_Utils_Date::formatDate($importedValue, $this->getSubmittedValue('dateFormats'));
       return ($value) ?: 'invalid_import_value';
     }
-    return $this->getFieldOptions($fieldName)[$importedValue] ?? 'invalid_import_value';
+    $options = $this->getFieldOptions($fieldName);
+    if ($options !== FALSE) {
+      if ($this->isAmbiguous($fieldName, $importedValue)) {
+        // We can't transform it at this stage. Perhaps later we can with
+        // other information such as country.
+        return $importedValue;
+      }
+
+      $comparisonValue = is_numeric($importedValue) ? $importedValue : mb_strtolower($importedValue);
+      return $options[$comparisonValue] ?? 'invalid_import_value';
+    }
+    return $importedValue;
   }
 
   /**
@@ -1255,32 +1242,74 @@ abstract class CRM_Import_Parser {
    * @throws \Civi\API\Exception\NotImplementedException
    */
   protected function getFieldMetadata(string $fieldName, bool $loadOptions = FALSE, $limitToContactType = FALSE): array {
-    $fieldMetadata = $this->getImportableFieldsMetadata()[$fieldName] ?? ($limitToContactType ? NULL : CRM_Contact_BAO_Contact::importableFields('All')[$fieldName]);
+
+    $fieldMap = $this->getOddlyMappedMetadataFields();
+    $fieldMapName = empty($fieldMap[$fieldName]) ? $fieldName : $fieldMap[$fieldName];
+
+    // This whole business of only loading metadata for one type when we actually need it for all is ... dubious.
+    if (empty($this->getImportableFieldsMetadata()[$fieldMapName])) {
+      if ($loadOptions || !$limitToContactType) {
+        $this->importableFieldsMetadata[$fieldMapName] = CRM_Contact_BAO_Contact::importableFields('All')[$fieldMapName];
+      }
+    }
+
+    $fieldMetadata = $this->getImportableFieldsMetadata()[$fieldMapName];
     if ($loadOptions && !isset($fieldMetadata['options'])) {
-      if (empty($fieldMetadata['pseudoconstant'])) {
-        $this->importableFieldsMetadata[$fieldName]['options'] = FALSE;
+      if (($fieldMetadata['data_type'] ?? '') === 'StateProvince') {
+        // Probably already loaded and also supports abbreviations - eg. NSW.
+        // Supporting for core AND custom state fields is more consistent.
+        $this->importableFieldsMetadata[$fieldMapName]['options'] = $this->getFieldOptions('state_province_id');
+        return $this->importableFieldsMetadata[$fieldMapName];
       }
-      else {
-        $options = civicrm_api4($fieldMetadata['entity'], 'getFields', [
-          'loadOptions' => ['id', 'name', 'label'],
-          'where' => [['name', '=', $fieldMetadata['name']]],
-          'select' => ['options'],
-        ])->first()['options'];
+      if (($fieldMetadata['data_type'] ?? '') === 'Country') {
+        // Probably already loaded and also supports abbreviations - eg. NSW.
+        // Supporting for core AND custom state fields is more consistent.
+        $this->importableFieldsMetadata[$fieldMapName]['options'] = $this->getFieldOptions('country_id');
+        return $this->importableFieldsMetadata[$fieldMapName];
+      }
+      $optionFieldName = empty($fieldMap[$fieldName]) ? $fieldMetadata['name'] : $fieldName;
+
+      if (!empty($fieldMetadata['custom_group_id'])) {
+        $customField = CustomField::get(FALSE)
+          ->addWhere('id', '=', $fieldMetadata['custom_field_id'])
+          ->addSelect('name', 'custom_group_id.name')
+          ->execute()
+          ->first();
+        $optionFieldName = $customField['custom_group_id.name'] . '.' . $customField['name'];
+      }
+      $options = civicrm_api4($this->getFieldEntity($fieldName), 'getFields', [
+        'loadOptions' => ['id', 'name', 'label', 'abbr'],
+        'where' => [['name', '=', $optionFieldName]],
+        'select' => ['options'],
+      ])->first()['options'];
+      if (is_array($options)) {
         // We create an array of the possible variants - notably including
-        // name AND label as either might be used, and capitalisation variants.
+        // name AND label as either might be used. We also lower case before checking
         $values = [];
         foreach ($options as $option) {
-          $values[$option['id']] = $option['id'];
-          $values[$option['label']] = $option['id'];
-          $values[$option['name']] = $option['id'];
-          $values[strtoupper($option['name'])] = $option['id'];
-          $values[strtolower($option['name'])] = $option['id'];
-          $values[strtoupper($option['label'])] = $option['id'];
-          $values[strtolower($option['label'])] = $option['id'];
+          $idKey = is_numeric($option['id']) ? $option['id'] : mb_strtolower($option['id']);
+          $values[$idKey] = $option['id'];
+          foreach (['name', 'label', 'abbr'] as $key) {
+            $optionValue = mb_strtolower($option[$key] ?? '');
+            if ($optionValue !== '') {
+              if (isset($values[$optionValue]) && $values[$optionValue] !== $option['id']) {
+                if (!isset($this->ambiguousOptions[$fieldName][$optionValue])) {
+                  $this->ambiguousOptions[$fieldName][$optionValue] = [$values[$optionValue]];
+                }
+                $this->ambiguousOptions[$fieldName][$optionValue][] = $option['id'];
+              }
+              else {
+                $values[$optionValue] = $option['id'];
+              }
+            }
+          }
         }
-        $this->importableFieldsMetadata[$fieldName]['options'] = $values;
+        $this->importableFieldsMetadata[$fieldMapName]['options'] = $values;
       }
-      return $this->importableFieldsMetadata[$fieldName];
+      else {
+        $this->importableFieldsMetadata[$fieldMapName]['options'] = $options;
+      }
+      return $this->importableFieldsMetadata[$fieldMapName];
     }
     return $fieldMetadata;
   }
@@ -1294,11 +1323,6 @@ abstract class CRM_Import_Parser {
    * @return ?string
    */
   protected function validateCustomField($customFieldID, $value, array $fieldMetaData, $dateType): ?string {
-    // validate null values for required custom fields of type boolean
-    if (!empty($fieldMetaData['is_required']) && (empty($value) && !is_numeric($value)) && $fieldMetaData['data_type'] == 'Boolean') {
-      return $fieldMetaData['label'] . '::' . $fieldMetaData['groupTitle'];
-    }
-
     /* validate the data against the CF type */
 
     if ($value) {
@@ -1312,7 +1336,7 @@ abstract class CRM_Import_Parser {
         }
         return $fieldMetaData['label'];
       }
-      elseif ($dataType == 'Boolean') {
+      elseif ($dataType === 'Boolean') {
         if (CRM_Utils_String::strtoboolstr($value) === FALSE) {
           return $fieldMetaData['label'] . '::' . $fieldMetaData['groupTitle'];
         }
@@ -1332,14 +1356,9 @@ abstract class CRM_Import_Parser {
 
       // check for values for custom fields for checkboxes and multiselect
       if ($isSerialized && $dataType != 'ContactReference') {
-        $value = trim($value);
-        $value = str_replace('|', ',', $value);
-        $mulValues = explode(',', $value);
+        $mulValues = array_filter(explode(',', str_replace('|', ',', trim($value))), 'strlen');
         $customOption = CRM_Core_BAO_CustomOption::getCustomOption($customFieldID, TRUE);
         foreach ($mulValues as $v1) {
-          if (strlen($v1) == 0) {
-            continue;
-          }
 
           $flag = FALSE;
           foreach ($customOption as $v2) {
@@ -1370,4 +1389,231 @@ abstract class CRM_Import_Parser {
     return NULL;
   }
 
+  /**
+   * Get the entity for the given field.
+   *
+   * @param string $fieldName
+   *
+   * @return mixed|null
+   * @throws \API_Exception
+   */
+  protected function getFieldEntity(string $fieldName) {
+    if ($fieldName === 'do_not_import') {
+      return NULL;
+    }
+    if (in_array($fieldName, ['email_greeting_id', 'postal_greeting_id', 'addressee_id'], TRUE)) {
+      return 'Contact';
+    }
+    $metadata = $this->getFieldMetadata($fieldName);
+    if (!isset($metadata['entity'])) {
+      return in_array($metadata['extends'], ['Individual', 'Organization', 'Household'], TRUE) ? 'Contact' : $metadata['extends'];
+    }
+
+    // Our metadata for these is fugly. Handling the fugliness during retrieval.
+    if (in_array($metadata['entity'], ['Country', 'StateProvince', 'County'], TRUE)) {
+      return 'Address';
+    }
+    return $metadata['entity'];
+  }
+
+  /**
+   * Validate the import file, updating the import table with results.
+   *
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   */
+  public function validate(): void {
+    $dataSource = $this->getDataSourceObject();
+    while ($row = $dataSource->getRow()) {
+      try {
+        $rowNumber = $row['_id'];
+        $values = array_values($row);
+        $this->validateValues($values);
+        $this->setImportStatus($rowNumber, 'NEW', '');
+      }
+      catch (CRM_Core_Exception $e) {
+        $this->setImportStatus($rowNumber, 'ERROR', $e->getMessage());
+      }
+    }
+  }
+
+  /**
+   * Search the value for the string 'invalid_import_value'.
+   *
+   * If the string is found it indicates the fields was rejected
+   * during `getTransformedValue` as not having valid data.
+   *
+   * @param string|array|int $value
+   * @param string $key
+   * @param string $prefixString
+   *
+   * @return array
+   * @throws \API_Exception
+   */
+  protected function getInvalidValues($value, string $key, string $prefixString = ''): array {
+    $errors = [];
+    if ($value === 'invalid_import_value') {
+      $errors[] = $prefixString . $this->getFieldMetadata($key)['title'];
+    }
+    elseif (is_array($value)) {
+      foreach ($value as $innerKey => $innerValue) {
+        $result = $this->getInvalidValues($innerValue, $innerKey, $prefixString);
+        if (!empty($result)) {
+          $errors = array_merge($result, $errors);
+        }
+      }
+    }
+    return array_filter($errors);
+  }
+
+  /**
+   * Get the available countries.
+   *
+   * If the site is not configured with a restriction then all countries are valid
+   * but otherwise only a select array are.
+   *
+   * @return array|false
+   *   FALSE indicates no restrictions.
+   */
+  protected function getAvailableCountries() {
+    if ($this->availableCountries === NULL) {
+      $availableCountries = Civi::settings()->get('countryLimit');
+      $this->availableCountries = !empty($availableCountries) ? array_fill_keys($availableCountries, TRUE) : FALSE;
+    }
+    return $this->availableCountries;
+  }
+
+  /**
+   * Get the metadata field for which importable fields does not key the actual field name.
+   *
+   * @return string[]
+   */
+  protected function getOddlyMappedMetadataFields(): array {
+    return [
+      'country_id' => 'country',
+      'state_province_id' => 'state_province',
+      'county_id' => 'county',
+      'email_greeting_id' => 'email_greeting',
+      'postal_greeting_id' => 'postal_greeting',
+      'addressee_id' => 'addressee',
+    ];
+  }
+
+  /**
+   * Get the default country for the site.
+   *
+   * @return int
+   */
+  protected function getSiteDefaultCountry(): int {
+    if (!isset($this->siteDefaultCountry)) {
+      $this->siteDefaultCountry = (int) Civi::settings()->get('defaultContactCountry');
+    }
+    return $this->siteDefaultCountry;
+  }
+
+  /**
+   * Is the option ambiguous.
+   *
+   * @param string $fieldName
+   * @param string $importedValue
+   */
+  protected function isAmbiguous(string $fieldName, $importedValue): bool {
+    return !empty($this->ambiguousOptions[$fieldName][mb_strtolower($importedValue)]);
+  }
+
+  /**
+   * Get the field mappings for the import.
+   *
+   * This is the same format as saved in civicrm_mapping_field except
+   * that location_type_id = 'Primary' rather than empty where relevant.
+   * Also 'im_provider_id' is mapped to the 'real' field name 'provider_id'
+   *
+   * @return array
+   * @throws \API_Exception
+   */
+  protected function getFieldMappings(): array {
+    $mappedFields = [];
+    foreach ($this->getSubmittedValue('mapper') as $i => $mapperRow) {
+      $mappedField = $this->getMappingFieldFromMapperInput($mapperRow, 0, $i);
+      // Just for clarity since 0 is a pseudo-value
+      unset($mappedField['mapping_id']);
+      $mappedFields[] = $mappedField;
+    }
+    return $mappedFields;
+  }
+
+  /**
+   * Check if an error in custom data.
+   *
+   * @deprecated all of this is duplicated if getTransformedValue is used.
+   *
+   * @param array $params
+   * @param string $errorMessage
+   *   A string containing all the error-fields.
+   *
+   * @param null $csType
+   */
+  public function isErrorInCustomData($params, &$errorMessage, $csType = NULL) {
+    $dateType = CRM_Core_Session::singleton()->get("dateTypes");
+    $errors = [];
+
+    if (!empty($params['contact_sub_type'])) {
+      $csType = $params['contact_sub_type'] ?? NULL;
+    }
+
+    if (empty($params['contact_type'])) {
+      $params['contact_type'] = 'Individual';
+    }
+
+    // get array of subtypes - CRM-18708
+    if (in_array($csType, CRM_Contact_BAO_ContactType::basicTypes(TRUE), TRUE)) {
+      $csType = $this->getSubtypes($params['contact_type']);
+    }
+
+    if (is_array($csType)) {
+      // fetch custom fields for every subtype and add it to $customFields array
+      // CRM-18708
+      $customFields = [];
+      foreach ($csType as $cType) {
+        $customFields += CRM_Core_BAO_CustomField::getFields($params['contact_type'], FALSE, FALSE, $cType);
+      }
+    }
+    else {
+      $customFields = CRM_Core_BAO_CustomField::getFields($params['contact_type'], FALSE, FALSE, $csType);
+    }
+
+    foreach ($params as $key => $value) {
+      if ($customFieldID = CRM_Core_BAO_CustomField::getKeyID($key)) {
+        //For address custom fields, we do get actual custom field value as an inner array of
+        //values so need to modify
+        if (!array_key_exists($customFieldID, $customFields)) {
+          return ts('field ID');
+        }
+        /* check if it's a valid custom field id */
+        $errors[] = $this->validateCustomField($customFieldID, $value, $customFields[$customFieldID], $dateType);
+      }
+    }
+    if ($errors) {
+      $errorMessage .= ($errorMessage ? '; ' : '') . implode('; ', array_filter($errors));
+    }
+  }
+
+  /**
+   * get subtypes given the contact type
+   *
+   * @param string $contactType
+   * @return array $subTypes
+   */
+  protected function getSubtypes($contactType) {
+    $subTypes = [];
+    $types = CRM_Contact_BAO_ContactType::subTypeInfo($contactType);
+
+    if (count($types) > 0) {
+      foreach ($types as $type) {
+        $subTypes[] = $type['name'];
+      }
+    }
+    return $subTypes;
+  }
+
 }
index 83ddc6dad59b48c9958eecef7970609df8416c4e..dbf54faa7b73e1a09d310f5313d4df585239b907 100644 (file)
@@ -1163,11 +1163,7 @@ ORDER BY   civicrm_email.is_bulkmail DESC
     }
 
     $mailParams = $headers;
-    if ($text && ($test || $contact['preferred_mail_format'] == 'Text' ||
-        $contact['preferred_mail_format'] == 'Both' ||
-        ($contact['preferred_mail_format'] == 'HTML' && !array_key_exists('html', $pEmails))
-      )
-    ) {
+    if ($text) {
       $textBody = implode('', $text);
       if ($useSmarty) {
         $textBody = $smarty->fetch("string:$textBody");
@@ -1175,10 +1171,7 @@ ORDER BY   civicrm_email.is_bulkmail DESC
       $mailParams['text'] = $textBody;
     }
 
-    if ($html && ($test || ($contact['preferred_mail_format'] == 'HTML' ||
-          $contact['preferred_mail_format'] == 'Both'
-        ))
-    ) {
+    if ($html) {
       $htmlBody = implode('', $html);
       if ($useSmarty) {
         $htmlBody = $smarty->fetch("string:$htmlBody");
@@ -1419,22 +1412,25 @@ ORDER BY   civicrm_email.is_bulkmail DESC
     if (!isset($this->id)) {
       return [];
     }
-    $mg = new CRM_Mailing_DAO_MailingGroup();
-    $mgtable = CRM_Mailing_DAO_MailingGroup::getTableName();
-    $group = CRM_Contact_BAO_Group::getTableName();
 
-    $mg->query("SELECT      $group.title as name FROM $mgtable
-                    INNER JOIN  $group ON $mgtable.entity_id = $group.id
-                    WHERE       $mgtable.mailing_id = {$this->id}
-                        AND     $mgtable.entity_table = '$group'
-                        AND     $mgtable.group_type = 'Include'
-                    ORDER BY    $group.name");
+    $mailingGroups = \Civi\Api4\MailingGroup::get()
+      ->addSelect('group.title', 'group.frontend_title')
+      ->addJoin('Group AS group', 'LEFT', ['entity_id', '=', 'group.id'])
+      ->addWhere('mailing_id', '=', $this->id)
+      ->addWhere('entity_table', '=', 'civicrm_group')
+      ->addWhere('group_type', '=', 'Include')
+      ->execute();
 
-    $groups = [];
-    while ($mg->fetch()) {
-      $groups[] = $mg->name;
+    $groupNames = [];
+
+    foreach ($mailingGroups as $mg) {
+      $name = $mg['group.frontend_title'] ?? $mg['group.title'];
+      if ($name) {
+        $groupNames[] = $name;
+      }
     }
-    return $groups;
+
+    return $groupNames;
   }
 
   /**
index 8ce02032234a3e7d3efc97d347e3021361b8336b..6457ba2c673ccc7eb24c720711fe991837bffe72 100644 (file)
@@ -67,7 +67,7 @@ class CRM_Mailing_BAO_MailingJob extends CRM_Mailing_DAO_MailingJob {
    * @return bool|null
    */
   public static function runJobs($testParams = NULL, $mode = NULL) {
-    $job = new CRM_Mailing_BAO_MailingJob();
+    $job = $mode === 'sms' ? new CRM_Mailing_BAO_SMSJob() : new CRM_Mailing_BAO_MailingJob();
 
     $jobTable = CRM_Mailing_DAO_MailingJob::getTableName();
     $mailingTable = CRM_Mailing_DAO_Mailing::getTableName();
@@ -580,15 +580,6 @@ VALUES (%1, %2, %3, %4, %5, %6, %7)
     $mail_sync_interval = Civi::settings()->get('civimail_sync_interval');
     $retryGroup = FALSE;
 
-    // CRM-15702: Sending bulk sms to contacts without e-mail address fails.
-    // Solution is to skip checking for on hold
-    //do include a statement to check wether e-mail address is on hold
-    $skipOnHold = TRUE;
-    if ($mailing->sms_provider_id) {
-      //do not include a statement to check wether e-mail address is on hold
-      $skipOnHold = FALSE;
-    }
-
     foreach ($fields as $key => $field) {
       $params[] = $field['contact_id'];
     }
@@ -596,7 +587,7 @@ VALUES (%1, %2, %3, %4, %5, %6, %7)
     [$details] = CRM_Utils_Token::getTokenDetails(
       $params,
       $returnProperties,
-      $skipOnHold, TRUE, NULL,
+      TRUE, TRUE, NULL,
       $mailing->getFlattenedTokens(),
       get_class($this),
       $this->id
@@ -633,12 +624,6 @@ VALUES (%1, %2, %3, %4, %5, %6, %7)
       $body = $message->get();
       $headers = $message->headers();
 
-      if ($mailing->sms_provider_id) {
-        $provider = CRM_SMS_Provider::singleton(['mailing_id' => $mailing->id]);
-        $body = $provider->getMessage($message, $field['contact_id'], $details[$contactID]);
-        $headers = $provider->getRecipientDetails($field, $details[$contactID]);
-      }
-
       // make $recipient actually be the *encoded* header, so as not to baffle Mail_RFC822, CRM-5743
       $recipient = $headers['To'];
       $result = NULL;
diff --git a/CRM/Mailing/BAO/SMSJob.php b/CRM/Mailing/BAO/SMSJob.php
new file mode 100644 (file)
index 0000000..d558d49
--- /dev/null
@@ -0,0 +1,108 @@
+<?php
+
+/**
+ * Job for SMS deliery functions.
+ */
+class CRM_Mailing_BAO_SMSJob extends CRM_Mailing_BAO_MailingJob {
+
+  /**
+   * This is used by CiviMail but will be made redundant by FlexMailer.
+   * @param array $fields
+   *   List of intended recipients.
+   *   Each recipient is an array with keys 'hash', 'contact_id', 'email', etc.
+   * @param $mailing
+   * @param $mailer
+   * @param $job_date
+   * @param $attachments
+   *
+   * @return bool|null
+   * @throws Exception
+   */
+  public function deliverGroup(&$fields, &$mailing, &$mailer, &$job_date, &$attachments) {
+    $count = 0;
+    // dev/core#1768 Get the mail sync interval.
+    $mail_sync_interval = Civi::settings()->get('civimail_sync_interval');
+    $retryGroup = FALSE;
+
+    foreach ($fields as $field) {
+      $contact = civicrm_api3('Contact', 'getsingle', ['id' => $field['contact_id']]);
+
+      $preview = civicrm_api3('Mailing', 'preview', [
+        'id' => $mailing->id,
+        'contact_id' => $field['contact_id'],
+      ])['values'];
+      $mailParams = [
+        'text' => $preview['body_text'],
+        'toName' => $contact['display_name'],
+        'job_id' => $this->id,
+      ];
+      CRM_Utils_Hook::alterMailParams($mailParams, 'civimail');
+      $body = $mailParams['text'];
+      $headers = ['To' => $field['phone']];
+
+      try {
+        $result = $mailer->send($headers['To'], $headers, $body, $this->id);
+
+        // Register the delivery event.
+        $deliveredParams[] = $field['id'];
+        $targetParams[] = $field['contact_id'];
+
+        $count++;
+        // dev/core#1768 Mail sync interval is now configurable.
+        if ($count % $mail_sync_interval == 0) {
+          $this->writeToDB(
+            $deliveredParams,
+            $targetParams,
+            $mailing,
+            $job_date
+          );
+          $count = 0;
+
+          // hack to stop mailing job at run time, CRM-4246.
+          // to avoid making too many DB calls for this rare case
+          // lets do it when we snapshot
+          $status = CRM_Core_DAO::getFieldValue(
+            'CRM_Mailing_DAO_MailingJob',
+            $this->id,
+            'status',
+            'id',
+            TRUE
+          );
+
+          if ($status !== 'Running') {
+            return FALSE;
+          }
+        }
+      }
+      catch (CRM_Core_Exception $e) {
+        // Handle SMS errors: CRM-15426
+        $job_id = (int) $this->id;
+        $mailing_id = (int) $mailing->id;
+        CRM_Core_Error::debug_log_message("Failed to send SMS message. Vars: mailing_id: ${mailing_id}, job_id: ${job_id}. Error message follows.");
+        CRM_Core_Error::debug_log_message($e->getMessage());
+      }
+
+      unset($result);
+
+      // If we have enabled the Throttle option, this is the time to enforce it.
+      $mailThrottleTime = Civi::settings()->get('mailThrottleTime');
+      if (!empty($mailThrottleTime)) {
+        usleep((int ) $mailThrottleTime);
+      }
+    }
+
+    $result = $this->writeToDB(
+      $deliveredParams,
+      $targetParams,
+      $mailing,
+      $job_date
+    );
+
+    if ($retryGroup) {
+      return FALSE;
+    }
+
+    return $result;
+  }
+
+}
index ff5c6ddfe26b4fc55a00d2baeb24f623b07f6acc..1cd4140b037a38eda4af09fe6d96abf055f5a75b 100644 (file)
@@ -129,15 +129,14 @@ class CRM_Mailing_Form_Approve extends CRM_Core_Form {
     // get the submitted form values.
     $params = $this->controller->exportValues($this->_name);
 
-    $ids = [];
     if (isset($this->_mailingID)) {
-      $ids['mailing_id'] = $this->_mailingID;
+      $params['id'] = $this->_mailingID;
     }
     else {
-      $ids['mailing_id'] = $this->get('mailing_id');
+      $params['id'] = $this->get('mailing_id');
     }
 
-    if (!$ids['mailing_id']) {
+    if (!$params['id']) {
       CRM_Core_Error::statusBounce(ts('No mailing id has been able to be determined'));
     }
 
@@ -154,14 +153,14 @@ class CRM_Mailing_Form_Approve extends CRM_Core_Form {
 
       // also delete any jobs associated with this mailing
       $job = new CRM_Mailing_BAO_MailingJob();
-      $job->mailing_id = $ids['mailing_id'];
+      $job->mailing_id = $params['id'];
       while ($job->fetch()) {
         CRM_Mailing_BAO_MailingJob::del($job->id);
       }
     }
     else {
       $mailing = new CRM_Mailing_BAO_Mailing();
-      $mailing->id = $ids['mailing_id'];
+      $mailing->id = $params['id'];
       $mailing->find(TRUE);
 
       $params['scheduled_date'] = CRM_Utils_Date::processDate($mailing->scheduled_date);
index 29818074424277ba02f1c832a43fc262be3f3ae8..4b63e2b4a2b808c1d2ca3e8ef943c5f13126368f 100644 (file)
@@ -150,7 +150,7 @@ class CRM_Mailing_Page_View extends CRM_Core_Page {
     $mailing = $result['values'] ?? NULL;
 
     $title = NULL;
-    if (isset($mailing['body_html']) && empty($_GET['text'])) {
+    if (!empty($mailing['body_html']) && empty($_GET['text'])) {
       $header = 'text/html; charset=utf-8';
       $content = $mailing['body_html'];
       if (strpos($content, '<head>') === FALSE && strpos($content, '<title>') === FALSE) {
index d6913e93183365e840a000d8396ba49e536e0a6d..0e91fb36c42a8bb1fa5ee1f5098fa5f479d9f580 100644 (file)
@@ -59,4 +59,16 @@ class CRM_Member_Import_Form_DataSource extends CRM_Import_Form_DataSource {
     $this->submitFileForMapping('CRM_Member_Import_Parser_Membership');
   }
 
+  /**
+   * @return \CRM_Member_Import_Parser_Membership
+   */
+  protected function getParser(): CRM_Member_Import_Parser_Membership {
+    if (!$this->parser) {
+      $this->parser = new CRM_Member_Import_Parser_Membership();
+      $this->parser->setUserJobID($this->getUserJobID());
+      $this->parser->init();
+    }
+    return $this->parser;
+  }
+
 }
index 80bdf79b87b87c9bd98711f5304592adee859264..b5cccd9dfc679d8312ecd25fd48e16d12ae2b735 100644 (file)
@@ -106,13 +106,9 @@ class CRM_Member_Import_Form_MapField extends CRM_Import_Form_MapField {
     else {
       $savedMapping = $this->get('savedMapping');
 
-      list($mappingName, $mappingContactType, $mappingLocation, $mappingPhoneType, $mappingRelation) = CRM_Core_BAO_Mapping::getMappingFields($savedMapping);
+      list($mappingName) = CRM_Core_BAO_Mapping::getMappingFields($savedMapping);
 
       $mappingName = $mappingName[1];
-      $mappingContactType = $mappingContactType[1];
-      $mappingLocation = $mappingLocation['1'] ?? NULL;
-      $mappingPhoneType = $mappingPhoneType['1'] ?? NULL;
-      $mappingRelation = $mappingRelation['1'] ?? NULL;
 
       //mapping is to be loaded from database
 
@@ -449,4 +445,16 @@ class CRM_Member_Import_Form_MapField extends CRM_Import_Form_MapField {
     $parser->set($this);
   }
 
+  /**
+   * @return \CRM_Member_Import_Parser_Membership
+   */
+  protected function getParser(): CRM_Member_Import_Parser_Membership {
+    if (!$this->parser) {
+      $this->parser = new CRM_Member_Import_Parser_Membership();
+      $this->parser->setUserJobID($this->getUserJobID());
+      $this->parser->init();
+    }
+    return $this->parser;
+  }
+
 }
index e1226987d45eb1587f62562733f89ef82e52dd84..f7d45b71d4a1170a133183003982b31ca63ee11e 100644 (file)
@@ -76,28 +76,12 @@ class CRM_Member_Import_Form_Preview extends CRM_Import_Form_Preview {
 
     $mapper = $this->controller->exportValue('MapField', 'mapper');
     $mapperKeys = [];
-    $mapperLocType = [];
-    $mapperPhoneType = [];
     // Note: we keep the multi-dimension array (even thought it's not
     // needed in the case of memberships import) so that we can merge
     // the common code with contacts import later and subclass contact
     // and membership imports from there
     foreach ($mapper as $key => $value) {
       $mapperKeys[$key] = $mapper[$key][0];
-
-      if (!empty($mapper[$key][1]) && is_numeric($mapper[$key][1])) {
-        $mapperLocType[$key] = $mapper[$key][1];
-      }
-      else {
-        $mapperLocType[$key] = NULL;
-      }
-
-      if (!empty($mapper[$key][2]) && (!is_numeric($mapper[$key][2]))) {
-        $mapperPhoneType[$key] = $mapper[$key][2];
-      }
-      else {
-        $mapperPhoneType[$key] = NULL;
-      }
     }
 
     $parser = new CRM_Member_Import_Parser_Membership($mapperKeys);
@@ -148,4 +132,16 @@ class CRM_Member_Import_Form_Preview extends CRM_Import_Form_Preview {
     }
   }
 
+  /**
+   * @return \CRM_Member_Import_Parser_Membership
+   */
+  protected function getParser(): CRM_Member_Import_Parser_Membership {
+    if (!$this->parser) {
+      $this->parser = new CRM_Member_Import_Parser_Membership();
+      $this->parser->setUserJobID($this->getUserJobID());
+      $this->parser->init();
+    }
+    return $this->parser;
+  }
+
 }
index 87640197e0a5911d82ce9aa28cd77749ace7efad..829b05c5fa6c0c47817940cf62170f38c348be63 100644 (file)
@@ -30,8 +30,6 @@ class CRM_Member_Import_Form_Summary extends CRM_Import_Form_Summary {
     $this->assign('errorFile', $this->get('errorFile'));
 
     $totalRowCount = $this->get('totalRowCount');
-    $relatedCount = $this->get('relatedCount');
-    $totalRowCount += $relatedCount;
     $this->set('totalRowCount', $totalRowCount);
 
     $invalidRowCount = $this->get('invalidRowCount');
index b484a964f8ee96b4061713c4d14ac3174443ebda..915385c6537a4d0b69bb3cfd67d53aa6d1099c53 100644 (file)
@@ -586,7 +586,7 @@ class CRM_Member_Import_Parser_Membership extends CRM_Import_Parser {
     $params['contact_type'] = 'Membership';
 
     //checking error in custom data
-    CRM_Contact_Import_Parser_Contact::isErrorInCustomData($params, $errorMessage);
+    $this->isErrorInCustomData($params, $errorMessage);
 
     if ($errorMessage) {
       $tempMsg = "Invalid value for field(s) : $errorMessage";
index 3fcb435fc17e48a0ccb1b5be46b9293b289d7691..05d4581f6cc4e79843aae9beeb85f8906ab006e1 100644 (file)
@@ -29,6 +29,7 @@ class CRM_Member_Page_UserDashboard extends CRM_Contact_Page_View_UserDashBoard
     $dao = new CRM_Member_DAO_Membership();
     $dao->contact_id = $this->_contactId;
     $dao->is_test = 0;
+    $dao->orderBy('start_date DESC');
     $dao->find();
 
     while ($dao->fetch()) {
index 4c5f027bf8ab91bf7241cfddf3308e5b02adc360..35f8be2b9d5ef9977dff4cd8de8037cae000fdac 100644 (file)
 /**
  * Track a list of known queues.
  */
-class CRM_Queue_BAO_Queue extends CRM_Queue_DAO_Queue {
+class CRM_Queue_BAO_Queue extends CRM_Queue_DAO_Queue implements \Civi\Core\HookInterface {
+
+  /**
+   * Get a list of valid statuses.
+   *
+   * The status determines whether automatic background-execution may proceed.
+   *
+   * @return string[]
+   */
+  public static function getStatuses($context = NULL) {
+    return [
+      'active' => ts('Active'),
+      // ^^ The queue is active. It will execute tasks at the nearest convenience.
+      'complete' => ts('Complete'),
+      // ^^ The queue will no longer execute tasks - because no new tasks are expected. Everything is complete.
+      'draft' => ts('Draft'),
+      // ^^ The queue is not ready to execute tasks - because we are still curating a list of tasks.
+      'aborted' => ts('Aborted'),
+      // ^^ The queue will no longer execute tasks - because it encountered an unhandled error.
+    ];
+  }
+
+  /**
+   * Get a list of valid error modes.
+   *
+   * This error-mode determines what to do if (1) a task encounters an unhandled
+   * exception, and (2) there are no hooks, and (3) there are no retries.
+   *
+   * Support for specific error-modes may depend on the `runner`.
+   *
+   * @return string[]
+   */
+  public static function getErrorModes($context = NULL) {
+    return [
+      'delete' => ts('Delete failed tasks'),
+      // ^^ Give up on the task. Carry-on with other tasks.
+      // This is more suitable if the queue is a service that lives forever and handles new/independent tasks as-they-come.
+      'abort' => ts('Abort the queue-runner'),
+      // ^^ Set the queue status to 'aborted'.
+      // This is more suitable if the queue is a closed batch of interdependent tasks.
+      // For linear queues (`Sql`), this will stop any new task-runs. For parallel queues (`SqlParallel`),
+      // it will also stop new task-runs, but on-going tasks must wind-down on their own.
+    ];
+  }
 
   /**
    * Get a list of valid queue types.
@@ -33,4 +76,24 @@ class CRM_Queue_BAO_Queue extends CRM_Queue_DAO_Queue {
     ];
   }
 
+  /**
+   * Queues which contain `CRM_Queue_Task` records should use the `task` runner to evaluate them.
+   *
+   * @code
+   * $q = Civi::queue('do-stuff', ['type' => 'Sql', 'runner' => 'task']);
+   * $q->createItem(new CRM_Queue_Task('my_callback_func', [1,2,3]));
+   * @endCode
+   *
+   * @param \CRM_Queue_Queue $queue
+   * @param array $items
+   * @param array $outcomes
+   * @throws \API_Exception
+   * @see CRM_Utils_Hook::queueRun()
+   */
+  public static function hook_civicrm_queueRun_task(CRM_Queue_Queue $queue, array $items, array &$outcomes) {
+    foreach ($items as $itemPos => $item) {
+      $outcomes[$itemPos] = (new \CRM_Queue_TaskRunner())->run($queue, $item);
+    }
+  }
+
 }
index 2d8002f5517e1deed811b2961bfbad0d87de5567..48debc8284f4fefcada730d34bcb442bb5d17c00 100644 (file)
@@ -6,7 +6,7 @@
  *
  * Generated from xml/schema/CRM/Queue/Queue.xml
  * DO NOT EDIT.  Generated by CRM_Core_CodeGen
- * (GenCodeChecksum:3b50eca7549430727237a4b2e295df1f)
+ * (GenCodeChecksum:0b068d0a6ba5d6348f11706b3854feb1)
  */
 
 /**
@@ -100,6 +100,24 @@ class CRM_Queue_DAO_Queue extends CRM_Core_DAO {
    */
   public $retry_interval;
 
+  /**
+   * Execution status
+   *
+   * @var string
+   *   (SQL type: varchar(16))
+   *   Note that values will be retrieved from the database as a string.
+   */
+  public $status;
+
+  /**
+   * Fallback behavior for unhandled errors
+   *
+   * @var string
+   *   (SQL type: varchar(16))
+   *   Note that values will be retrieved from the database as a string.
+   */
+  public $error;
+
   /**
    * Class constructor.
    */
@@ -266,6 +284,49 @@ class CRM_Queue_DAO_Queue extends CRM_Core_DAO {
           ],
           'add' => '5.48',
         ],
+        'status' => [
+          'name' => 'status',
+          'type' => CRM_Utils_Type::T_STRING,
+          'title' => ts('Status'),
+          'description' => ts('Execution status'),
+          'required' => FALSE,
+          'maxlength' => 16,
+          'size' => CRM_Utils_Type::TWELVE,
+          'where' => 'civicrm_queue.status',
+          'default' => 'active',
+          'table_name' => 'civicrm_queue',
+          'entity' => 'Queue',
+          'bao' => 'CRM_Queue_BAO_Queue',
+          'localizable' => 0,
+          'html' => [
+            'type' => 'Text',
+          ],
+          'pseudoconstant' => [
+            'callback' => 'CRM_Queue_BAO_Queue::getStatuses',
+          ],
+          'add' => '5.51',
+        ],
+        'error' => [
+          'name' => 'error',
+          'type' => CRM_Utils_Type::T_STRING,
+          'title' => ts('Error Mode'),
+          'description' => ts('Fallback behavior for unhandled errors'),
+          'required' => FALSE,
+          'maxlength' => 16,
+          'size' => CRM_Utils_Type::TWELVE,
+          'where' => 'civicrm_queue.error',
+          'table_name' => 'civicrm_queue',
+          'entity' => 'Queue',
+          'bao' => 'CRM_Queue_BAO_Queue',
+          'localizable' => 0,
+          'html' => [
+            'type' => 'Text',
+          ],
+          'pseudoconstant' => [
+            'callback' => 'CRM_Queue_BAO_Queue::getErrorModes',
+          ],
+          'add' => '5.51',
+        ],
       ];
       CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'fields_callback', Civi::$statics[__CLASS__]['fields']);
     }
index a8b98b8b64aef01441a21a8a6ed124ba09758a01..ed2d0f4388960e0e54c74e0533ee02eadeb18fdc 100644 (file)
@@ -20,6 +20,8 @@
  */
 abstract class CRM_Queue_Queue {
 
+  const DEFAULT_LEASE_TIME = 3600;
+
   /**
    * @var string
    */
@@ -44,6 +46,35 @@ abstract class CRM_Queue_Queue {
   public function __construct($queueSpec) {
     $this->_name = $queueSpec['name'];
     $this->queueSpec = $queueSpec;
+    unset($this->queueSpec['status']);
+    // Status may be meaningfully + independently toggled (eg when using type=SqlParallel,error=abort).
+    // Retaining a copy of 'status' in here would be misleading.
+  }
+
+  /**
+   * Determine whether this queue is currently active.
+   *
+   * @return bool
+   *   TRUE if runners should continue claiming new tasks from this queue
+   * @throws \CRM_Core_Exception
+   */
+  public function isActive(): bool {
+    $status = CRM_Core_DAO::getFieldValue('CRM_Queue_DAO_Queue', $this->_name, 'status', 'name', TRUE);
+    // Note: In the future, we may want to incorporate other data (like maintenance-mode or upgrade-status) in deciding active queues.
+    return ($status === 'active');
+  }
+
+  /**
+   * Change the status of the queue.
+   *
+   * @param string $status
+   *   Ex: 'active', 'draft', 'aborted'
+   */
+  public function setStatus(string $status): void {
+    CRM_Core_DAO::executeQuery('UPDATE civicrm_queue SET status = %1 WHERE name = %2', [
+      1 => ['aborted', 'String'],
+      2 => [$this->getName(), 'String'],
+    ]);
   }
 
   /**
@@ -108,13 +139,13 @@ abstract class CRM_Queue_Queue {
   /**
    * Get the next item.
    *
-   * @param int $lease_time
-   *   Seconds.
-   *
+   * @param int|null $lease_time
+   *   Hold a lease on the claimed item for $X seconds.
+   *   If NULL, inherit a default.
    * @return object
    *   with key 'data' that matches the inputted data
    */
-  abstract public function claimItem($lease_time = 3600);
+  abstract public function claimItem($lease_time = NULL);
 
   /**
    * Get the next item, even if there's an active lease
@@ -125,7 +156,7 @@ abstract class CRM_Queue_Queue {
    * @return object
    *   with key 'data' that matches the inputted data
    */
-  abstract public function stealItem($lease_time = 3600);
+  abstract public function stealItem($lease_time = NULL);
 
   /**
    * Remove an item from the queue.
@@ -135,6 +166,17 @@ abstract class CRM_Queue_Queue {
    */
   abstract public function deleteItem($item);
 
+  /**
+   * Get the full data for an item.
+   *
+   * This is a passive peek - it does not claim/steal/release anything.
+   *
+   * @param int|string $id
+   *   The unique ID of the task within the queue.
+   * @return CRM_Queue_DAO_QueueItem|object|null $dao
+   */
+  abstract public function fetchItem($id);
+
   /**
    * Return an item that could not be processed.
    *
diff --git a/CRM/Queue/Queue/BatchQueueInterface.php b/CRM/Queue/Queue/BatchQueueInterface.php
new file mode 100644 (file)
index 0000000..fd0c836
--- /dev/null
@@ -0,0 +1,57 @@
+<?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       |
+ +--------------------------------------------------------------------+
+ */
+
+/**
+ * Variation on CRM_Queue_Queue which can claim/release/delete items in batches.
+ */
+interface CRM_Queue_Queue_BatchQueueInterface {
+
+  /**
+   * Get a batch of queue items.
+   *
+   * @param int $limit
+   *   Maximum number of records to claim
+   * @param int|null $lease_time
+   *   Hold a lease on the claimed item for $X seconds.
+   *   If NULL, inherit a default.
+   * @return object
+   *   with key 'data' that matches the inputted data
+   */
+  public function claimItems(int $limit, ?int $lease_time = NULL): array;
+
+  /**
+   * Remove items from the queue.
+   *
+   * @param array $items
+   *   The item returned by claimItem.
+   */
+  public function deleteItems(array $items): void;
+
+  /**
+   * Get the full data for multiple items.
+   *
+   * This is a passive peek - it does not claim/steal/release anything.
+   *
+   * @param array $ids
+   *   The unique IDs of the tasks within the queue.
+   * @return array
+   */
+  public function fetchItems(array $ids): array;
+
+  /**
+   * Return an item that could not be processed.
+   *
+   * @param array $items
+   *   The items returned by claimItem.
+   */
+  public function releaseItems(array $items): void;
+
+}
index e745fc834b97008e9fe0aafaea9c0dbeb4822c3c..b11630f2f516a89912c25acf66b7ab16c10c304b 100644 (file)
@@ -26,6 +26,14 @@ class CRM_Queue_Queue_Memory extends CRM_Queue_Queue {
    */
   public $releaseTimes;
 
+  /**
+   * Number of times each queue item has been attempted.
+   *
+   * @var array
+   *   array(queueItemId => int $count),
+   */
+  protected $runCounts;
+
   public $nextQueueItemId = 1;
 
   /**
@@ -52,6 +60,7 @@ class CRM_Queue_Queue_Memory extends CRM_Queue_Queue {
   public function createQueue() {
     $this->items = [];
     $this->releaseTimes = [];
+    $this->runCounts = [];
   }
 
   /**
@@ -68,6 +77,7 @@ class CRM_Queue_Queue_Memory extends CRM_Queue_Queue {
   public function deleteQueue() {
     $this->items = NULL;
     $this->releaseTimes = NULL;
+    $this->runCounts = NULL;
   }
 
   /**
@@ -92,6 +102,7 @@ class CRM_Queue_Queue_Memory extends CRM_Queue_Queue {
     $id = $this->nextQueueItemId++;
     // force copy, no unintendedsharing effects from pointers
     $this->items[$id] = serialize($data);
+    $this->runCounts[$id] = 0;
   }
 
   /**
@@ -106,22 +117,26 @@ class CRM_Queue_Queue_Memory extends CRM_Queue_Queue {
   /**
    * Get and remove the next item.
    *
-   * @param int $leaseTime
-   *   Seconds.
-   *
+   * @param int|null $leaseTime
+   *   Hold a lease on the claimed item for $X seconds.
+   *   If NULL, inherit a queue default (`$queueSpec['lease_time']`) or system default (`DEFAULT_LEASE_TIME`).
    * @return object
    *   Includes key 'data' that matches the inputted data.
    */
-  public function claimItem($leaseTime = 3600) {
+  public function claimItem($leaseTime = NULL) {
+    $leaseTime = $leaseTime ?: $this->getSpec('lease_time') ?: static::DEFAULT_LEASE_TIME;
+
     // foreach hits the items in order -- but we short-circuit after the first
     foreach ($this->items as $id => $data) {
       $nowEpoch = CRM_Utils_Time::getTimeRaw();
       if (empty($this->releaseTimes[$id]) || $this->releaseTimes[$id] < $nowEpoch) {
         $this->releaseTimes[$id] = $nowEpoch + $leaseTime;
+        $this->runCounts[$id]++;
 
         $item = new stdClass();
         $item->id = $id;
         $item->data = unserialize($data);
+        $item->run_count = $this->runCounts[$id];
         return $item;
       }
       else {
@@ -136,21 +151,25 @@ class CRM_Queue_Queue_Memory extends CRM_Queue_Queue {
   /**
    * Get the next item.
    *
-   * @param int $leaseTime
-   *   Seconds.
-   *
+   * @param int|null $leaseTime
+   *   Hold a lease on the claimed item for $X seconds.
+   *   If NULL, inherit a queue default (`$queueSpec['lease_time']`) or system default (`DEFAULT_LEASE_TIME`).
    * @return object
    *   With key 'data' that matches the inputted data.
    */
-  public function stealItem($leaseTime = 3600) {
+  public function stealItem($leaseTime = NULL) {
+    $leaseTime = $leaseTime ?: $this->getSpec('lease_time') ?: static::DEFAULT_LEASE_TIME;
+
     // foreach hits the items in order -- but we short-circuit after the first
     foreach ($this->items as $id => $data) {
       $nowEpoch = CRM_Utils_Time::getTimeRaw();
       $this->releaseTimes[$id] = $nowEpoch + $leaseTime;
+      $this->runCounts[$id]++;
 
       $item = new stdClass();
       $item->id = $id;
       $item->data = unserialize($data);
+      $item->run_count = $this->runCounts[$id];
       return $item;
     }
     // nothing in queue
@@ -166,6 +185,20 @@ class CRM_Queue_Queue_Memory extends CRM_Queue_Queue {
   public function deleteItem($item) {
     unset($this->items[$item->id]);
     unset($this->releaseTimes[$item->id]);
+    unset($this->runCounts[$item->id]);
+  }
+
+  /**
+   * Get the full data for an item.
+   *
+   * This is a passive peek - it does not claim/steal/release anything.
+   *
+   * @param int|string $id
+   *   The unique ID of the task within the queue.
+   * @return CRM_Queue_DAO_QueueItem|object|null $dao
+   */
+  public function fetchItem($id) {
+    return $this->items[$id] ?? NULL;
   }
 
   /**
@@ -175,7 +208,13 @@ class CRM_Queue_Queue_Memory extends CRM_Queue_Queue {
    *   The item returned by claimItem.
    */
   public function releaseItem($item) {
-    unset($this->releaseTimes[$item->id]);
+    if (empty($this->queueSpec['retry_interval'])) {
+      unset($this->releaseTimes[$item->id]);
+    }
+    else {
+      $nowEpoch = CRM_Utils_Time::getTimeRaw();
+      $this->releaseTimes[$item->id] = $nowEpoch + $this->queueSpec['retry_interval'];
+    }
   }
 
 }
index 1cee3894c3cb35f56d0c8763e4b1a876b2fd63b6..ae9dc4e3bf596ef042b8ba440a9f3b47fda3bdb1 100644 (file)
@@ -37,19 +37,20 @@ class CRM_Queue_Queue_Sql extends CRM_Queue_Queue {
   /**
    * Get the next item.
    *
-   * @param int $lease_time
-   *   Seconds.
-   *
+   * @param int|null $lease_time
+   *   Hold a lease on the claimed item for $X seconds.
+   *   If NULL, inherit a queue default (`$queueSpec['lease_time']`) or system default (`DEFAULT_LEASE_TIME`).
    * @return object
    *   With key 'data' that matches the inputted data.
    */
-  public function claimItem($lease_time = 3600) {
+  public function claimItem($lease_time = NULL) {
+    $lease_time = $lease_time ?: $this->getSpec('lease_time') ?: static::DEFAULT_LEASE_TIME;
 
     $result = NULL;
     $dao = CRM_Core_DAO::executeQuery('LOCK TABLES civicrm_queue_item WRITE;');
     $sql = "
         SELECT first_in_queue.* FROM (
-          SELECT id, queue_name, submit_time, release_time, data
+          SELECT id, queue_name, submit_time, release_time, run_count, data
           FROM civicrm_queue_item
           WHERE queue_name = %1
           ORDER BY weight ASC, id ASC
@@ -69,10 +70,14 @@ class CRM_Queue_Queue_Sql extends CRM_Queue_Queue {
 
     if ($dao->fetch()) {
       $nowEpoch = CRM_Utils_Time::getTimeRaw();
-      CRM_Core_DAO::executeQuery("UPDATE civicrm_queue_item SET release_time = %1 WHERE id = %2", [
+      $dao->run_count++;
+      $sql = "UPDATE civicrm_queue_item SET release_time = %1, run_count = %3 WHERE id = %2";
+      $sqlParams = [
         '1' => [date('YmdHis', $nowEpoch + $lease_time), 'String'],
         '2' => [$dao->id, 'Integer'],
-      ]);
+        '3' => [$dao->run_count, 'Integer'],
+      ];
+      CRM_Core_DAO::executeQuery($sql, $sqlParams);
       // (Comment by artfulrobot Sep 2019: Not sure what the below comment means, should be removed/clarified?)
       // work-around: inconsistent date-formatting causes unintentional breakage
       #        $dao->submit_time = date('YmdHis', strtotime($dao->submit_time));
@@ -90,15 +95,17 @@ class CRM_Queue_Queue_Sql extends CRM_Queue_Queue {
   /**
    * Get the next item, even if there's an active lease
    *
-   * @param int $lease_time
-   *   Seconds.
-   *
+   * @param int|null $lease_time
+   *   Hold a lease on the claimed item for $X seconds.
+   *   If NULL, inherit a queue default (`$queueSpec['lease_time']`) or system default (`DEFAULT_LEASE_TIME`).
    * @return object
    *   With key 'data' that matches the inputted data.
    */
-  public function stealItem($lease_time = 3600) {
+  public function stealItem($lease_time = NULL) {
+    $lease_time = $lease_time ?: $this->getSpec('lease_time') ?: static::DEFAULT_LEASE_TIME;
+
     $sql = "
-      SELECT id, queue_name, submit_time, release_time, data
+      SELECT id, queue_name, submit_time, release_time, run_count, data
       FROM civicrm_queue_item
       WHERE queue_name = %1
       ORDER BY weight ASC, id ASC
@@ -110,6 +117,7 @@ class CRM_Queue_Queue_Sql extends CRM_Queue_Queue {
     $dao = CRM_Core_DAO::executeQuery($sql, $params, TRUE, 'CRM_Queue_DAO_QueueItem');
     if ($dao->fetch()) {
       $nowEpoch = CRM_Utils_Time::getTimeRaw();
+      $dao->run_count++;
       CRM_Core_DAO::executeQuery("UPDATE civicrm_queue_item SET release_time = %1 WHERE id = %2", [
         '1' => [date('YmdHis', $nowEpoch + $lease_time), 'String'],
         '2' => [$dao->id, 'Integer'],
index bf48d45e9386702b29e2d4c80dad70e68588aeda..8573b7b99883b9aad03cfe0e3ccfdf6457a27a14 100644 (file)
@@ -12,7 +12,7 @@
 /**
  * A queue implementation which stores items in the CiviCRM SQL database
  */
-class CRM_Queue_Queue_SqlParallel extends CRM_Queue_Queue {
+class CRM_Queue_Queue_SqlParallel extends CRM_Queue_Queue implements CRM_Queue_Queue_BatchQueueInterface {
 
   use CRM_Queue_Queue_SqlTrait;
 
@@ -35,28 +35,32 @@ class CRM_Queue_Queue_SqlParallel extends CRM_Queue_Queue {
   }
 
   /**
-   * Get the next item.
-   *
-   * @param int $lease_time
-   *   Seconds.
-   *
-   * @return object
-   *   With key 'data' that matches the inputted data.
+   * @inheritDoc
+   */
+  public function claimItem($lease_time = NULL) {
+    $items = $this->claimItems(1, $lease_time);
+    return $items[0] ?? NULL;
+  }
+
+  /**
+   * @inheritDoc
    */
-  public function claimItem($lease_time = 3600) {
+  public function claimItems(int $limit, ?int $lease_time = NULL): array {
+    $lease_time = $lease_time ?: $this->getSpec('lease_time') ?: static::DEFAULT_LEASE_TIME;
+    $limit = $this->getSpec('batch_limit') ? min($limit, $this->getSpec('batch_limit')) : $limit;
 
-    $result = NULL;
     $dao = CRM_Core_DAO::executeQuery('LOCK TABLES civicrm_queue_item WRITE;');
-    $sql = "SELECT id, queue_name, submit_time, release_time, data
+    $sql = "SELECT id, queue_name, submit_time, release_time, run_count, data
         FROM civicrm_queue_item
         WHERE queue_name = %1
               AND (release_time IS NULL OR release_time < %2)
         ORDER BY weight ASC, id ASC
-        LIMIT 1
+        LIMIT %3
       ";
     $params = [
       1 => [$this->getName(), 'String'],
       2 => [CRM_Utils_Time::getTime(), 'Timestamp'],
+      3 => [$limit, 'Integer'],
     ];
     $dao = CRM_Core_DAO::executeQuery($sql, $params, TRUE, 'CRM_Queue_DAO_QueueItem');
     if (is_a($dao, 'DB_Error')) {
@@ -64,19 +68,22 @@ class CRM_Queue_Queue_SqlParallel extends CRM_Queue_Queue {
       CRM_Core_Error::fatal();
     }
 
-    if ($dao->fetch()) {
+    $result = [];
+    while ($dao->fetch()) {
+      $result[] = (object) [
+        'id' => $dao->id,
+        'data' => unserialize($dao->data),
+        'queue_name' => $dao->queue_name,
+        'run_count' => 1 + (int) $dao->run_count,
+      ];
+    }
+    if ($result) {
       $nowEpoch = CRM_Utils_Time::getTimeRaw();
-      CRM_Core_DAO::executeQuery("UPDATE civicrm_queue_item SET release_time = %1 WHERE id = %2", [
-        '1' => [date('YmdHis', $nowEpoch + $lease_time), 'String'],
-        '2' => [$dao->id, 'Integer'],
+      $sql = CRM_Utils_SQL::interpolate('UPDATE civicrm_queue_item SET release_time = @RT, run_count = 1+run_count WHERE id IN (#ids)', [
+        'RT' => date('YmdHis', $nowEpoch + $lease_time),
+        'ids' => CRM_Utils_Array::collect('id', $result),
       ]);
-      // (Comment by artfulrobot Sep 2019: Not sure what the below comment means, should be removed/clarified?)
-      // work-around: inconsistent date-formatting causes unintentional breakage
-      #        $dao->submit_time = date('YmdHis', strtotime($dao->submit_time));
-      #        $dao->release_time = date('YmdHis', $nowEpoch + $lease_time);
-      #        $dao->save();
-      $dao->data = unserialize($dao->data);
-      $result = $dao;
+      CRM_Core_DAO::executeQuery($sql);
     }
 
     $dao = CRM_Core_DAO::executeQuery('UNLOCK TABLES;');
@@ -87,15 +94,17 @@ class CRM_Queue_Queue_SqlParallel extends CRM_Queue_Queue {
   /**
    * Get the next item, even if there's an active lease
    *
-   * @param int $lease_time
-   *   Seconds.
-   *
+   * @param int|null $lease_time
+   *   Hold a lease on the claimed item for $X seconds.
+   *   If NULL, inherit a queue default (`$queueSpec['lease_time']`) or system default (`DEFAULT_LEASE_TIME`).
    * @return object
    *   With key 'data' that matches the inputted data.
    */
-  public function stealItem($lease_time = 3600) {
+  public function stealItem($lease_time = NULL) {
+    $lease_time = $lease_time ?: $this->getSpec('lease_time') ?: static::DEFAULT_LEASE_TIME;
+
     $sql = "
-      SELECT id, queue_name, submit_time, release_time, data
+      SELECT id, queue_name, submit_time, release_time, run_count, data
       FROM civicrm_queue_item
       WHERE queue_name = %1
       ORDER BY weight ASC, id ASC
@@ -107,9 +116,11 @@ class CRM_Queue_Queue_SqlParallel extends CRM_Queue_Queue {
     $dao = CRM_Core_DAO::executeQuery($sql, $params, TRUE, 'CRM_Queue_DAO_QueueItem');
     if ($dao->fetch()) {
       $nowEpoch = CRM_Utils_Time::getTimeRaw();
-      CRM_Core_DAO::executeQuery("UPDATE civicrm_queue_item SET release_time = %1 WHERE id = %2", [
+      $dao->run_count++;
+      CRM_Core_DAO::executeQuery("UPDATE civicrm_queue_item SET release_time = %1, run_count = %3 WHERE id = %2", [
         '1' => [date('YmdHis', $nowEpoch + $lease_time), 'String'],
         '2' => [$dao->id, 'Integer'],
+        '3' => [$dao->run_count, 'Integer'],
       ]);
       $dao->data = unserialize($dao->data);
       return $dao;
index 4c93e2d320d27537e98c68c387793dc078c37171..0f68620c712612739ea04e097b8677c0c621ddae 100644 (file)
@@ -86,27 +86,89 @@ trait CRM_Queue_Queue_SqlTrait {
   /**
    * Remove an item from the queue.
    *
-   * @param CRM_Core_DAO|stdClass $dao
+   * @param CRM_Core_DAO|stdClass $item
    *   The item returned by claimItem.
    */
-  public function deleteItem($dao) {
-    $dao->delete();
-    $dao->free();
+  public function deleteItem($item) {
+    $this->deleteItems([$item]);
+  }
+
+  public function deleteItems($items): void {
+    if (empty($items)) {
+      return;
+    }
+    $sql = CRM_Utils_SQL::interpolate('DELETE FROM civicrm_queue_item WHERE id IN (#ids) AND queue_name = @name', [
+      'ids' => CRM_Utils_Array::collect('id', $items),
+      'name' => $this->getName(),
+    ]);
+    CRM_Core_DAO::executeQuery($sql);
+    $this->freeDAOs($items);
+  }
+
+  /**
+   * Get the full data for an item.
+   *
+   * This is a passive peek - it does not claim/steal/release anything.
+   *
+   * @param int|string $id
+   *   The unique ID of the task within the queue.
+   * @return CRM_Queue_DAO_QueueItem|object|null $dao
+   */
+  public function fetchItem($id) {
+    $items = $this->fetchItems([$id]);
+    return $items[0] ?? NULL;
+  }
+
+  public function fetchItems(array $ids): array {
+    $dao = CRM_Utils_SQL_Select::from('civicrm_queue_item')
+      ->select(['id', 'data', 'run_count'])
+      ->where('id IN (#ids)', ['ids' => $ids])
+      ->where('queue_name = @name', ['name' => $this->getName()])
+      ->execute();
+    $result = [];
+    while ($dao->fetch()) {
+      $result[] = (object) [
+        'id' => $dao->id,
+        'data' => unserialize($dao->data),
+        'run_count' => $dao->run_count,
+        'queue_name' => $this->getName(),
+      ];
+    }
+    return $result;
   }
 
   /**
    * Return an item that could not be processed.
    *
-   * @param CRM_Core_DAO $dao
+   * @param CRM_Core_DAO $item
    *   The item returned by claimItem.
    */
-  public function releaseItem($dao) {
-    $sql = "UPDATE civicrm_queue_item SET release_time = NULL WHERE id = %1";
-    $params = [
-      1 => [$dao->id, 'Integer'],
-    ];
-    CRM_Core_DAO::executeQuery($sql, $params);
-    $dao->free();
+  public function releaseItem($item) {
+    $this->releaseItems([$item]);
+  }
+
+  public function releaseItems($items): void {
+    if (empty($items)) {
+      return;
+    }
+    $sql = empty($this->queueSpec['retry_interval'])
+      ? 'UPDATE civicrm_queue_item SET release_time = NULL WHERE id IN (#ids) AND queue_name = @name'
+      : 'UPDATE civicrm_queue_item SET release_time = DATE_ADD(NOW(), INTERVAL #retry SECOND) WHERE id IN (#ids) AND queue_name = @name';
+    CRM_Core_DAO::executeQuery(CRM_Utils_SQL::interpolate($sql, [
+      'ids' => CRM_Utils_Array::collect('id', $items),
+      'name' => $this->getName(),
+      'retry' => $this->queueSpec['retry_interval'] ?? NULL,
+    ]));
+    $this->freeDAOs($items);
+  }
+
+  protected function freeDAOs($mixed) {
+    $mixed = (array) $mixed;
+    foreach ($mixed as $item) {
+      if ($item instanceof CRM_Core_DAO) {
+        $item->free();
+      }
+    }
   }
 
 }
index 4985bda7680ea349f4c63b29629885a2697dfe61..ee69cb63106b8555832c86b748ee6a0cf8bb49d9 100644 (file)
@@ -26,9 +26,9 @@
  *   This is used by some CLI upgrades.
  *
  * This runner is not appropriate for all queues or workloads, so you might choose or create
- * a different runner. For example, `CRM_Queue_Autorunner` is geared toward background task lists.
+ * a different runner. For example, `CRM_Queue_TaskRunner` is geared toward background task lists.
  *
- * @see CRM_Queue_Autorunner
+ * @see CRM_Queue_TaskRunner
  */
 class CRM_Queue_Runner {
 
index 2a9815ecfcf396a9e8931d126a115c92d3d2d667..0fd874a96b63fdf861f8266d96c767fcd94be5d3 100644 (file)
@@ -43,7 +43,7 @@ class CRM_Queue_Service {
    * @var string[]
    * @readonly
    */
-  private static $commonFields = ['name', 'type', 'runner', 'batch_limit', 'lease_time', 'retry_limit', 'retry_interval'];
+  private static $commonFields = ['name', 'type', 'runner', 'status', 'error', 'batch_limit', 'lease_time', 'retry_limit', 'retry_interval'];
 
   /**
    * FIXME: Singleton pattern should be removed when dependency-injection
@@ -90,6 +90,10 @@ class CRM_Queue_Service {
    *   - is_persistent: bool, optional; if true, then this queue is loaded from `civicrm_queue` list
    *   - runner: string, optional; if given, then items in this queue can run
    *     automatically via `hook_civicrm_queueRun_{$runner}`
+   *   - status: string, required for runnable-queues; specify whether the runner is currently active
+   *     ex: 'active', 'draft', 'completed'
+   *   - error: string, required for runnable-queues; specify what to do with unhandled errors
+   *     ex: "drop" or "abort"
    *   - batch_limit: int, Maximum number of items in a batch.
    *   - lease_time: int, When claiming an item (or batch of items) for work, how long should the item(s) be reserved. (Seconds)
    *   - retry_limit: int, Number of permitted retries. Set to zero (0) to disable.
@@ -104,6 +108,7 @@ class CRM_Queue_Service {
     if (!empty($queueSpec['is_persistent'])) {
       $queueSpec = $this->findCreateQueueSpec($queueSpec);
     }
+    $this->validateQueueSpec($queueSpec);
     $queue = $this->instantiateQueueObject($queueSpec);
     $exists = $queue->existsQueue();
     if (!$exists) {
@@ -137,10 +142,7 @@ class CRM_Queue_Service {
       return $loaded;
     }
 
-    if (empty($queueSpec['type'])) {
-      throw new \CRM_Core_Exception(sprintf('Failed to find or create persistent queue "%s". Missing field "%s".',
-        $queueSpec['name'], 'type'));
-    }
+    $this->validateQueueSpec($queueSpec);
 
     $dao = new CRM_Queue_DAO_Queue();
     $dao->name = $queueSpec['name'];
@@ -217,4 +219,38 @@ class CRM_Queue_Service {
     return $class->newInstance($queueSpec);
   }
 
+  /**
+   * Assert that the queueSpec is well-formed.
+   *
+   * @param array $queueSpec
+   * @throws \CRM_Core_Exception
+   */
+  public function validateQueueSpec(array $queueSpec): void {
+    $throw = function(string $message, ...$args) use ($queueSpec) {
+      $prefix = sprintf('Failed to create queue "%s". ', $queueSpec['name']);
+      throw new CRM_Core_Exception($prefix . sprintf($message, ...$args));
+    };
+
+    if (empty($queueSpec['type'])) {
+      $throw('Missing field "type".');
+    }
+
+    // The rest of the validations only apply to persistent, runnable queues.
+    if (empty($queueSpec['is_persistent']) || empty($queueSpec['runner'])) {
+      return;
+    }
+
+    $statuses = CRM_Queue_BAO_Queue::getStatuses();
+    $status = $queueSpec['status'] ?? NULL;
+    if (!isset($statuses[$status])) {
+      $throw('Invalid queue status "%s".', $status);
+    }
+
+    $errorModes = CRM_Queue_BAO_Queue::getErrorModes();
+    $errorMode = $queueSpec['error'] ?? NULL;
+    if ($queueSpec['runner'] === 'task' && !isset($errorModes[$errorMode])) {
+      $throw('Invalid error mode "%s".', $errorMode);
+    }
+  }
+
 }
diff --git a/CRM/Queue/TaskRunner.php b/CRM/Queue/TaskRunner.php
new file mode 100644 (file)
index 0000000..002e93b
--- /dev/null
@@ -0,0 +1,132 @@
+<?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       |
+ +--------------------------------------------------------------------+
+ */
+
+/**
+ * `CRM_Queue_TaskRunner`  a list tasks from a queue. It is designed to supported background
+ * tasks which run automatically.
+ *
+ * This runner is not appropriate for all queues or workloads, so you might choose or create
+ * a different runner. For example, `CRM_Queue_Runner` is geared toward background task lists.
+ *
+ * @see CRM_Queue_Runner
+ */
+class CRM_Queue_TaskRunner {
+
+  /**
+   * @param \CRM_Queue_Queue $queue
+   * @param $item
+   * @return string
+   *   One of the following:
+   *    - 'ok': Task executed normally. Removed from queue.
+   *    - 'retry': Task encountered an error. Will try again later.
+   *    - 'delete': Task encountered an error. Will not try again later. Removed from queue.
+   *    - 'abort': Task encountered an error. Will not try again later. Stopped the queue.
+   * @throws \API_Exception
+   */
+  public function run(CRM_Queue_Queue $queue, $item): string {
+    $this->assertType($item->data, ['CRM_Queue_Task'], 'Cannot run. Invalid task given.');
+
+    /** @var \CRM_Queue_Task $task */
+    $task = $item->data;
+
+    /** @var string $outcome One of 'ok', 'retry', 'delete', 'abort' */
+
+    if (is_numeric($queue->getSpec('retry_limit')) && $item->run_count > 1 + $queue->getSpec('retry_limit')) {
+      \Civi::log()->debug("Skipping exhausted task: " . $task->title);
+      $outcome = $queue->getSpec('error');
+      $exception = new \API_Exception(sprintf('Skipping exhausted task after %d tries: %s', $item->run_count, print_r($task, 1)), 'queue_retry_exhausted');
+    }
+    else {
+      \Civi::log()->debug("Running task: " . $task->title);
+      try {
+        $runResult = $task->run($this->createContext($queue));
+        $outcome = $runResult ? 'ok' : $queue->getSpec('error');
+        $exception = ($outcome === 'ok') ? NULL : new \API_Exception('Queue task returned false', 'queue_false');
+      }
+      catch (\Exception $e) {
+        $outcome = $queue->getSpec('error');
+        $exception = $e;
+      }
+
+      if (in_array($outcome, ['delete', 'abort']) && $this->isRetriable($queue, $item)) {
+        $outcome = 'retry';
+      }
+    }
+
+    if ($outcome !== 'ok') {
+      \CRM_Utils_Hook::queueTaskError($queue, $item, $outcome, $exception);
+    }
+
+    if ($outcome === 'ok') {
+      $queue->deleteItem($item);
+      return $outcome;
+    }
+
+    $logDetails = [
+      'id' => $queue->getName() . '#' . $item->id,
+      'task' => CRM_Utils_Array::subset((array) $task, ['title', 'callback', 'arguments']),
+      'outcome' => $outcome,
+      'message' => $exception ? $exception->getMessage() : NULL,
+      'exception' => $exception,
+    ];
+
+    switch ($outcome) {
+      case 'retry':
+        \Civi::log('queue')->error('Task "{id}" failed and should be retried. {message}', $logDetails);
+        $queue->releaseItem($item);
+        break;
+
+      case 'delete':
+        \Civi::log('queue')->error('Task "{id}" failed and will be deleted. {message}', $logDetails);
+        $queue->deleteItem($item);
+        break;
+
+      case 'abort':
+        \Civi::log('queue')->error('Task "{id}" failed. Queue processing aborted. {message}', $logDetails);
+        $queue->setStatus('aborted');
+        $queue->releaseItem($item); /* Sysadmin might inspect, fix, and then resume. Item should be accessible. */
+        break;
+
+      default:
+        \Civi::log('queue')->critical('Unrecognized outcome for task "{id}": {outcome}', $logDetails);
+        break;
+    }
+
+    return $outcome;
+  }
+
+  /**
+   * @param \CRM_Queue_Queue $queue
+   * return CRM_Queue_TaskContext;
+   */
+  private function createContext(\CRM_Queue_Queue $queue): \CRM_Queue_TaskContext {
+    $taskCtx = new \CRM_Queue_TaskContext();
+    $taskCtx->queue = $queue;
+    $taskCtx->log = \CRM_Core_Error::createDebugLogger();
+    return $taskCtx;
+  }
+
+  private function assertType($object, array $types, string $message) {
+    foreach ($types as $type) {
+      if ($object instanceof  $type) {
+        return;
+      }
+    }
+    throw new \Exception($message);
+  }
+
+  private function isRetriable(\CRM_Queue_Queue $queue, $item): bool {
+    return property_exists($item, 'run_count')
+      && is_numeric($queue->getSpec('retry_limit'))
+      && $queue->getSpec('retry_limit') + 1 > $item->run_count;
+  }
+
+}
index 3ce289b632ece55c19e3bc5faf981c1820036319..a06ce0ca74644a9227bfe1cd547ce59375a2b7f1 100644 (file)
@@ -3014,7 +3014,9 @@ WHERE cg.extends IN ('" . implode("','", $this->_customGroupExtends) . "') AND
     // fields array is missing because form building etc is skipped
     // in dashboard mode for report
     //@todo - this could be done in the dashboard no we have a setter
-    if (empty($this->_params['fields']) && !$this->_noFields) {
+    if (empty($this->_params['fields']) && !$this->_noFields
+      && empty($this->_params['task'])
+    ) {
       $this->setParams($this->_formValues);
     }
 
index c1f740b5b2c1526c30caa97a6f6f31ee7b8f82ea..9cb40538cdca5efada7b9f8c178919f3ce4ff097 100644 (file)
@@ -83,38 +83,6 @@ abstract class CRM_SMS_Provider {
    */
   abstract public function send($recipients, $header, $message, $dncID = NULL);
 
-  /**
-   * Return message text.
-   *
-   * Child class could override this function to have better control over the message being sent.
-   *
-   * @param Mail_mime $message
-   * @param int $contactID
-   * @param array $contactDetails
-   *
-   * @return string
-   */
-  public function getMessage($message, $contactID, $contactDetails) {
-    $html = $message->getHTMLBody();
-    $text = $message->getTXTBody();
-
-    return $html ? $html : $text;
-  }
-
-  /**
-   * Get recipient details.
-   *
-   * @param array $fields
-   * @param array $additionalDetails
-   *
-   * @return mixed
-   */
-  public function getRecipientDetails($fields, $additionalDetails) {
-    // we could do more altering here
-    $fields['To'] = $fields['phone'];
-    return $fields;
-  }
-
   /**
    * @param int $apiMsgID
    * @param $message
index 836ff3facae6acf968fd62e3c0fa9744b2905d78..fe10b78e63308ec6f84bc654d05416076bcf8187 100644 (file)
@@ -32,6 +32,30 @@ class CRM_Upgrade_Incremental_php_FiveFiftyOne extends CRM_Upgrade_Incremental_B
   public function upgrade_5_51_alpha1($rev): void {
     $this->addTask(ts('Upgrade DB to %1: SQL', [1 => $rev]), 'runSql', $rev);
     $this->addTask(ts('Convert import mappings to use names'), 'convertMappingFieldLabelsToNames', $rev);
+    $this->addTask('Add column "civicrm_queue.status"', 'addColumn', 'civicrm_queue',
+      'status', "varchar(16) NULL DEFAULT 'active' COMMENT 'Execution status'");
+    $this->addTask('Add column "civicrm_queue.error"', 'addColumn', 'civicrm_queue',
+      'error', "varchar(16) NULL COMMENT 'Fallback behavior for unhandled errors'");
+    $this->addTask('Backfill "civicrm_queue.status" and "civicrm_queue.error")', 'fillQueueColumns');
+  }
+
+  public static function fillQueueColumns($ctx): bool {
+    // Generally, anything we do here is nonsensical because there shouldn't be much real world data,
+    // and the goal is to require something specific going forward (for anything that has an automatic runner).
+    // But this ensures that satisfy the invariant.
+    //
+    // What default value of "error" should apply to pre-existing queues (if they somehow exist)?
+    // Go back to our heuristic "short-term/finite queue <=> abort" vs "long-term/infinite queue <=> log".
+    // We don't have adequate data to differentiate these, so some will be wrong/suboptimal.
+    // What's the impact of getting it wrong?
+    // - For a finite/short-term queue, work has finished already (or will finish soon), so there is
+    //   very limited impact to wrongly setting `error=delete`.
+    // - For an infinite/long-term queue, work will continue indefinitely into the future. The impact
+    //   of wrongly setting `error=abort` would continue indefinitely to the future.
+    // Therefore, backfilling `error=log` is less-problematic than backfilling `error=abort`.
+    CRM_Core_DAO::executeQuery('UPDATE civicrm_queue SET error = "delete" WHERE runner IS NOT NULL AND error IS NULL');
+    CRM_Core_DAO::executeQuery('UPDATE civicrm_queue SET status = IF(runner IS NULL, NULL, "active")');
+    return TRUE;
   }
 
   /**
@@ -59,6 +83,8 @@ class CRM_Upgrade_Incremental_php_FiveFiftyOne extends CRM_Upgrade_Incremental_B
     $fieldMap[ts('Soft Credit')] = 'soft_credit';
     $fieldMap[ts('Pledge Payment')] = 'pledge_payment';
     $fieldMap[ts(ts('Pledge ID'))] = 'pledge_id';
+    $fieldMap[ts(ts('Financial Type'))] = 'financial_type_id';
+    $fieldMap[ts(ts('Payment Method'))] = 'payment_instrument_id';
 
     foreach ($mappings as $mapping) {
       if (!empty($fieldMap[$mapping['name']])) {
index 5968ae54e0af42a4b329d9e791eabd8627002dc9..9f5a493844f298b7c47ddcb8229fb0743a772df1 100644 (file)
@@ -184,8 +184,9 @@ class CRM_Upgrade_Snapshot {
    *   How long should we retain old snapshots?
    *   Time is measured in terms of MINOR versions - eg "4" means "retain for 4 MINOR versions".
    *   Thus, on v5.60, you could delete any snapshots predating 5.56.
+   * @return bool
    */
-  public static function cleanupTask(?CRM_Queue_TaskContext $ctx = NULL, string $owner = 'civicrm', ?string $version = NULL, ?int $cleanupAfter = NULL): void {
+  public static function cleanupTask(?CRM_Queue_TaskContext $ctx = NULL, string $owner = 'civicrm', ?string $version = NULL, ?int $cleanupAfter = NULL): bool {
     $version = $version ?: CRM_Core_BAO_Domain::version();
     $cleanupAfter = $cleanupAfter ?: static::$cleanupAfter;
 
@@ -213,6 +214,7 @@ class CRM_Upgrade_Snapshot {
     });
 
     array_map(['CRM_Core_BAO_SchemaHandler', 'dropTable'], $oldTables);
+    return TRUE;
   }
 
 }
index ff30f95cb138c82f9781db19cb0fc3512b65fcc6..ff54a5b83b56ac42f42389c02fa8ccdc8196c931 100644 (file)
@@ -598,4 +598,13 @@ class CRM_Utils_Address {
     return CRM_Utils_Address::format($addressFields);
   }
 
+  /**
+   * @return string
+   */
+  public static function getDefaultDistanceUnit() {
+    $countryDefault = Civi::settings()->get('defaultContactCountry');
+    // US, UK use miles. Everything else is Km
+    return ($countryDefault == '1228' || $countryDefault == '1226') ? 'miles' : 'km';
+  }
+
 }
index 84618bd26358d589a0ed12e407f28668a8cf9803..97df20cb12745b62569389eeb68e0f375c2297f4 100644 (file)
@@ -115,7 +115,9 @@ class CRM_Utils_Cache_SqlGroup implements CRM_Utils_Cache_Interface {
     }
 
     if (is_int($ttl) && $ttl <= 0) {
-      return $this->delete($key);
+      $result = $this->delete($key);
+      $lock->release();
+      return $result;
     }
 
     $dataExists = CRM_Core_DAO::singleValueQuery("SELECT COUNT(*) FROM {$this->table} WHERE {$this->where($key)}");
index f2b718c06d2bb25851c61b0a6008939a862a6fe9..4bfe2c71f435a3dd38be709d637e198234a07d8d 100644 (file)
@@ -42,27 +42,33 @@ function _civicrm_api3_deprecated_duplicate_formatted_contact($params) {
 
     if ($contact->find(TRUE)) {
       if ($params['contact_type'] != $contact->contact_type) {
-        return civicrm_api3_create_error("Mismatched contact IDs OR Mismatched contact Types");
+        return ['is_error' => 1, 'error_message' => 'Mismatched contact IDs OR Mismatched contact Types'];
       }
-
-      $error = CRM_Core_Error::createError("Found matching contacts: $contact->id",
-        CRM_Core_Error::DUPLICATE_CONTACT,
-        'Fatal', $contact->id
-      );
-      return civicrm_api3_create_error($error->pop());
+      return [
+        'is_error' => 1,
+        'error_message' => [
+          'code' => CRM_Core_Error::DUPLICATE_CONTACT,
+          'params' => $contact->id,
+          'level' => 'Fatal',
+          'message' => "Found matching contacts: $contact->id",
+        ],
+      ];
     }
   }
   else {
     $ids = CRM_Contact_BAO_Contact::getDuplicateContacts($params, $params['contact_type'], 'Unsupervised');
 
     if (!empty($ids)) {
-      $ids = implode(',', $ids);
-      $error = CRM_Core_Error::createError("Found matching contacts: $ids",
-        CRM_Core_Error::DUPLICATE_CONTACT,
-        'Fatal', $ids
-      );
-      return civicrm_api3_create_error($error->pop());
+      return [
+        'is_error' => 1,
+        'error_message' => [
+          'code' => CRM_Core_Error::DUPLICATE_CONTACT,
+          'params' => $ids,
+          'level' => 'Fatal',
+          'message' => 'Found matching contacts: ' . implode(',', $ids),
+        ],
+      ];
     }
   }
-  return civicrm_api3_create_success(TRUE);
+  return ['is_error' => 0];
 }
index ad670f6c94ffe796fde3d12bba175f70a4b98ca8..0885a3d3ae6a59daa4d31dc7e8a707c33b05af3e 100644 (file)
@@ -51,8 +51,6 @@ class CRM_Utils_Geocode_Google {
       return FALSE;
     }
 
-    $config = CRM_Core_Config::singleton();
-
     $add = '';
 
     if (!empty($values['street_address'])) {
@@ -99,6 +97,37 @@ class CRM_Utils_Geocode_Google {
       $add .= '+' . urlencode(str_replace('', '+', $values['country']));
     }
 
+    $coord = self::makeRequest($add);
+
+    $values['geo_code_1'] = $coord['geo_code_1'] ?? 'null';
+    $values['geo_code_2'] = $coord['geo_code_2'] ?? 'null';
+
+    if (isset($coord['geo_code_error'])) {
+      $values['geo_code_error'] = $coord['geo_code_error'];
+    }
+
+    return isset($coord['geo_code_1'], $coord['geo_code_2']);
+  }
+
+  /**
+   * @param string $address
+   *   Plain text address
+   * @return array
+   * @throws \GuzzleHttp\Exception\GuzzleException
+   */
+  public static function getCoordinates($address) {
+    return self::makeRequest(urlencode($address));
+  }
+
+  /**
+   * @param string $add
+   *   Url-encoded address
+   * @return array
+   * @throws \GuzzleHttp\Exception\GuzzleException
+   */
+  private static function makeRequest($add) {
+
+    $config = CRM_Core_Config::singleton();
     if (!empty($config->geoAPIKey)) {
       $add .= '&key=' . urlencode($config->geoAPIKey);
     }
@@ -115,7 +144,7 @@ class CRM_Utils_Geocode_Google {
     if ($xml === FALSE) {
       // account blocked maybe?
       CRM_Core_Error::debug_var('Geocoding failed.  Message from Google:', $string);
-      return FALSE;
+      return ['geo_code_error' => $string];
     }
 
     if (isset($xml->status)) {
@@ -126,23 +155,22 @@ class CRM_Utils_Geocode_Google {
       ) {
         $ret = $xml->result->geometry->location->children();
         if ($ret->lat && $ret->lng) {
-          $values['geo_code_1'] = (float) $ret->lat;
-          $values['geo_code_2'] = (float) $ret->lng;
-          return TRUE;
+          return [
+            'geo_code_1' => (float) $ret->lat,
+            'geo_code_2' => (float) $ret->lng,
+          ];
         }
       }
       elseif ($xml->status == 'ZERO_RESULTS') {
         // reset the geo code values if we did not get any good values
-        $values['geo_code_1'] = $values['geo_code_2'] = 'null';
-        return FALSE;
+        return [];
       }
       else {
         CRM_Core_Error::debug_var("Geocoding failed. Message from Google: ({$xml->status})", (string ) $xml->error_message);
-        $values['geo_code_1'] = $values['geo_code_2'] = 'null';
-        $values['geo_code_error'] = $xml->status;
-        return FALSE;
+        return ['geo_code_error' => $xml->status];
       }
     }
+    return [];
   }
 
 }
index 5f6a6027a61fc18e5354272c3e33d2e527454f5e..8a0acc4badfb571665733c90d3895bb2f179ce0b 100644 (file)
@@ -89,11 +89,10 @@ class CRM_Utils_GeocodeProvider {
   }
 
   /**
-   * Reset geoprovider (after it has been disabled).
+   * Reset geoprovider (after settting has been changed).
    */
   public static function reset() {
     self::$providerClassName = NULL;
-    self::getUsableClassName();
   }
 
 }
index 76c3cd004751765ceb8b868be137d7cc70c6bf67..d2fb744c88439184311e67f6ccecfb3c853134b7 100644 (file)
@@ -2727,6 +2727,62 @@ abstract class CRM_Utils_Hook {
     );
   }
 
+  /**
+   * Fire `hook_civicrm_queueRun_{$runner}`.
+   *
+   * This event only fires if these conditions are met:
+   *
+   * 1. The `$queue` has been persisted in `civicrm_queue`.
+   * 2. The `$queue` has a `runner` property.
+   * 3. The `$queue` has some pending tasks.
+   * 4. The system has a queue-running agent.
+   *
+   * @param \CRM_Queue_Queue $queue
+   * @param array $items
+   *   List of claimed items which we may evaluate.
+   * @param array $outcomes
+   *   The outcomes of each task. One of 'ok', 'retry', 'fail'.
+   *   Keys should match the keys in $items.
+   */
+  public static function queueRun(CRM_Queue_Queue $queue, array $items, &$outcomes) {
+    $runner = $queue->getSpec('runner');
+    if (empty($runner) || !preg_match(';^[A-Za-z0-9_]+$;', $runner)) {
+      throw new \CRM_Core_Exception("Cannot autorun queue: " . $queue->getName());
+    }
+    return self::singleton()->invoke(['queue', 'items', 'outcomes'], $queue, $items,
+      $outcomes, $exception, self::$_nullObject, self::$_nullObject,
+      'civicrm_queueRun_' . $runner
+    );
+  }
+
+  /**
+   * This is called if automatic execution of a queue-task fails.
+   *
+   * The `$outcome` may be modified. For example, you might inspect the $item and $exception -- and then
+   * decide whether to 'retry', 'delete', or 'abort'.
+   *
+   * @param \CRM_Queue_Queue $queue
+   * @param \CRM_Queue_DAO_QueueItem|\stdClass $item
+   *   The enqueued item $item.
+   *   In principle, this is the $item format determined by the queue, which includes `id` and `data`.
+   *   In practice, it is typically an instance of `CRM_Queue_DAO_QueueItem`.
+   * @param string $outcome
+   *   The outcome of the task. Legal values:
+   *   - 'retry': The task encountered a problem, and it should be retried.
+   *   - 'delete': The task encountered a non-recoverable problem, and it should be deleted.
+   *   - 'abort': The task encountered a non-recoverable problem, and the queue should be stopped.
+   *   - 'ok': The task finished normally. (You won't generally see this, but it could be useful in some customizations.)
+   *   The default outcome for task-errors is determined by the queue settings (`civicrm_queue.error`).
+   * @param \Throwable|null $exception
+   *   If the task failed, this is the cause of the failure.
+   */
+  public static function queueTaskError(CRM_Queue_Queue $queue, $item, &$outcome, ?Throwable $exception) {
+    return self::singleton()->invoke(['job', 'params'], $queue, $item,
+      $outcome, $exception, self::$_nullObject, self::$_nullObject,
+      'civicrm_queueTaskError'
+    );
+  }
+
   /**
    * This hook is called before a scheduled job is executed
    *
index a2a22ebb4127b75d3a1185cfa466b962d372439f..04e5801edb75f7a02f084c94ef7ea096a76386ff 100644 (file)
@@ -244,7 +244,7 @@ class CRM_Utils_Recent {
       }
       elseif ($event->action === 'edit') {
         if (isset($event->object->is_deleted)) {
-          \Civi\Api4\RecentItem::update()
+          \Civi\Api4\RecentItem::update(FALSE)
             ->addWhere('entity_type', '=', $entityType)
             ->addWhere('entity_id', '=', $event->id)
             ->addValue('is_deleted', (bool) $event->object->is_deleted)
index 61ded6086528d956bc20b1c45036be37cd20d692..df27c0d9e44a9df355f090d81ddaf813043dc396 100644 (file)
@@ -114,6 +114,9 @@ class CRM_Utils_System_Drupal8 extends CRM_Utils_System_DrupalBase {
     if ($user && $user->getEmail() != $email) {
       $user->setEmail($email);
 
+      // Skip requirement for password when changing the current user fields
+      $user->_skipProtectedUserFieldConstraint = TRUE;
+
       if (!count($user->validate())) {
         $user->save();
       }
index 3c9d71f929859835f0d6b1f0d94552c240d5d84a..86e72ee847e75d959623c1e297267b3c9c55c3e7 100644 (file)
--- a/Civi.php
+++ b/Civi.php
@@ -117,13 +117,14 @@ class Civi {
    *   Specification for a queue.
    *   This is not required for accessing an existing queue.
    *   Specify this if you wish to auto-create the queue or to include advanced options (eg `reset`).
-   *   Example: ['type' => 'SqlParallel']
+   *   Example: ['type' => 'Sql', 'error' => 'abort']
+   *   Example: ['type' => 'SqlParallel', 'error' => 'delete']
    *   Defaults: ['reset'=>FALSE, 'is_persistent'=>TRUE, 'is_autorun'=>FALSE]
    * @return \CRM_Queue_Queue
    * @see \CRM_Queue_Service
    */
   public static function queue(string $name, array $params = []): CRM_Queue_Queue {
-    $defaults = ['reset' => FALSE, 'is_persistent' => TRUE];
+    $defaults = ['reset' => FALSE, 'is_persistent' => TRUE, 'status' => 'active'];
     $params = array_merge($defaults, $params, ['name' => $name]);
     return CRM_Queue_Service::singleton()->create($params);
   }
index e76220ca0dc0785a4723b000073865fbb0760e93..7be1bfb913c1e200e4a8df8f2398304c6b991238 100644 (file)
@@ -89,8 +89,11 @@ class ChainSubscriber implements EventSubscriberInterface {
       $oldResult = $result;
       $result = ['values' => [0 => $oldResult]];
     }
+
+    // Scan the params for chain calls.
     foreach ($params as $field => $newparams) {
       if ((is_array($newparams) || $newparams === 1) && $field <> 'api.has_parent' && substr($field, 0, 3) == 'api') {
+        // This param is a chain call, e.g. api.<entity>.<action>
 
         // 'api.participant.delete' => 1 is a valid options - handle 1
         // instead of an array
@@ -105,9 +108,13 @@ class ChainSubscriber implements EventSubscriberInterface {
         $subAPI = explode($separator, $field);
 
         $subaction = empty($subAPI[2]) ? $action : $subAPI[2];
-        $subParams = [
+        /** @var array of parameters that will be applied to every chained request. */
+        $enforcedSubParams = [
           'debug' => $params['debug'] ?? NULL,
         ];
+        /** @var array of parameters that provide defaults to every chained request, but which may be overridden by parameters in the chained request. */
+        $defaultSubParams = [];
+
         $subEntity = _civicrm_api_get_entity_name_from_camel($subAPI[1]);
 
         // Hard coded list of entitys that have fields starting api_ and shouldn't be automatically
@@ -131,8 +138,8 @@ class ChainSubscriber implements EventSubscriberInterface {
             //from the parent call. in this case 'contact_id' will also be
             //set to the parent's id
             if (!($subEntity == 'line_item' && $lowercase_entity == 'contribution' && $action != 'create')) {
-              $subParams["entity_id"] = $parentAPIValues['id'];
-              $subParams['entity_table'] = 'civicrm_' . $lowercase_entity;
+              $defaultSubParams["entity_id"] = $parentAPIValues['id'];
+              $defaultSubParams['entity_table'] = 'civicrm_' . $lowercase_entity;
             }
 
             $addEntityId = TRUE;
@@ -150,38 +157,39 @@ class ChainSubscriber implements EventSubscriberInterface {
               }
             }
             if ($addEntityId) {
-              $subParams[$lowercase_entity . "_id"] = $parentAPIValues['id'];
+              $defaultSubParams[$lowercase_entity . "_id"] = $parentAPIValues['id'];
             }
           }
+          // @todo remove strtolower: $subEntity is already lower case
           if ($entity != 'Contact' && \CRM_Utils_Array::value(strtolower($subEntity . "_id"), $parentAPIValues)) {
             //e.g. if event_id is in the values returned & subentity is event
             //then pass in event_id as 'id' don't do this for contact as it
             //does some weird things like returning primary email &
             //thus limiting the ability to chain email
             //TODO - this might need the camel treatment
-            $subParams['id'] = $parentAPIValues[$subEntity . "_id"];
+            $defaultSubParams['id'] = $parentAPIValues[$subEntity . "_id"];
           }
 
           if (\CRM_Utils_Array::value('entity_table', $result['values'][$idIndex]) == $subEntity) {
-            $subParams['id'] = $result['values'][$idIndex]['entity_id'];
+            $defaultSubParams['id'] = $result['values'][$idIndex]['entity_id'];
           }
           // if we are dealing with the same entity pass 'id' through
           // (useful for get + delete for example)
           if ($lowercase_entity == $subEntity) {
-            $subParams['id'] = $result['values'][$idIndex]['id'];
+            $defaultSubParams['id'] = $result['values'][$idIndex]['id'];
           }
 
-          $subParams['version'] = $version;
-          if (!empty($params['check_permissions'])) {
-            $subParams['check_permissions'] = $params['check_permissions'];
-          }
-          $subParams['sequential'] = 1;
-          $subParams['api.has_parent'] = 1;
+          $enforcedSubParams['version'] = $version;
+          // Copy check_permissions from parent.
+          $enforcedSubParams['check_permissions'] = $params['check_permissions'] ?? NULL;
+          $defaultSubParams['sequential'] = 1;
+          $enforcedSubParams['api.has_parent'] = 1;
+          // Inspect $newparams, the passed in params for the chain call.
           if (array_key_exists(0, $newparams)) {
-            $genericParams = $subParams;
-            // it is a numerically indexed array - ie. multiple creates
+            // It is a numerically indexed array - ie. multiple creates
             foreach ($newparams as $entityparams) {
-              $subParams = array_merge($genericParams, $entityparams);
+              // Defaults, overridden by request params, overridden by enforced params.
+              $subParams = array_merge($defaultSubParams, $entityparams, $enforcedSubParams);
               _civicrm_api_replace_variables($subParams, $result['values'][$idIndex], $separator);
               $result['values'][$idIndex][$field][] = $apiKernel->runSafe($subEntity, $subaction, $subParams);
               if ($result['is_error'] === 1) {
@@ -190,8 +198,8 @@ class ChainSubscriber implements EventSubscriberInterface {
             }
           }
           else {
-
-            $subParams = array_merge($subParams, $newparams);
+            // Defaults, overridden by request params, overridden by enforced params.
+            $subParams = array_merge($defaultSubParams, $newparams, $enforcedSubParams);
             _civicrm_api_replace_variables($subParams, $result['values'][$idIndex], $separator);
             $result['values'][$idIndex][$field] = $apiKernel->runSafe($subEntity, $subaction, $subParams);
             if (!empty($result['is_error'])) {
diff --git a/Civi/Api4/Action/Address/GetCoordinates.php b/Civi/Api4/Action/Address/GetCoordinates.php
new file mode 100644 (file)
index 0000000..bf9ad89
--- /dev/null
@@ -0,0 +1,48 @@
+<?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\Address;
+
+use Civi\Api4\Generic\Result;
+
+/**
+ * Converts an address string to lat/long coordinates.
+ *
+ * @method $this setAddress(string $address)
+ * @method string getAddress()
+ */
+class GetCoordinates extends \Civi\Api4\Generic\AbstractAction {
+
+  /**
+   * Address string to convert to lat/long
+   *
+   * @var string
+   * @required
+   */
+  protected $address;
+
+  public function _run(Result $result) {
+    $geocodingClassName = \CRM_Utils_GeocodeProvider::getUsableClassName();
+    $geocodingProvider = \CRM_Utils_GeocodeProvider::getConfiguredProvider();
+    if (!is_callable([$geocodingProvider, 'getCoordinates'])) {
+      throw new \API_Exception('Geocoding provider does not support getCoordinates');
+    }
+    $coord = $geocodingClassName::getCoordinates($this->address);
+    if (isset($coord['geo_code_1'], $coord['geo_code_2'])) {
+      $result[] = $coord;
+    }
+    elseif (!empty($coord['geo_code_error'])) {
+      throw new \API_Exception('Geocoding failed. ' . $coord['geo_code_error']);
+    }
+  }
+
+}
diff --git a/Civi/Api4/Action/Queue/ClaimItems.php b/Civi/Api4/Action/Queue/ClaimItems.php
new file mode 100644 (file)
index 0000000..8d05495
--- /dev/null
@@ -0,0 +1,90 @@
+<?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\Queue;
+
+use Civi\Api4\Generic\Traits\SelectParamTrait;
+
+/**
+ * Claim an item from the queue.  Returns zero or one items.
+ *
+ * @method ?string setQueue
+ * @method $this setQueue(?string $queue)
+ */
+class ClaimItems extends \Civi\Api4\Generic\AbstractAction {
+
+  use SelectParamTrait;
+
+  /**
+   * Name of the target queue.
+   *
+   * @var string|null
+   */
+  protected $queue;
+
+  public function _run(\Civi\Api4\Generic\Result $result) {
+    $this->select = empty($this->select) ? ['id', 'data', 'queue'] : $this->select;
+    $queue = $this->queue();
+    if (!$queue->isActive()) {
+      return;
+    }
+
+    $isBatch = $queue instanceof \CRM_Queue_Queue_BatchQueueInterface;
+    $limit = $queue->getSpec('batch_limit') ?: 1;
+    if ($limit > 1 && !$isBatch) {
+      throw new \API_Exception(sprintf('Queue "%s" (%s) does not support batching.', $queue->getName(), get_class($queue)));
+      // Note 1: Simply looping over `claimItem()` is unlikley to help the consumer b/c
+      // drivers like Sql+Memory are linear+blocking.
+      // Note 2: The default is batch_limit=1. So someone has specifically chosen an invalid configuration...
+    }
+    $items = $isBatch ? $queue->claimItems($limit) : [$queue->claimItem()];
+
+    foreach ($items as $item) {
+      if ($item) {
+        $result[] = $this->convertItemToStub($item);
+      }
+    }
+  }
+
+  /**
+   * @param \CRM_Queue_DAO_QueueItem|\stdClass $item
+   * @return array
+   */
+  protected function convertItemToStub(object $item): array {
+    $array = [];
+    foreach ($this->select as $field) {
+      switch ($field) {
+        case 'id':
+          $array['id'] = $item->id;
+          break;
+
+        case 'data':
+          $array['data'] = (array) $item->data;
+          break;
+
+        case 'queue':
+          $array['queue'] = $this->queue;
+          break;
+
+      }
+    }
+    return $array;
+  }
+
+  protected function queue(): \CRM_Queue_Queue {
+    if (empty($this->queue)) {
+      throw new \API_Exception('Missing required parameter: $queue');
+    }
+    return \Civi::queue($this->queue);
+  }
+
+}
diff --git a/Civi/Api4/Action/Queue/RunItems.php b/Civi/Api4/Action/Queue/RunItems.php
new file mode 100644 (file)
index 0000000..b65ccf7
--- /dev/null
@@ -0,0 +1,119 @@
+<?php
+
+namespace Civi\Api4\Action\Queue;
+
+/**
+ * Run an enqueued item (task).
+ *
+ * You must either:
+ *
+ * - (a) Give the target queue-item specifically (`setItem()`). Useful if you called `claimItem()` separately.
+ * - (b) Give the name of the queue from which to find an item (`setQueue()`).
+ *
+ * Note: If you use `setItem()`, the inputted will be validated (refetched) to ensure authenticity of all details.
+ *
+ * Returns 0 or 1 records which indicate the outcome of running the chosen task.
+ *
+ * ```php
+ * $todo = Civi\Api4\Queue::claimItem()->setQueue($item)->setLeaseTime(600)->execute()->single();
+ * $result = Civi\Api4\Queue::runItem()->setItem($todo)->execute()->single();
+ * assert(in_array($result['outcome'], ['ok', 'retry', 'fail']))
+ *
+ * $result = Civi\Api4\Queue::runItem()->setQueue('foo')->execute()->first();
+ * assert(in_array($result['outcome'], ['ok', 'retry', 'fail']))
+ * ```
+ *
+ * Valid outcomes are:
+ * - 'ok': Task executed normally. Removed from queue.
+ * - 'retry': Task encountered an error. Will try again later.
+ * - 'fail': Task encountered an error. Will not try again later. Removed from queue.
+ *
+ * @method $this setItem(?array $item)
+ * @method ?array getItem()
+ * @method ?string setQueue
+ * @method $this setQueue(?string $queue)
+ */
+class RunItems extends \Civi\Api4\Generic\AbstractAction {
+
+  /**
+   * Previously claimed item - which should now be released.
+   *
+   * @var array|null
+   *   Fields: {id: scalar, queue: string}
+   */
+  protected $items;
+
+  /**
+   * Name of the target queue.
+   *
+   * @var string|null
+   */
+  protected $queue;
+
+  public function _run(\Civi\Api4\Generic\Result $result) {
+    if (!empty($this->items)) {
+      $this->validateItemStubs();
+      $queue = \Civi::queue($this->items[0]['queue']);
+      $ids = \CRM_Utils_Array::collect('id', $this->items);
+      if (count($ids) > 1 && !($queue instanceof \CRM_Queue_Queue_BatchQueueInterface)) {
+        throw new \API_Exception("runItems: Error: Running multiple items requires BatchQueueInterface");
+      }
+      if (count($ids) > 1) {
+        $items = $queue->fetchItems($ids);
+      }
+      else {
+        $items = [$queue->fetchItem($ids[0])];
+      }
+    }
+    elseif (!empty($this->queue)) {
+      $queue = \Civi::queue($this->queue);
+      if (!$queue->isActive()) {
+        return;
+      }
+      $items = $queue instanceof \CRM_Queue_Queue_BatchQueueInterface
+        ? $queue->claimItems($queue->getSpec('batch_limit') ?: 1)
+        : [$queue->claimItem()];
+    }
+    else {
+      throw new \API_Exception("runItems: Requires either 'queue' or 'item'.");
+    }
+
+    if (empty($items)) {
+      return;
+    }
+
+    $outcomes = [];
+    \CRM_Utils_Hook::queueRun($queue, $items, $outcomes);
+    if (empty($outcomes)) {
+      throw new \API_Exception(sprintf('Failed to run queue items (name=%s, runner=%s, itemCount=%d, outcomeCount=%d)',
+        $queue->getName(), $queue->getSpec('runner'), count($items), count($outcomes)));
+    }
+    foreach ($items as $itemPos => $item) {
+      $result[] = ['outcome' => $outcomes[$itemPos], 'item' => $this->createItemStub($item)];
+    }
+  }
+
+  private function validateItemStubs(): void {
+    $queueNames = [];
+    if (!isset($this->items[0])) {
+      throw new \API_Exception("Queue items must be given as numeric array.");
+    }
+    foreach ($this->items as $item) {
+      if (empty($item['queue'])) {
+        throw new \API_Exception("Queue item requires property 'queue'.");
+      }
+      if (empty($item['id'])) {
+        throw new \API_Exception("Queue item requires property 'id'.");
+      }
+      $queueNames[$item['queue']] = 1;
+    }
+    if (count($queueNames) > 1) {
+      throw new \API_Exception("Queue items cannot be mixed. Found queues: " . implode(', ', array_keys($queueNames)));
+    }
+  }
+
+  private function createItemStub($item): array {
+    return ['id' => $item->id, 'queue' => $item->queue_name];
+  }
+
+}
index a4ec60aeb31eefeeac3fa2cd34681d97bd8f3877..c9db2a80150d569211bca93f329a8c14855c192f 100644 (file)
@@ -54,4 +54,13 @@ class Address extends Generic\DAOEntity {
       ->setCheckPermissions($checkPermissions);
   }
 
+  /**
+   * @param bool $checkPermissions
+   * @return Action\Address\GetCoordinates
+   */
+  public static function getCoordinates($checkPermissions = TRUE) {
+    return (new Action\Address\GetCoordinates(__CLASS__, __FUNCTION__))
+      ->setCheckPermissions($checkPermissions);
+  }
+
 }
index 618454281c9b843abece6c9b6faf3ecba6123c6a..29b41829f374e044f5464b2a9ec91df2ca703964 100644 (file)
@@ -16,7 +16,7 @@ namespace Civi\Api4;
  * This contains configuration settings for each type of CiviCase.
  *
  * @see \Civi\Api4\Case
- * @searchable none
+ * @searchable secondary
  * @since 5.37
  * @package Civi\Api4
  */
index afaee7fa0794a75286d49ceebd557e69445c4e58..494af4829cd44341f42e73e02ee646e6f485ac9f 100644 (file)
@@ -340,6 +340,7 @@ class BasicGetFieldsAction extends BasicGetAction {
           'Radio' => ts('Radio Buttons'),
           'Select' => ts('Select'),
           'Text' => ts('Text'),
+          'Location' => ts('Address Location'),
         ],
       ],
       [
index e3ae1acbf3524611d9c75f2815b9bd3ab2670e7e..86173f6d3c64e5f7ddb5856d1d66164ced38c2e6 100644 (file)
@@ -10,6 +10,9 @@
  */
 namespace Civi\Api4;
 
+use Civi\Api4\Action\Queue\ClaimItems;
+use Civi\Api4\Action\Queue\RunItems;
+
 /**
  * Track a list of durable/scannable queues.
  *
@@ -31,7 +34,34 @@ class Queue extends \Civi\Api4\Generic\DAOEntity {
     return [
       'meta' => ['access CiviCRM'],
       'default' => ['administer queues'],
+      'runItem' => [\CRM_Core_Permission::ALWAYS_DENY_PERMISSION],
     ];
   }
 
+  /**
+   * Claim an item from the queue. Returns zero or one items.
+   *
+   * Note: This is appropriate for persistent, auto-run queues.
+   *
+   * @param bool $checkPermissions
+   * @return \Civi\Api4\Action\Queue\ClaimItems
+   */
+  public static function claimItems($checkPermissions = TRUE) {
+    return (new ClaimItems(static::getEntityName(), __FUNCTION__))
+      ->setCheckPermissions($checkPermissions);
+  }
+
+  /**
+   * Run an item from the queue.
+   *
+   * Note: This is appropriate for persistent, auto-run queues.
+   *
+   * @param bool $checkPermissions
+   * @return \Civi\Api4\Action\Queue\RunItems
+   */
+  public static function runItems($checkPermissions = TRUE) {
+    return (new RunItems(static::getEntityName(), __FUNCTION__))
+      ->setCheckPermissions($checkPermissions);
+  }
+
 }
diff --git a/Civi/Api4/Service/Spec/Provider/AddressGetSpecProvider.php b/Civi/Api4/Service/Spec/Provider/AddressGetSpecProvider.php
new file mode 100644 (file)
index 0000000..65e891b
--- /dev/null
@@ -0,0 +1,90 @@
+<?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\Service\Spec\Provider;
+
+use Civi\Api4\Address;
+use Civi\Api4\Query\Api4SelectQuery;
+use Civi\Api4\Service\Spec\FieldSpec;
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class AddressGetSpecProvider implements Generic\SpecProviderInterface {
+
+  /**
+   * @param \Civi\Api4\Service\Spec\RequestSpec $spec
+   */
+  public function modifySpec(RequestSpec $spec) {
+    // Groups field
+    $field = new FieldSpec('proximity', 'Address', 'Boolean');
+    $field->setLabel(ts('Address Proximity'))
+      ->setTitle(ts('Address Proximity'))
+      ->setInputType('Location')
+      ->setColumnName('geo_code_1')
+      ->setDescription(ts('Address is within a given distance to a location'))
+      ->setType('Filter')
+      ->setOperators(['<='])
+      ->addSqlFilter([__CLASS__, 'getProximitySql']);
+    $spec->addFieldSpec($field);
+  }
+
+  /**
+   * @param string $entity
+   * @param string $action
+   *
+   * @return bool
+   */
+  public function applies($entity, $action) {
+    return $entity === 'Address' && $action === 'get';
+  }
+
+  /**
+   * @param array $field
+   * @param string $fieldAlias
+   * @param string $operator
+   * @param mixed $value
+   * @param \Civi\Api4\Query\Api4SelectQuery $query
+   * @param int $depth
+   * return string
+   */
+  public static function getProximitySql(array $field, string $fieldAlias, string $operator, $value, Api4SelectQuery $query, int $depth): string {
+    $unit = $value['distance_unit'] ?? 'km';
+    $distance = $value['distance'] ?? 0;
+
+    if ($unit === 'miles') {
+      $distance = $distance * 1609.344;
+    }
+    else {
+      $distance = $distance * 1000.00;
+    }
+
+    if (!isset($value['geo_code_1'], $value['geo_code_2'])) {
+      $value = Address::getCoordinates(FALSE)
+        ->setAddress($value['address'])
+        ->execute()->first();
+    }
+
+    if (
+      isset($value['geo_code_1']) && is_numeric($value['geo_code_1']) &&
+      isset($value['geo_code_2']) && is_numeric($value['geo_code_2'])
+    ) {
+      return \CRM_Contact_BAO_ProximityQuery::where(
+        $value['geo_code_1'],
+        $value['geo_code_2'],
+        $distance,
+        explode('.', $fieldAlias)[0]
+      );
+    }
+
+    return '(0)';
+  }
+
+}
index 94a4abc073a9eade95903be3c45899ac3cfc4aa0..d05acd27598071c096c193702e4210148ed506c4 100644 (file)
@@ -122,6 +122,7 @@ class SpecGatherer {
 
     $query = CustomField::get(FALSE)
       ->setSelect(['custom_group_id.name', 'custom_group_id.title', '*'])
+      ->addWhere('is_active', '=', TRUE)
       ->addWhere('custom_group_id.is_multiple', '=', '0');
 
     // Contact custom groups are extra complicated because contact_type can be a value for extends
@@ -140,7 +141,9 @@ class SpecGatherer {
         $query->addWhere('custom_group_id.extends_entity_column_value', 'IS EMPTY');
       }
       else {
-        $clause = [];
+        $clause = [
+          ['custom_group_id.extends_entity_column_value', 'IS EMPTY'],
+        ];
         foreach ((array) $values[$grouping] as $value) {
           $clause[] = ['custom_group_id.extends_entity_column_value', 'CONTAINS', $value];
         }
@@ -178,6 +181,13 @@ class SpecGatherer {
         }
       }
       if ($clauses) {
+        $clauses[] = [
+          'AND',
+          [
+            ['custom_group_id.extends_entity_column_id', 'IS EMPTY'],
+            ['custom_group_id.extends_entity_column_value', 'IS EMPTY'],
+          ],
+        ];
         $query->addClause('OR', $clauses);
       }
     }
@@ -196,6 +206,7 @@ class SpecGatherer {
   private function getCustomGroupFields($customGroup, RequestSpec $specification) {
     $customFields = CustomField::get(FALSE)
       ->addWhere('custom_group_id.name', '=', $customGroup)
+      ->addWhere('is_active', '=', TRUE)
       ->setSelect(['custom_group_id.name', 'custom_group_id.table_name', 'custom_group_id.title', '*'])
       ->execute();
 
index af8fb61c3b91b63decbb779515b4ce0fd5219e68..5a83a7192255cab1720cd5bbe8d27fb60c4ce9aa 100644 (file)
@@ -27,7 +27,7 @@ function civicrm_api3_generic_getList($apiRequest) {
   $meta = civicrm_api3_generic_getfields(['action' => 'get'] + $apiRequest, FALSE)['values'];
 
   // If the user types an integer into the search
-  $forceIdSearch = empty($request['id']) && !empty($request['input']) && !empty($meta['id']) && CRM_Utils_Rule::positiveInteger($request['input']);
+  $forceIdSearch = empty($request['id']) && !empty($request['input']) && !empty($meta['id']) && CRM_Utils_Rule::positiveInteger($request['input']) && (substr($request['input'], 0, 1) !== '0');
   // Add an extra page of results for the record with an exact id match
   if ($forceIdSearch) {
     $request['page_num'] = ($request['page_num'] ?? 1) - 1;
index a89cb0d471fd9cf6b10daf72534969813fa6d093..c7ec5be828233440b72d6018b1387e54b5456c9b 100644 (file)
@@ -554,11 +554,11 @@ function civicrm_api3_mailing_preview($params) {
   $mailingParams = ['contact_id' => $contactID];
 
   if (!$contactID) {
-    $details = CRM_Utils_Token::getAnonymousTokenDetails($mailingParams, $returnProperties, TRUE, TRUE, NULL, $mailing->getFlattenedTokens());
+    $details = CRM_Utils_Token::getAnonymousTokenDetails($mailingParams, $returnProperties, empty($mailing->sms_provider_id), TRUE, NULL, $mailing->getFlattenedTokens());
     $details = $details[0][0] ?? NULL;
   }
   else {
-    [$details] = CRM_Utils_Token::getTokenDetails($mailingParams, $returnProperties, TRUE, TRUE, NULL, $mailing->getFlattenedTokens());
+    [$details] = CRM_Utils_Token::getTokenDetails($mailingParams, $returnProperties, empty($mailing->sms_provider_id), TRUE, NULL, $mailing->getFlattenedTokens());
     $details = $details[$contactID];
   }
 
index 9939204d0333f68cc451cc97abb87e64fe8d0947..437148effaf9dd78aef62676b3665dce5a15db30 100644 (file)
 - github      : brianPHM
   name        : Brian Matemachani
 
+- github      : briennekordis
+  name        : Brienne Kordis
+  organization: Megaphone Technology Consulting
+  
 - github      : brucew2013
   name        : Bruce Wolfe
   organization: Alcohol Justice
index 01642b67da4756b9fa0f84fe18b3ffa2efc44365..61fff6ed41dc3a8ceb231aa20d5bcfc8d9abe13e 100644 (file)
@@ -230,14 +230,14 @@ function authx_civicrm_permission(&$permissions) {
  *
  * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_navigationMenu
  */
-//function authx_civicrm_navigationMenu(&$menu) {
-//  _authx_civix_insert_navigation_menu($menu, 'Mailings', array(
-//    'label' => E::ts('New subliminal message'),
-//    'name' => 'mailing_subliminal_message',
-//    'url' => 'civicrm/mailing/subliminal',
-//    'permission' => 'access CiviMail',
-//    'operator' => 'OR',
-//    'separator' => 0,
-//  ));
-//  _authx_civix_navigationMenu($menu);
-//}
+function authx_civicrm_navigationMenu(&$menu) {
+  _authx_civix_insert_navigation_menu($menu, 'Administer/System Settings', [
+    'label' => E::ts('Authentication'),
+    'name' => 'authx_admin',
+    'url' => 'civicrm/admin/setting/authx',
+    'permission' => 'administer CiviCRM',
+    'operator' => 'OR',
+    'separator' => 0,
+  ]);
+  _authx_civix_navigationMenu($menu);
+}
index 82f703aef9a1c1cc0831d87688d23831e37205d6..1d5dde1a9c8f379e0fce56f50856ddf0f559cd1e 100644 (file)
@@ -16,7 +16,7 @@
   </urls>
   <releaseDate>2021-02-11</releaseDate>
   <version>5.51.alpha1</version>
-  <develStage>alpha</develStage>
+  <develStage>stable</develStage>
   <compatibility>
     <ver>5.51</ver>
   </compatibility>
index 8e49dbdbdb27a18e9722b88e4dc69eebc4dd9f0f..daab7e0224f57af9c601de756d28bb6b04be4f0c 100644 (file)
@@ -74,15 +74,15 @@ function civigrant_civicrm_summaryActions(&$menu, $cid) {
  */
 function civigrant_civicrm_permission(&$permissions) {
   $permissions['access CiviGrant'] = [
-    E::ts('access CiviGrant'),
+    E::ts('CiviGrant:') . ' ' . E::ts('access CiviGrant'),
     E::ts('View all grants'),
   ];
   $permissions['edit grants'] = [
-    E::ts('edit grants'),
+    E::ts('CiviGrant:') . ' ' . E::ts('edit grants'),
     E::ts('Create and update grants'),
   ];
   $permissions['delete in CiviGrant'] = [
-    E::ts('delete in CiviGrant'),
+    E::ts('CiviGrant:') . ' ' . E::ts('delete in CiviGrant'),
     E::ts('Delete grants'),
   ];
 }
index e585eda77ab703a6792b6837679a4fa729d84a16..4d3810c4ff5e204595016b721578bde5422f62ae 100644 (file)
@@ -33,7 +33,7 @@ class MailingPreview {
       $mailing->copyValues($params);
     }
 
-    if (!Abdicator::isFlexmailPreferred($mailing)) {
+    if (!Abdicator::isFlexmailPreferred($mailing) && empty($mailing->sms_provider_id)) {
       require_once 'api/v3/Mailing.php';
       return civicrm_api3_mailing_preview($params);
     }
index d9c61df4a2f97a03f478c8b5fbb7f5ca4fd42a73..93559b8a1a137e2c089c12210764d9788138e039 100644 (file)
@@ -57,7 +57,7 @@ class DefaultComposer extends BaseListener {
       $this->createTokenProcessorContext($e));
 
     $tpls = $this->createMessageTemplates($e);
-    $tp->addMessage('subject', $tpls['subject'], 'text/plain');
+    $tp->addMessage('subject', $tpls['subject'] ?? '', 'text/plain');
     $tp->addMessage('body_text', isset($tpls['text']) ? $tpls['text'] : '',
       'text/plain');
     $tp->addMessage('body_html', isset($tpls['html']) ? $tpls['html'] : '',
index e572e99f5e8bfbeba7d903575252ab04ffbd82eb..d33c0731a4011cbf93fa6e3935d3a027daae82d8 100644 (file)
@@ -237,7 +237,7 @@ class CRM_Contact_Form_Search_Custom_Proximity extends CRM_Contact_Form_Search_C
   }
 
   /**
-   * @return array|null
+   * @return array
    */
   public function setDefaultValues() {
     if (!empty($this->_formValues)) {
@@ -246,22 +246,17 @@ class CRM_Contact_Form_Search_Custom_Proximity extends CRM_Contact_Form_Search_C
     $config = CRM_Core_Config::singleton();
     $countryDefault = $config->defaultContactCountry;
     $stateprovinceDefault = $config->defaultContactStateProvince;
-    $defaults = [];
+    $defaults = [
+      'prox_distance_unit' => CRM_Utils_Address::getDefaultDistanceUnit(),
+    ];
 
     if ($countryDefault) {
-      if ($countryDefault == '1228' || $countryDefault == '1226') {
-        $defaults['prox_distance_unit'] = 'miles';
-      }
-      else {
-        $defaults['prox_distance_unit'] = 'km';
-      }
       $defaults['country_id'] = $countryDefault;
       if ($stateprovinceDefault) {
         $defaults['state_province_id'] = $stateprovinceDefault;
       }
-      return $defaults;
     }
-    return NULL;
+    return $defaults;
   }
 
   /**
index 5a652474b3886bc39ee79250fd9ab90b971baa63..5eb3d4601a7743e63e432fe627ef4cec4f04dbbc 100644 (file)
@@ -47,6 +47,7 @@ class Admin {
       'defaultDisplay' => SearchDisplay::getDefault(FALSE)->setSavedSearch(['id' => NULL])->execute()->first(),
       'modules' => $extensions,
       'defaultContactType' => \CRM_Contact_BAO_ContactType::basicTypeInfo()['Individual']['name'] ?? NULL,
+      'defaultDistanceUnit' => \CRM_Utils_Address::getDefaultDistanceUnit(),
       'tags' => Tag::get()
         ->addSelect('id', 'name', 'color', 'is_selectable', 'description')
         ->addWhere('used_for', 'CONTAINS', 'civicrm_saved_search')
index cd235a73128d0853aa2e035cf29b9a559db684a0..4b7b4b05ee0cd4d2e1093454ab25d64e22bb847b 100644 (file)
         return expr.indexOf('(') > -1;
       };
 
+      this.areFunctionsAllowed = function(expr) {
+        return this.allowFunctions && ctrl.getField(expr).type !== 'Filter';
+      };
+
       this.addGroup = function(op) {
         ctrl.clauses.push([op, []]);
       };
index b5a3ef25fb478483fbf295ee7c94f1df84bf9ef5..5baaaa7bd1ca0ab0c7cbc4227390556e704d428f 100644 (file)
@@ -15,7 +15,7 @@
         </span>
       </div>
       <div ng-if="!$ctrl.conjunctions[clause[0]]" class="api4-input-group">
-        <crm-search-function ng-if="$ctrl.allowFunctions" class="form-group" expr="clause[0]" mode="clause"></crm-search-function>
+        <crm-search-function ng-if="$ctrl.areFunctionsAllowed(clause[0])" class="form-group" expr="clause[0]" mode="clause"></crm-search-function>
         <span ng-if="!$ctrl.hasFunction(clause[0])">
           <input class="form-control collapsible-optgroups" ng-model="clause[0]" crm-ui-select="{data: $ctrl.fields, allowClear: true, placeholder: 'Field'}" ng-change="$ctrl.changeClauseField(clause, index)" />
         </span>
index f1c149de3a5e6b9b77423b04e6395afc5d52392c..ed54a75a289ae8c4696685b362fe89fe44799cd5 100644 (file)
@@ -1,2 +1,2 @@
-<select class="form-control api4-operator" ng-model="$ctrl.getSetOperator" ng-model-options="{getterSetter: true}" ng-options="o.key as o.value for o in $ctrl.getOperators()" ng-change="$ctrl.changeClauseOperator()" ></select>
+<select class="form-control api4-operator" ng-model="$ctrl.getSetOperator" ng-if="$ctrl.getOperators().length > 1" ng-model-options="{getterSetter: true}" ng-options="o.key as o.value for o in $ctrl.getOperators()" ng-change="$ctrl.changeClauseOperator()" ></select>
 <crm-search-input ng-if="$ctrl.operatorTakesInput()" ng-model="$ctrl.getSetValue" ng-model-options="{getterSetter: true}" field="$ctrl.field" option-key="$ctrl.optionKey" op="$ctrl.getSetOperator()" format="$ctrl.format" class="form-group"></crm-search-input>
index a249019898ceb36389faacd84d3135988edce4ab..5cc68b77d209e8fb0124f8e28f909e17eed662f6 100644 (file)
@@ -9,7 +9,7 @@
     },
     require: {ngModel: 'ngModel'},
     template: '<div class="form-group" ng-include="$ctrl.getTemplate()"></div>',
-    controller: function($scope, formatForSelect2) {
+    controller: function($scope, formatForSelect2, crmApi4) {
       var ts = $scope.ts = CRM.ts('org.civicrm.search_kit'),
         ctrl = this;
 
         }
       };
 
+      this.lookupAddress = function() {
+        ctrl.value.geo_code_1 = null;
+        ctrl.value.geo_code_2 = null;
+        if (ctrl.value.address) {
+          crmApi4('Address', 'getCoordinates', {
+            address: ctrl.value.address
+          }).then(function(coordinates) {
+            if (coordinates[0]) {
+              ctrl.value.geo_code_1 = coordinates[0].geo_code_1;
+              ctrl.value.geo_code_2 = coordinates[0].geo_code_2;
+            }
+          });
+        }
+      };
+
       this.getTemplate = function() {
         var field = ctrl.field || {};
 
           return '~/crmSearchTasks/crmSearchInput/text.html';
         }
 
+        if (field.input_type === 'Location') {
+          ctrl.value = ctrl.value || {distance_unit: CRM.crmSearchAdmin.defaultDistanceUnit};
+          return '~/crmSearchTasks/crmSearchInput/location.html';
+        }
+
         if (isDateField(field)) {
           return '~/crmSearchTasks/crmSearchInput/date.html';
         }
diff --git a/ext/search_kit/ang/crmSearchTasks/crmSearchInput/location.html b/ext/search_kit/ang/crmSearchTasks/crmSearchInput/location.html
new file mode 100644 (file)
index 0000000..8e59598
--- /dev/null
@@ -0,0 +1,8 @@
+<div class="form-group">
+  <input class="form-control" type="number" ng-model="$ctrl.value.distance" placeholder="{{:: ts('Distance') }}" >
+  <select class="form-control" ng-model="$ctrl.value.distance_unit">
+    <option value="km">{{:: ts('Km') }}</option>
+    <option value="miles">{{:: ts('Miles') }}</option>
+  </select>
+  <input class="form-control" ng-model="$ctrl.value.address" placeholder="{{:: ts('Street, City, State, Country') }}" ng-change="$ctrl.lookupAddress()" ng-model-options="{updateOn: 'blur'}" >
+</div>
index b6e9f077bc4832e3726337079d437e27818113b0..b0c9f9112bc3b689d1550f5c5d8bc9172a16bf84 100644 (file)
@@ -20,12 +20,40 @@ Other resources for identifying changes are:
 Released June 1, 2022
 
 - **[Synopsis](release-notes/5.50.0.md#synopsis)**
+- **[Security advisories](release-notes/5.50.0.md#security)**
 - **[Features](release-notes/5.50.0.md#features)**
 - **[Bugs resolved](release-notes/5.50.0.md#bugs)**
 - **[Miscellany](release-notes/5.50.0.md#misc)**
 - **[Credits](release-notes/5.50.0.md#credits)**
 - **[Feedback](release-notes/5.50.0.md#feedback)**
 
+## CiviCRM 5.49.3
+
+Released May 25, 2022
+
+- **[Synopsis](release-notes/5.49.3.md#synopsis)**
+- **[Bugs resolved](release-notes/5.49.3.md#bugs)**
+- **[Credits](release-notes/5.49.3.md#credits)**
+- **[Feedback](release-notes/5.49.3.md#feedback)**
+
+## CiviCRM 5.49.2
+
+Released May 19, 2022
+
+- **[Synopsis](release-notes/5.49.2.md#synopsis)**
+- **[Bugs resolved](release-notes/5.49.2.md#bugs)**
+- **[Credits](release-notes/5.49.2.md#credits)**
+- **[Feedback](release-notes/5.49.2.md#feedback)**
+
+## CiviCRM 5.49.1
+
+Released May 6, 2022
+
+- **[Synopsis](release-notes/5.49.1.md#synopsis)**
+- **[Bugs resolved](release-notes/5.49.1.md#bugs)**
+- **[Credits](release-notes/5.49.1.md#credits)**
+- **[Feedback](release-notes/5.49.1.md#feedback)**
+
 ## CiviCRM 5.49.0
 
 Released May 4, 2022
@@ -37,6 +65,24 @@ Released May 4, 2022
 - **[Credits](release-notes/5.49.0.md#credits)**
 - **[Feedback](release-notes/5.49.0.md#feedback)**
 
+## CiviCRM 5.48.2
+
+Released April 20, 2022
+
+- **[Synopsis](release-notes/5.48.2.md#synopsis)**
+- **[Bugs resolved](release-notes/5.48.2.md#bugs)**
+- **[Credits](release-notes/5.48.2.md#credits)**
+- **[Feedback](release-notes/5.48.2.md#feedback)**
+
+## CiviCRM 5.48.1
+
+Released April 12, 2022
+
+- **[Synopsis](release-notes/5.48.1.md#synopsis)**
+- **[Bugs resolved](release-notes/5.48.1.md#bugs)**
+- **[Credits](release-notes/5.48.1.md#credits)**
+- **[Feedback](release-notes/5.48.1.md#feedback)**
+
 ## CiviCRM 5.48.0
 
 Released April 6, 2022
@@ -48,6 +94,45 @@ Released April 6, 2022
 - **[Credits](release-notes/5.48.0.md#credits)**
 - **[Feedback](release-notes/5.48.0.md#feedback)**
 
+## CiviCRM 5.47.4
+
+Released April 6, 2022
+
+- **[Synopsis](release-notes/5.47.4.md#synopsis)**
+- **[Security advisories](release-notes/5.47.4.md#security)**
+- **[Bugs resolved](release-notes/5.47.4.md#bugs)**
+- **[Credits](release-notes/5.47.4.md#credits)**
+- **[Feedback](release-notes/5.47.4.md#feedback)**
+
+## CiviCRM 5.47.3
+
+Released March 27, 2022
+
+- **[Synopsis](release-notes/5.47.3.md#synopsis)**
+- **[Features removed](release-notes/5.47.3.md#features)**
+- **[Bugs resolved](release-notes/5.47.3.md#bugs)**
+- **[Credits](release-notes/5.47.3.md#credits)**
+- **[Feedback](release-notes/5.47.3.md#feedback)**
+
+## CiviCRM 5.47.2
+
+Released March 16, 2022
+
+- **[Synopsis](release-notes/5.47.2.md#synopsis)**
+- **[Security advisories](release-notes/5.47.2.md#security)**
+- **[Bugs resolved](release-notes/5.47.2.md#bugs)**
+- **[Credits](release-notes/5.47.2.md#credits)**
+- **[Feedback](release-notes/5.47.2.md#feedback)**
+
+## CiviCRM 5.47.1
+
+Released March 9, 2022
+
+- **[Synopsis](release-notes/5.47.1.md#synopsis)**
+- **[Bugs resolved](release-notes/5.47.1.md#bugs)**
+- **[Credits](release-notes/5.47.1.md#credits)**
+- **[Feedback](release-notes/5.47.1.md#feedback)**
+
 ## CiviCRM 5.47.0
 
 Released March 4, 2022
@@ -59,6 +144,34 @@ Released March 4, 2022
 - **[Credits](release-notes/5.47.0.md#credits)**
 - **[Feedback](release-notes/5.47.0.md#feedback)**
 
+## CiviCRM 5.46.3
+
+Released March 16, 2022
+
+- **[Synopsis](release-notes/5.46.3.md#synopsis)**
+- **[Security advisories](release-notes/5.46.3.md#security)**
+- **[Bugs resolved](release-notes/5.46.3.md#bugs)**
+- **[Credits](release-notes/5.46.3.md#credits)**
+- **[Feedback](release-notes/5.46.3.md#feedback)**
+
+## CiviCRM 5.46.2
+
+Released February 10, 2022
+
+- **[Synopsis](release-notes/5.46.2.md#synopsis)**
+- **[Bugs resolved](release-notes/5.46.2.md#bugs)**
+- **[Credits](release-notes/5.46.2.md#credits)**
+- **[Feedback](release-notes/5.46.2.md#feedback)**
+
+## CiviCRM 5.46.1
+
+Released February 9, 2022
+
+- **[Synopsis](release-notes/5.46.1.md#synopsis)**
+- **[Bugs resolved](release-notes/5.46.1.md#bugs)**
+- **[Credits](release-notes/5.46.1.md#credits)**
+- **[Feedback](release-notes/5.46.1.md#feedback)**
+
 ## CiviCRM 5.46.0
 
 Released February 3, 2022
@@ -70,6 +183,15 @@ Released February 3, 2022
 - **[Credits](release-notes/5.46.0.md#credits)**
 - **[Feedback](release-notes/5.46.0.md#feedback)**
 
+## CiviCRM 5.45.3
+
+Released February 3, 2022
+
+- **[Synopsis](release-notes/5.45.3.md#synopsis)**
+- **[Bugs resolved](release-notes/5.45.3.md#bugs)**
+- **[Credits](release-notes/5.45.3.md#credits)**
+- **[Feedback](release-notes/5.45.3.md#feedback)**
+
 ## CiviCRM 5.45.2
 
 Released January 28, 2022
diff --git a/release-notes/5.45.3.md b/release-notes/5.45.3.md
new file mode 100644 (file)
index 0000000..ec85bdf
--- /dev/null
@@ -0,0 +1,39 @@
+# CiviCRM 5.45.3
+
+Released February 3, 2022
+
+- **[Synopsis](#synopsis)**
+- **[Bugs resolved](#bugs)**
+- **[Credits](#credits)**
+- **[Feedback](#feedback)**
+
+## <a name=synopsis></a>Synopsis
+
+| *Does this version...?*                                         |          |
+| --------------------------------------------------------------- | -------- |
+| Change the database schema?                                     | no       |
+| Alter the API?                                                  | no       |
+| Require attention to configuration options?                     | no       |
+| **Fix problems installing or upgrading to a previous version?** | **yes**  |
+| Introduce features?                                             | no       |
+| Fix bugs?                                                       | no       |
+
+## <a name=bugs></a>Bugs resolved
+
+* **_Managed Entities_: Fix crash during upgrade ([dev/core#3045](https://lab.civicrm.org/dev/core/-/issues/3045): [#22642](https://github.com/civicrm/civicrm-core/pull/22642))**
+
+    The configurations affected by this issue could not be positively identified. However, affected users reported that the patch fixed the issue.
+
+## <a name=credits></a>Credits
+
+This release was developed by the following authors and reviewers:
+
+CiviCoop - Jaap Jansma; CiviCRM - Coleman Watts, Tim Otten; JMA Consulting - Seamus Lee;
+Third Sector Design - William Mortada, Kurund Jalmi; Wikimedia Foundation - Eileen
+McNaughton
+
+## <a name=feedback></a>Feedback
+
+These release notes are edited by Tim Otten and Andie Hunt.  If you'd like to
+provide feedback on them, please login to https://chat.civicrm.org/civicrm and
+contact `@agh1`.
diff --git a/release-notes/5.46.1.md b/release-notes/5.46.1.md
new file mode 100644 (file)
index 0000000..4d07c8d
--- /dev/null
@@ -0,0 +1,37 @@
+# CiviCRM 5.46.1
+
+Released February 9, 2022
+
+- **[Synopsis](#synopsis)**
+- **[Bugs resolved](#bugs)**
+- **[Credits](#credits)**
+- **[Feedback](#feedback)**
+
+## <a name="synopsis"></a>Synopsis
+
+| *Does this version...?*                                         |          |
+| --------------------------------------------------------------- | -------- |
+| Change the database schema?                                     | no       |
+| Alter the API?                                                  | no       |
+| Require attention to configuration options?                     | no       |
+| Fix problems installing or upgrading to a previous version?     | no       |
+| Introduce features?                                             | no       |
+| **Fix bugs?**                                                   | **yes**  |
+
+## <a name="bugs"></a>Bugs resolved
+
+* **_Custom Data_: Error when displaying monetary certain values ([dev/core#3059](https://lab.civicrm.org/dev/core/-/issues/3059): [#22727](https://github.com/civicrm/civicrm-core/pull/22727))**
+* **_Status Check_: API-based staus-check fails due to incorrect permissioning ([dev/core#3055](https://lab.civicrm.org/dev/core/-/issues/3055): [#22733](https://github.com/civicrm/civicrm-core/pull/22733))**
+
+## <a name="credits"></a>Credits
+
+This release was developed by the following authors and reviewers:
+
+Wikimedia Foundation - Eileen McNaughton; Megaphone Technology Consulting - Jon Goldberg;
+Dave D; CiviCRM - Tim Otten, Coleman Watts; BrightMinded Ltd - Bradley Taylor
+
+## <a name="feedback"></a>Feedback
+
+These release notes are edited by Tim Otten and Andie Hunt.  If you'd like to
+provide feedback on them, please login to https://chat.civicrm.org/civicrm and
+contact `@agh1`.
diff --git a/release-notes/5.46.2.md b/release-notes/5.46.2.md
new file mode 100644 (file)
index 0000000..f9cf1ac
--- /dev/null
@@ -0,0 +1,43 @@
+# CiviCRM 5.46.2
+
+Released February 10, 2022
+
+- **[Synopsis](#synopsis)**
+- **[Bugs resolved](#bugs)**
+- **[Credits](#credits)**
+- **[Feedback](#feedback)**
+
+## <a name="synopsis"></a>Synopsis
+
+| *Does this version...?*                                         |          |
+| --------------------------------------------------------------- | -------- |
+| Change the database schema?                                     | no       |
+| **Alter the API?**                                              | **yes**  |
+| Require attention to configuration options?                     | no       |
+| Fix problems installing or upgrading to a previous version?     | no       |
+| Introduce features?                                             | no       |
+| **Fix bugs?**                                                   | **yes**  |
+
+## <a name="bugs"></a>Bugs resolved
+
+* **_APIv3_: `Duplicatecheck` has hard failure when `rule_type` is omitted ([dev/core#3065](https://lab.civicrm.org/dev/core/-/issues/3065): [#22741](https://github.com/civicrm/civicrm-core/pull/22741))**
+
+  This change restores compatibility with certain webform_civicrm configurations.
+
+* **_APIv3_: Relations with numerical names no longer resolved ([dev/core#3063](https://lab.civicrm.org/dev/core/-/issues/3063): [#22751](https://github.com/civicrm/civicrm-core/pull/22751))**
+
+  This change restores compatibility with certain REST API consumers.
+
+## <a name="credits"></a>Credits
+
+This release was developed by the following authors and reviewers:
+
+Wikimedia Foundation - Eileen McNaughton; Semper IT - Karin Gerritsen; Mikey O'Toole;
+Megaphone Technology Consulting - Jon Goldberg; JMA Consulting - Seamus Lee; CiviCRM -
+Coleman Watts, Tim Otten
+
+## <a name="feedback"></a>Feedback
+
+These release notes are edited by Tim Otten and Andie Hunt.  If you'd like to
+provide feedback on them, please login to https://chat.civicrm.org/civicrm and
+contact `@agh1`.
diff --git a/release-notes/5.46.3.md b/release-notes/5.46.3.md
new file mode 100644 (file)
index 0000000..a03e550
--- /dev/null
@@ -0,0 +1,42 @@
+# CiviCRM 5.46.3
+
+Released March 16, 2022
+
+- **[Synopsis](#synopsis)**
+- **[Security advisories](#security)**
+- **[Bugs resolved](#bugs)**
+- **[Credits](#credits)**
+- **[Feedback](#feedback)**
+
+## <a name="synopsis"></a>Synopsis
+
+| *Does this version...?*                                         |          |
+| --------------------------------------------------------------- | -------- |
+| Change the database schema?                                     | no       |
+| Alter the API?                                                  | no       |
+| Require attention to configuration options?                     | no       |
+| Fix problems installing or upgrading to a previous version?     | no       |
+| Introduce features?                                             | no       |
+| **Fix bugs?**                                                   | **yes**  |
+
+## <a name="security"></a>Security advisories
+
+- **[CIVI-SA-2022-01: CiviContribute, Access Bypass](https://civicrm.org/advisory/civi-sa-2022-01-civicontribute-access-bypass)**
+- **[CIVI-SA-2022-02: CiviEvent Importer, SQL Injection](https://civicrm.org/advisory/civi-sa-2022-02-civievent-importer-sql-injection)**
+- **[CIVI-SA-2022-03: Permission Advice](https://civicrm.org/advisory/civi-sa-2022-03-permission-advice)**
+- **[CIVI-SA-2022-04: jQuery UI v1.13](https://civicrm.org/advisory/civi-sa-2022-04-jquery-ui-v113)**
+- **[CIVI-SA-2022-05: CKEditor v4.18](https://civicrm.org/advisory/civi-sa-2022-05-ckeditor-v418)**
+
+## <a name="credits"></a>Credits
+
+This release was developed by the following authors and reviewers:
+
+Tadpole Collective - Kevin Cristiano; JMA Consulting - Seamus Lee; Coop
+SymbioTIC - Mathieu Lutfy; CiviCRM - Tim Otten; Bob Silvern; Artful Robot -
+Rich Lott
+
+## <a name="feedback"></a>Feedback
+
+These release notes are edited by Tim Otten and Andie Hunt.  If you'd like to
+provide feedback on them, please login to https://chat.civicrm.org/civicrm and
+contact `@agh1`.
diff --git a/release-notes/5.47.1.md b/release-notes/5.47.1.md
new file mode 100644 (file)
index 0000000..f858b56
--- /dev/null
@@ -0,0 +1,38 @@
+# CiviCRM 5.47.1
+
+Released March 9, 2022
+
+- **[Synopsis](#synopsis)**
+- **[Bugs resolved](#bugs)**
+- **[Credits](#credits)**
+- **[Feedback](#feedback)**
+
+## <a name="synopsis"></a>Synopsis
+
+| *Does this version...?*                                         |          |
+| --------------------------------------------------------------- | -------- |
+| Change the database schema?                                     | no       |
+| Alter the API?                                                  | no       |
+| Require attention to configuration options?                     | no       |
+| **Fix problems installing or upgrading to a previous version?** | **yes**  |
+| Introduce features?                                             | no       |
+| **Fix bugs?**                                                   | **yes**  |
+
+## <a name="bugs"></a>Bugs resolved
+
+* **_CiviGrant_: Fix a conflict between upgraded data and default data ([#22913](https://github.com/civicrm/civicrm-core/pull/22913))**
+* **_CiviGrant_: Fix migrated menu data ([dev/core#3100](https://lab.civicrm.org/dev/core/-/issues/3100): [#22911](https://github.com/civicrm/civicrm-core/pull/22911))**
+* **_CiviGrant_: Fix support for grant data in Drupal Views ([drupal#654](https://github.com/civicrm/civicrm-drupal/pull/654))**
+
+## <a name="credits"></a>Credits
+
+This release was developed by the following authors and reviewers:
+
+Wikimedia Foundation - Eileen McNaughton; Semper IT - Karin Gerritsen; JMA Consulting -
+Seamus Lee; Dave D; Daniel Strum; CiviCRM - Coleman Watts, Tim Otten
+
+## <a name="feedback"></a>Feedback
+
+These release notes are edited by Tim Otten and Andie Hunt.  If you'd like to
+provide feedback on them, please login to https://chat.civicrm.org/civicrm and
+contact `@agh1`.
diff --git a/release-notes/5.47.2.md b/release-notes/5.47.2.md
new file mode 100644 (file)
index 0000000..31e445a
--- /dev/null
@@ -0,0 +1,51 @@
+# CiviCRM 5.47.2
+
+Released March 16, 2022
+
+- **[Synopsis](#synopsis)**
+- **[Security advisories](#security)**
+- **[Bugs resolved](#bugs)**
+- **[Credits](#credits)**
+- **[Feedback](#feedback)**
+
+## <a name="synopsis"></a>Synopsis
+
+| *Does this version...?*                                         |          |
+| --------------------------------------------------------------- | -------- |
+| Change the database schema?                                     | no       |
+| Alter the API?                                                  | no       |
+| Require attention to configuration options?                     | no       |
+| **Fix problems installing or upgrading to a previous version?** | **yes**  |
+| Introduce features?                                             | no       |
+| **Fix bugs?**                                                   | **yes**  |
+
+## <a name="security"></a>Security advisories
+
+- **[CIVI-SA-2022-01: CiviContribute, Access Bypass](https://civicrm.org/advisory/civi-sa-2022-01-civicontribute-access-bypass)**
+- **[CIVI-SA-2022-02: CiviEvent Importer, SQL Injection](https://civicrm.org/advisory/civi-sa-2022-02-civievent-importer-sql-injection)**
+- **[CIVI-SA-2022-03: Permission Advice](https://civicrm.org/advisory/civi-sa-2022-03-permission-advice)**
+- **[CIVI-SA-2022-04: jQuery UI v1.13](https://civicrm.org/advisory/civi-sa-2022-04-jquery-ui-v113)**
+- **[CIVI-SA-2022-05: CKEditor v4.18](https://civicrm.org/advisory/civi-sa-2022-05-ckeditor-v418)**
+
+## <a name="bugs"></a>Bugs resolved
+
+* **_CiviEvent_: Skip status-check if CiviEvent is disabled ([#22898](https://github.com/civicrm/civicrm-core/pull/22898))**
+* **_CiviGrant_: Fix error when editing grant ([dev/core#3118](https://lab.civicrm.org/dev/core/-/issues/3118): [#22947](https://github.com/civicrm/civicrm-core/pull/22947))**
+* **_Search API_: Restore compatibility with `CRM_Contact_BAO_Query_Interface` ([#22933](https://github.com/civicrm/civicrm-core/pull/22933))**
+* **_Upgrader_: Add warning about CiviEvent upgrade issues ([#22958](https://github.com/civicrm/civicrm-core/pull/22958/))**
+* **_Upgrader_: Clear cache with old CiviGrant data ([dev/core#3112](https://lab.civicrm.org/dev/core/-/issues/3112): [#22932](https://github.com/civicrm/civicrm-core/pull/22932))**
+
+## <a name="credits"></a>Credits
+
+This release was developed by the following authors and reviewers:
+
+Wikimedia Foundation - Eileen McNaughton; Tadpole Collective - Kevin Cristiano; Symbiotic - Mathieu Lutfy; Semper IT -
+Karin Gerritsen; San Diego 350 - Bob Silvern; Megaphone Technology Consulting - Jon Goldberg; JMA Consulting - Seamus
+Lee; Dave D; CiviCRM - Coleman Watts, Tim Otten; Circle Interactive - Pradeep Nayak, Matt Trim; Artful Robot - Richard
+Lott; AGH Strategies - Andie Hunt
+
+## <a name="feedback"></a>Feedback
+
+These release notes are edited by Tim Otten and Andie Hunt.  If you'd like to
+provide feedback on them, please login to https://chat.civicrm.org/civicrm and
+contact `@agh1`.
diff --git a/release-notes/5.47.3.md b/release-notes/5.47.3.md
new file mode 100644 (file)
index 0000000..dbc04f8
--- /dev/null
@@ -0,0 +1,56 @@
+# CiviCRM 5.47.3
+
+Released March 27, 2022
+
+- **[Synopsis](#synopsis)**
+- **[Features removed](#features)**
+- **[Bugs resolved](#bugs)**
+- **[Credits](#credits)**
+- **[Feedback](#feedback)**
+
+## <a name="synopsis"></a>Synopsis
+
+| *Does this version...?*                                         |          |
+| --------------------------------------------------------------- | -------- |
+| **Change the database schema?**                                 | **yes**  |
+| **Alter the API?**                                              | **yes**  |
+| Require attention to configuration options?                     | no       |
+| **Fix problems installing or upgrading to a previous version?** | **yes**  |
+| Introduce features?                                             | no       |
+| **Fix bugs?**                                                   | **yes**  |
+
+## <a name="features"></a>Features removed
+
+* **_CiviEvent_: Revert timezone changes ([dev/core#2122](https://lab.civicrm.org/dev/core/-/issues/2122): [#22940](https://github.com/civicrm/civicrm-core/pull/22940), [#22930](https://github.com/civicrm/civicrm-core/pull/22930))**
+
+    v5.47.0 added timezone support to CiviEvent. Due to open issues which can affect downstream integrations and the accuracy of times, it is being removed from 5.47.3.
+
+    The schema and API for CiviEvent will now match v5.46.
+
+    If you use CiviEvent and ran v5.47.0-v5.47.2, please read the [CiviEvent v5.47 Timezone Notice](https://civicrm.org/redirect/event-timezone-5.47).
+
+## <a name="bugs"></a>Bugs resolved
+
+* **_Afform_: Admin screen does show "Submit Actions" for custom forms ([dev/core#2522](https://lab.civicrm.org/dev/core/-/issues/2522): [#23024](https://github.com/civicrm/civicrm-core/pull/23024))**
+* **_CiviMember_: "New Membership" fails when "Price Set" is present but not selected ([dev/core#3134](https://lab.civicrm.org/dev/core/-/issues/3134): [#23027](https://github.com/civicrm/civicrm-core/pull/23027))**
+* **_CiviReport_: Title and statistics appear twice (in print/PDF view) ([dev/core#3126](https://lab.civicrm.org/dev/core/-/issues/3126): [#22976](https://github.com/civicrm/civicrm-core/pull/22976))**
+* **_Search Kit_: Fix multi-valued filters in custom forms ([#23012](https://github.com/civicrm/civicrm-core/pull/23012))**
+* **_Upgrader_: Post-upgrade message no longer displayed ([dev/core#3119](https://lab.civicrm.org/dev/core/-/issues/3119): [#22985](https://github.com/civicrm/civicrm-core/pull/22985))**
+* **_WordPress_: Function `is_favicon()` doesn't exist on WordPress <v5.4 ([wordpress#275](https://github.com/civicrm/civicrm-wordpress/pull/275))**
+
+## <a name="credits"></a>Credits
+
+This release was developed by the following authors and reviewers:
+
+Wikimedia Foundation - Eileen McNaughton; Third Sector Design - William Mortada; Tadpole
+Collective - Kevin Cristiano; Squiffle Consulting - Aidan Saunders; Semper IT - Karin
+Gerritsen; schoel-bis; JMA Consulting - Seamus Lee; guitarman; Ginkgo Street Labs -
+Michael Z Daryabeygi; Fuzion - Luke Stewart, Peter Davis; Dave D; CiviCRM - Tim Otten,
+Coleman Watts; Christian Wach; chris_bluejac; barijohn; Artful Robot - Rich Lott;
+Agileware - Francis Whittle, Justin Freeman; AGH Strategies - Andie Hunt
+
+## <a name="feedback"></a>Feedback
+
+These release notes are edited by Tim Otten and Andie Hunt.  If you'd like to
+provide feedback on them, please login to https://chat.civicrm.org/civicrm and
+contact `@agh1`.
diff --git a/release-notes/5.47.4.md b/release-notes/5.47.4.md
new file mode 100644 (file)
index 0000000..695d416
--- /dev/null
@@ -0,0 +1,39 @@
+# CiviCRM 5.47.4
+
+Released April 6, 2022
+
+- **[Synopsis](#synopsis)**
+- **[Bugs resolved](#bugs)**
+- **[Credits](#credits)**
+- **[Feedback](#feedback)**
+
+## <a name="synopsis"></a>Synopsis
+
+| *Does this version...?*                                         |          |
+| --------------------------------------------------------------- | -------- |
+| Change the database schema?                                     | no       |
+| Alter the API?                                                  | no       |
+| Require attention to configuration options?                     | no       |
+| **Fix problems installing or upgrading to a previous version?** | **yes**  |
+| Introduce features?                                             | no       |
+| **Fix bugs?**                                                   | **yes**  |
+
+## <a name="security"></a>Security advisories
+
+- **[CIVI-SA-2022-06: Dompdf 1.12.1](https://civicrm.org/advisory/civi-sa-2022-06-dompdf-121)**
+
+## <a name="bugs"></a>Bugs resolved
+
+* **_Upgrader_: Fix upgrade error on multilingual sites ([dev/core#3151](https://lab.civicrm.org/dev/core/-/issues/3151): [#23063](https://github.com/civicrm/civicrm-core/pull/23063))**
+
+## <a name="credits"></a>Credits
+
+This release was developed by the following authors and reviewers:
+
+Wikimedia Foundation - Eileen McNaughton; Stephen Palmstrom; Joseph Lacey; JMA Consulting - Seamus Lee; Fuzion - Luke Stewart; Dave D; CiviCRM - Tim Otten; Artful Robot - Rich Lott
+
+## <a name="feedback"></a>Feedback
+
+These release notes are edited by Tim Otten and Andie Hunt.  If you'd like to
+provide feedback on them, please login to https://chat.civicrm.org/civicrm and
+contact `@agh1`.
diff --git a/release-notes/5.48.1.md b/release-notes/5.48.1.md
new file mode 100644 (file)
index 0000000..9bd0c75
--- /dev/null
@@ -0,0 +1,46 @@
+# CiviCRM 5.48.1
+
+Released April 12, 2022
+
+- **[Synopsis](#synopsis)**
+- **[Bugs resolved](#bugs)**
+- **[Credits](#credits)**
+- **[Feedback](#feedback)**
+
+## <a name="synopsis"></a>Synopsis
+
+| *Does this version...?*                                         |          |
+| --------------------------------------------------------------- | -------- |
+| Change the database schema?                                     | no       |
+| Alter the API?                                                  | no       |
+| **Require attention to configuration options?**                 | **yes**  |
+| **Fix problems installing or upgrading to a previous version?** | **yes**  |
+| Introduce features?                                             | no       |
+| **Fix bugs?**                                                   | **yes**  |
+
+## <a name="bugs"></a>Bugs resolved
+
+* **_CiviGrant_: Custom statuses renamed during migration ([dev/core#3161](https://lab.civicrm.org/dev/core/-/issues/3161): [#23130](https://github.com/civicrm/civicrm-core/pull/23130), [#23140](https://github.com/civicrm/civicrm-core/pull/23140))**
+
+  CiviGrant supports configurable statuses ("Administer => CiviGrant => Grant Status").  If one of the _default statuses_ was
+  _modified_, then the status could have reverted to its default name.
+
+  This fix prevents similar problems in new upgrades.  However, if you previously used an affected version (5.47.0-5.48.0),
+  then please review the list in "Administer => CiviGrant => Grant Status".
+
+* **_CiviMail_: Fix validation error on sites that do not use Flexmailer ([#23141](https://github.com/civicrm/civicrm-core/pull/23141))**
+* **_Upgrader_: Fix "No such table" error for web-user who navigates toward upgrade screen ([dev/core#3166](https://lab.civicrm.org/dev/core/-/issues/3166): [#23148](https://github.com/civicrm/civicrm-core/pull/23148))**
+
+## <a name="credits"></a>Credits
+
+This release was developed by the following authors and reviewers:
+
+Wikimedia Foundation - Eileen McNaughton; Tadpole Collective - Kevin Cristiano; Stephen
+Palmstrom; Lighthouse Consulting and Design - Brian Shaughnessy; JMA Consulting - Seamus
+Lee; Dave D; CiviCRM - Coleman Watts, Tim Otten
+
+## <a name="feedback"></a>Feedback
+
+These release notes are edited by Tim Otten and Andie Hunt.  If you'd like to
+provide feedback on them, please login to https://chat.civicrm.org/civicrm and
+contact `@agh1`.
diff --git a/release-notes/5.48.2.md b/release-notes/5.48.2.md
new file mode 100644 (file)
index 0000000..5913966
--- /dev/null
@@ -0,0 +1,42 @@
+# CiviCRM 5.48.2
+
+Released April 20, 2022
+
+- **[Synopsis](#synopsis)**
+- **[Bugs resolved](#bugs)**
+- **[Credits](#credits)**
+- **[Feedback](#feedback)**
+
+## <a name="synopsis"></a>Synopsis
+
+| *Does this version...?*                                         |          |
+| --------------------------------------------------------------- | -------- |
+| Change the database schema?                                     | no       |
+| Alter the API?                                                  | no       |
+| Require attention to configuration options?                     | no       |
+| Fix problems installing or upgrading to a previous version?     | no       |
+| Introduce features?                                             | no       |
+| **Fix bugs?**                                                   | **yes**  |
+
+## <a name="bugs"></a>Bugs resolved
+
+* **_CiviContribute_: Fix SQL error when interpreting ACL ([#23235](https://github.com/civicrm/civicrm-core/pull/23235))**
+* **_CiviContribute_: Fix buttons on bottom of "View Contribution" dialog ([#23202](https://github.com/civicrm/civicrm-core/pull/23202))**
+* **_CiviContribute_: Fix "Download Invoice" button ([dev/core#3168](https://lab.civicrm.org/dev/core/-/issues/3168): [#23255](https://github.com/civicrm/civicrm-core/pull/23255))**
+* **_CiviMember_: Fix malformed query when user has no access to any financial ACLs ([#23228](https://github.com/civicrm/civicrm-core/pull/23228))**
+* **_Relationships_: Restore support for "Employer" relationships with "Individual" employers ([dev/core#3182](https://lab.civicrm.org/dev/core/-/issues/3182): [#23226](https://github.com/civicrm/civicrm-core/pull/23226))**
+* **_Search Kit_: Prevent error when sorting on a non-aggregated column ([#23247](https://github.com/civicrm/civicrm-core/pull/23247))**
+
+## <a name="credits"></a>Credits
+
+This release was developed by the following authors and reviewers:
+
+Wikimedia Foundation - Eileen McNaughton; Phil McKerracher; Megaphone Technology
+Consulting - Jon Goldberg; JMA Consulting - Seamus Lee; CiviDesk - Yashodha Chaku; Dave D;
+CiviCRM - Coleman Watts, Tim Otten
+
+## <a name="feedback"></a>Feedback
+
+These release notes are edited by Tim Otten and Andie Hunt.  If you'd like to
+provide feedback on them, please login to https://chat.civicrm.org/civicrm and
+contact `@agh1`.
diff --git a/release-notes/5.49.1.md b/release-notes/5.49.1.md
new file mode 100644 (file)
index 0000000..3b3d866
--- /dev/null
@@ -0,0 +1,38 @@
+# CiviCRM 5.49.1
+
+Released May 6, 2022
+
+- **[Synopsis](#synopsis)**
+- **[Bugs resolved](#bugs)**
+- **[Credits](#credits)**
+- **[Feedback](#feedback)**
+
+## <a name="synopsis"></a>Synopsis
+
+| *Does this version...?*                                         |          |
+| --------------------------------------------------------------- | -------- |
+| Change the database schema?                                     | no       |
+| Alter the API?                                                  | no       |
+| Require attention to configuration options?                     | no       |
+| Fix problems installing or upgrading to a previous version?     | no       |
+| Introduce features?                                             | no       |
+| **Fix bugs?**                                                   | **yes**  |
+
+## <a name="bugs"></a>Bugs resolved
+
+* **_APIv4_: Fix error in certain calls to "Contact.getFields" ([#23389](https://github.com/civicrm/civicrm-core/pull/23389))**
+* **_CA Certs_: Fix error "Certificate Authority file is too old" when loading certain dashlets/feeds ([#23387](https://github.com/civicrm/civicrm-core/pull/23387))**
+
+  The error primarily appears on Windows-based PHP deployments.
+
+## <a name="credits"></a>Credits
+
+This release was developed by the following authors and reviewers:
+
+JMA Consulting - Seamus Lee; Dave D; CiviCRM - Coleman Watts, Tim Otten
+
+## <a name="feedback"></a>Feedback
+
+These release notes are edited by Tim Otten and Andie Hunt.  If you'd like to
+provide feedback on them, please login to https://chat.civicrm.org/civicrm and
+contact `@agh1`.
diff --git a/release-notes/5.49.2.md b/release-notes/5.49.2.md
new file mode 100644 (file)
index 0000000..02605af
--- /dev/null
@@ -0,0 +1,40 @@
+# CiviCRM 5.49.2
+
+Released May 19, 2022
+
+- **[Synopsis](#synopsis)**
+- **[Bugs resolved](#bugs)**
+- **[Credits](#credits)**
+- **[Feedback](#feedback)**
+
+## <a name="synopsis"></a>Synopsis
+
+| *Does this version...?*                                         |          |
+| --------------------------------------------------------------- | -------- |
+| Change the database schema?                                     | no       |
+| Alter the API?                                                  | no       |
+| **Require attention to configuration options?**                 | **yes**  |
+| **Fix problems installing or upgrading to a previous version?** | **yes**  |
+| Introduce features?                                             | no       |
+| **Fix bugs?**                                                   | **yes**  |
+
+## <a name="bugs"></a>Bugs resolved
+
+* **_Scheduled Reminders_: Fix storage of "Limit To" option. ([dev/core#3464](https://lab.civicrm.org/dev/core/-/issues/3464), [dev/core#3465](https://lab.civicrm.org/dev/core/-/issues/3465): [#23497](https://github.com/civicrm/civicrm-core/pull/23497))**
+
+  On sites which used 5.49.0 or 5.49.1, scheduled reminders could store incorrect values of the "Limit To" option.  This
+  can lead to excessive notifications. The upgrader should significantly reduce this risk, but it may advise you to
+  review the configuration. ([Learn more](https://civicrm.org/redirect/reminders-5.49))
+
+## <a name="credits"></a>Credits
+
+This release was developed by the following authors and reviewers:
+
+Wikimedia Foundation - Eileen McNaughton; Megaphone Technology Consulting - Jon Goldberg;
+JMA Consulting - Monish Deb; CiviCRM - Tim Otten; Agileware - Justin Freeman
+
+## <a name="feedback"></a>Feedback
+
+These release notes are edited by Tim Otten and Andie Hunt.  If you'd like to
+provide feedback on them, please login to https://chat.civicrm.org/civicrm and
+contact `@agh1`.
diff --git a/release-notes/5.49.3.md b/release-notes/5.49.3.md
new file mode 100644 (file)
index 0000000..824d2e9
--- /dev/null
@@ -0,0 +1,45 @@
+# CiviCRM 5.49.3
+
+Released May 25, 2022
+
+- **[Synopsis](#synopsis)**
+- **[Bugs resolved](#bugs)**
+- **[Credits](#credits)**
+- **[Feedback](#feedback)**
+
+## <a name="synopsis"></a>Synopsis
+
+| *Does this version...?*                                         |          |
+| --------------------------------------------------------------- | -------- |
+| Change the database schema?                                     | no       |
+| Alter the API?                                                  | no       |
+| Require attention to configuration options?                     | no       |
+| Fix problems installing or upgrading to a previous version?     | no       |
+| Introduce features?                                             | no       |
+| **Fix bugs?**                                                   | **yes**  |
+
+## <a name="bugs"></a>Bugs resolved
+
+* **_CiviContribute_: Fix calculation of unit-price for recurring contributions ([#23566](https://github.com/civicrm/civicrm-core/pull/23566))**
+
+  The inaccurate unit-price did not affect payment processing, but it would produce inaccurate records.  The effect would be apparent when synchronizing records to another accounting system (eg Xero).
+
+* **_CiviContribute_: Fix error displaying empty values of "{contribution.tax_amount}" ([#23528](https://github.com/civicrm/civicrm-core/pull/23528))**
+* **_Custom Fields_: Fix Javascript error when using certain translation data ([dev/core#3436](https://lab.civicrm.org/dev/core/-/issues/3436): [#23499](https://github.com/civicrm/civicrm-core/pull/23499))**
+* **_Guzzle_: Update to v6.5.6 ([#23584](https://github.com/civicrm/civicrm-core/pull/23584))**
+
+  This applies a prophylactic security update. It is not believed to impact the security of CiviCRM deployments.
+
+## <a name="credits"></a>Credits
+
+This release was developed by the following authors and reviewers:
+
+Wikimedia Foundation - Eileen McNaughton; Oxfam Germany - Thomas Schüttler; MJW Consulting - Matthew Wire;
+Klangsoft - David Reedy Jr; jmargraf; JMA Consulting - Seamus Lee; Fuzion - Peter Davis; CiviDesk - Yashodha
+Chaku; CiviCRM - Tim Otten; CiviCoop - Jaap Jansma
+
+## <a name="feedback"></a>Feedback
+
+These release notes are edited by Tim Otten and Andie Hunt.  If you'd like to
+provide feedback on them, please login to https://chat.civicrm.org/civicrm and
+contact `@agh1`.
index a734de61f37ea1e259c8f71c6846fd0bb01efe9e..1300f62ad2f2bf791ba76bf4d4197f81d1116388 100644 (file)
@@ -3,6 +3,7 @@
 Released June 1, 2022
 
 - **[Synopsis](#synopsis)**
+- **[Security advisories](#security)**
 - **[Features](#features)**
 - **[Bugs resolved](#bugs)**
 - **[Miscellany](#misc)**
@@ -13,7 +14,7 @@ Released June 1, 2022
 
 | *Does this version...?*                                         |         |
 |:--------------------------------------------------------------- |:-------:|
-| Fix security vulnerabilities?                                   |   no    |
+| Fix security vulnerabilities?                                   | **yes** |
 | **Change the database schema?**                                 | **yes** |
 | **Alter the API?**                                              | **yes** |
 | Require attention to configuration options?                     |   no    |
@@ -21,6 +22,10 @@ Released June 1, 2022
 | **Introduce features?**                                         | **yes** |
 | **Fix bugs?**                                                   | **yes** |
 
+## <a name="security"></a>Security advisories
+
+- **[CIVI-SA-2022-07: APIv3 Access Bypass](https://civicrm.org/advisory/civi-sa-2022-07-apiv3-access-bypass)**
+
 ## <a name="features"></a>Features
 
 ### Core CiviCRM
@@ -28,20 +33,22 @@ Released June 1, 2022
 - **System Check - Add a reminder about CIVICRM_SIGN_KEYS.
   ([23224](https://github.com/civicrm/civicrm-core/pull/23224))**
 
-  Adds a system status check regarding CIVICRM_SIGN_KEYS.
+  Adds a system status check that generates a reminder about cryptographic
+  signing keys.
 
 - **Restrict allowed uploads - contact image
   ([23147](https://github.com/civicrm/civicrm-core/pull/23147))**
 
   Restrict file types allowed for the contact image field.
-  
+
 - **Add tracking table for import jobs
   ([dev/core#1307](https://lab.civicrm.org/dev/core/-/issues/1307):
   [23199](https://github.com/civicrm/civicrm-core/pull/23199) and
   [23245](https://github.com/civicrm/civicrm-core/pull/23245))**
 
-  Adds a table for the purpose of tracking user jobs (imports) and associated
-  temp tables and starts tracking the submittedValues and data source with it.
+  This adds a new table for the purpose of tracking user jobs (e.g. imports) and
+  associated temp tables and starts tracking the submittedValues and data source
+  with it.
 
 - **CustomFields - Improve metadata about which custom groups belong to which
   entities ([23336](https://github.com/civicrm/civicrm-core/pull/23336))**
@@ -49,6 +56,22 @@ Released June 1, 2022
   Makes the relationship between Custom Field Groups, entity types and subtypes
   discoverable via APIv4 metadata.
 
+- **Upgrader - Add support for automatic snapshots
+  ([23522](https://github.com/civicrm/civicrm-core/pull/23522) and
+  [23544](https://github.com/civicrm/civicrm-core/pull/23594))**
+
+  This adds a utility for recording a snapshot of certain columns in a database
+  table prior to applying any upgrade steps to it.  This will make it easier to
+  roll back or compare changes if necessary after the upgrade.
+
+  The snapshot tables begin with the prefix `snap_civicrm_` and will be cleaned
+  up after a certain number of minor version upgrades.  For now, the feature is
+  disabled by default, but you may enable it by adding
+
+      define('CIVICRM_UPGRADE_SNAPSHOT', TRUE);
+
+  to the settings file.
+
 - **Api4 - minor fixes and updates
   ([23310](https://github.com/civicrm/civicrm-core/pull/23310))**
 
@@ -141,7 +164,9 @@ Released June 1, 2022
   ([dev/core#3249](https://lab.civicrm.org/dev/core/-/issues/3249):
   [23313](https://github.com/civicrm/civicrm-core/pull/23313))**
 
-  Makes casetype a managed entity.
+  This makes `CaseType` in APIv4 a managed entity.  This is part of a move
+  towards having all cases defined in configuration and deprecating XML-defined
+  case types.
 
 ### CiviContribute
 
@@ -180,6 +205,13 @@ Released June 1, 2022
   ([dev/core#3164](https://lab.civicrm.org/dev/core/-/issues/3164):
   [23191](https://github.com/civicrm/civicrm-core/pull/23191))**
 
+- **Fix 'Authorization Failed' regression when submitting eg. webform via
+  checksum ([23607](https://github.com/civicrm/civicrm-core/pull/23607))**
+
+  This resolves a bug where accessing an entity through APIv3, coming in via a
+  checksum link, results in a failed authorization for the step of updating the
+  recent items stack via APIv4.
+
 - **Manage Extensions - Hide nag for core exts
   ([dev/core#3171](https://lab.civicrm.org/dev/core/-/issues/3171):
   [23204](https://github.com/civicrm/civicrm-core/pull/23204))**
@@ -210,6 +242,12 @@ Released June 1, 2022
 - **SearchKit - Move grid css to its own file
   ([23315](https://github.com/civicrm/civicrm-core/pull/23315))**
 
+- **SearchKit - Fix 'undefined var' error after import
+  ([23572](https://github.com/civicrm/civicrm-core/pull/23572))**
+
+  Fixes an unresponsive screen after importing multiple records into SearchKit
+  (using the Import dialog).
+
 - **add missing Parishes of Bermuda (civicrm_state_province)
   ([23339](https://github.com/civicrm/civicrm-core/pull/23339))**
 
@@ -327,6 +365,9 @@ Released June 1, 2022
 - **Apply nodefaults to contact tabs for escape-on-output
   ([23232](https://github.com/civicrm/civicrm-core/pull/23232))**
 
+- **MultipleRecordFieldsListing.tpl - JS strings should us JS escaping
+  ([23499](https://github.com/civicrm/civicrm-core/pull/23499))**
+
 ### CiviCampaign
 
 - **update-supporter-url
@@ -374,6 +415,18 @@ Released June 1, 2022
 
   Definitively load main files during bootstrap.
 
+- **Fix empty money handling
+  ([23528](https://github.com/civicrm/civicrm-core/pull/23528))**
+
+  Tokens representing money fields will now default to 0 for empty values.
+
+- **Calculate unit_price based on qty
+  ([23566](https://github.com/civicrm/civicrm-core/pull/23566))**
+
+  This resolves a bug when a template contribution was created for a recurring
+  contribution: the unit_price on the line item was set to match the line_total,
+  ignoring qty.
+
 ### CiviEvent
 
 - **batch geocode API does not process event addresses
@@ -668,7 +721,8 @@ Released June 1, 2022
   ([23169](https://github.com/civicrm/civicrm-core/pull/23169))**
 
 - **(NFC) mixin/**.php - Add @since tags
-  ([23423](https://github.com/civicrm/civicrm-core/pull/23423))**
+  ([23423](https://github.com/civicrm/civicrm-core/pull/23423) and
+  [23440](https://github.com/civicrm/civicrm-core/pull/23440))**
 
 - **(NFC) Skip CliRunnerTest on php80+drush+Backdrop
   ([23184](https://github.com/civicrm/civicrm-core/pull/23184))**
@@ -710,7 +764,7 @@ Andreas Howiller; Andy Burns; Artful Robot - Rich Lott; Australian Greens - John
 Twyman; Betty Dolfing; Christian Wach; Circle Interactive - Dave Jenkins, Matt
 Trim; CiviCoop - Jaap Jansma; iXiam - Vangelis Pantazis; JMA Consulting - Edsel
 Lopez; John Kingsnorth; Joinery - Allen Shaw; Nicol Wistreich; Tadpole
-Collective - Kevin Cristiano;
+Collective - Kevin Cristiano
 
 ## <a name="feedback"></a>Feedback
 
index 5dee60b77733e4f9e5352a4ddd73e0dbd1e51876..cf6af9b7acd487249a159746e5e277250ceb4fb5 100644 (file)
@@ -52,6 +52,9 @@ return [
     'pseudoconstant' => [
       'callback' => 'CRM_Core_SelectValues::geoProvider',
     ],
+    'on_change' => [
+      'CRM_Utils_GeocodeProvider::reset',
+    ],
     'default' => NULL,
     'title' => ts('Geocoding Provider'),
     'description' => ts('This can be the same or different from the mapping provider selected.'),
index 1287c6d6a501cbd6032e35eea71999d8fa0c79e2..5395b37fcf7f1a3df698cf51a1dbd040a3b0cd9b 100644 (file)
@@ -7,47 +7,4 @@
  | and copyright information, see https://civicrm.org/licensing       |
  +--------------------------------------------------------------------+
 *}
-{* Import Wizard - Step 1 (choose data source) *}
-<div class="crm-block crm-form-block crm-import-datasource-form-block">
-
-  {* WizardHeader.tpl provides visual display of steps thru the wizard as well as title for current step *}
-  {include file="CRM/common/WizardHeader.tpl"}
-  <div class="help">
-    {ts 1=$importEntity 2= $importEntities}The %1 Import Wizard allows you to easily upload %2 from other applications into CiviCRM.{/ts}
-    {ts}Files to be imported must be in the 'comma-separated-values' format (CSV) and must contain data needed to match an existing contact in your CiviCRM database.{/ts} {help id='upload'}
-  </div>
-
- <div class="crm-submit-buttons">{include file="CRM/common/formButtons.tpl" location="top"}</div>
- <div id="upload-file">
- <h3>{ts}Upload Data File{/ts}</h3>
-      <table class="form-layout-compressed">
-        <tr class="crm-import-uploadfile-from-block-uploadFile">
-           <td class="label">{$form.uploadFile.label}</td>
-           <td>{$form.uploadFile.html}<br />
-                <span class="description">{ts}File format must be comma-separated-values (CSV).{/ts}</span><br /><span>{ts 1=$uploadSize}Maximum Upload File Size: %1 MB{/ts}</span>
-           </td>
-        </tr>
-        <tr class="crm-import-uploadfile-form-block-skipColumnHeader">
-           <td class="label"></td>
-           <td>{$form.skipColumnHeader.html}{$form.skipColumnHeader.label}<br />
-               <span class="description">{ts}Check this box if the first row of your file consists of field names (Example: 'Contact ID', 'Activity Type', 'Activity Date').{/ts}</span>
-           </td>
-        </tr>
-        <tr class="crm-import-datasource-form-block-fieldSeparator">
-          <td class="label">{$form.fieldSeparator.label} {help id='id-fieldSeparator' file='CRM/Contact/Import/Form/DataSource'}</td>
-          <td>{$form.fieldSeparator.html}</td>
-        </tr>
-        <tr>{include file="CRM/Core/Date.tpl"}</tr>
-        {if $savedMapping}
-        <tr class="crm-import-uploadfile-form-block-savedMapping">
-        <td>{$form.savedMapping.label}</td>
-           <td>{$form.savedMapping.html}<br />
-              <span class="description">{ts}Select Saved Mapping or Leave blank to create a new One.{/ts}</span>
-{/if}
-           </td>
-        </tr>
- </table>
- <div class="spacer"></div>
- </div>
- <div class="crm-submit-buttons">{include file="CRM/common/formButtons.tpl" location="bottom"}</div>
-</div>
+{include file="CRM/Import/Form/DataSource.tpl"}
index 92fb90d4f745bb42ce1238f0227563654a8a58ca..ffa45aeec995a35b80505f51f45bbb57ea1adae0 100644 (file)
@@ -17,7 +17,7 @@
            <a href="{$ev.url}">{$ev.title}</a><br />
            {$ev.start_date|truncate:10:""|crmDate}<br />
            {assign var=evSummary value=$ev.summary|truncate:80:""}
-           <em>{$evSummary}{if $ev.summary|count_characters:true GT 80}  (<a href="{$ev.url}">{ts}more{/ts}...</a>){/if}</em>
+           <em>{$evSummary}{if $ev.summary|crmCountCharacters:true GT 80}  (<a href="{$ev.url}">{ts}more{/ts}...</a>){/if}</em>
            </p>
          {/foreach}
      {else}
index f69e2a764c5391dbf89b4de3bda12305cef1e455..102964316bee2b6519136797a241d976606df849 100644 (file)
   </div>
   <div class="crm-submit-buttons">{include file="CRM/common/formButtons.tpl" location="top"}</div>
   <div id="choose-data-source" class="form-item">
-      <h3>{ts}Choose Data Source{/ts}</h3>
-      <table class="form-layout">
-        <tr class="crm-import-datasource-form-block-dataSource">
-            <td class="label">{$form.dataSource.label}</td>
-            <td>{$form.dataSource.html} {help id='data-source-selection'}</td>
-        </tr>
-      </table>
+    <h3>{ts}Choose Data Source{/ts}</h3>
+    <table class="form-layout">
+      <tr class="crm-import-datasource-form-block-dataSource">
+        <td class="label">{$form.dataSource.label}</td>
+        <td>{$form.dataSource.html} {help id='data-source-selection'}</td>
+      </tr>
+    </table>
   </div>
 
   {* Data source form pane is injected here when the data source is selected. *}
   </div>
 
   <div id="common-form-controls" class="form-item">
-      <h3>{ts}Import Options{/ts}</h3>
-      <table class="form-layout-compressed">
-         <tr class="crm-import-datasource-form-block-contactType">
-       <td class="label">{$form.contactType.label}</td>
-             <td>{$form.contactType.html} {help id='contact-type'}&nbsp;&nbsp;&nbsp;
-               <span id="contact-subtype">{$form.contactSubType.label}&nbsp;&nbsp;&nbsp;{$form.contactSubType.html} {help id='contact-sub-type'}</span></td>
-         </tr>
-         <tr class="crm-import-datasource-form-block-onDuplicate">
-             <td class="label">{$form.onDuplicate.label}</td>
-             <td>{$form.onDuplicate.html} {help id='dupes'}</td>
-         </tr>
-         <tr class="crm-import-datasource-form-block-dedupe">
-             <td class="label">{$form.dedupe_rule_id.label}</td>
-             <td><span id="contact-dedupe_rule_id">{$form.dedupe_rule_id.html}</span> {help id='id-dedupe_rule'}</td>
-         </tr>
-         <tr class="crm-import-datasource-form-block-fieldSeparator">
-             <td class="label">{$form.fieldSeparator.label}</td>
-             <td>{$form.fieldSeparator.html} {help id='id-fieldSeparator'}</td>
-         </tr>
-         <tr>{include file="CRM/Core/Date.tpl"}</tr>
-         <tr>
-             <td></td><td class="description">{ts}Select the format that is used for date fields in your import data.{/ts}</td>
-         </tr>
-
-        {if $geoCode}
-         <tr class="crm-import-datasource-form-block-doGeocodeAddress">
-             <td class="label"></td>
-             <td>{$form.doGeocodeAddress.html} {$form.doGeocodeAddress.label}<br />
-               <span class="description">
-                {ts}This option is not recommended for large imports. Use the command-line geocoding script instead.{/ts}
-               </span>
-               {docURL page="user/initial-set-up/scheduled-jobs"}
-            </td>
-         </tr>
-        {/if}
-
-        {if $savedMapping}
-         <tr  class="crm-import-datasource-form-block-savedMapping">
-              <td class="label"><label for="savedMapping">{$form.savedMapping.label}</label></td>
-              <td>{$form.savedMapping.html}<br />
-      &nbsp;&nbsp;&nbsp;<span class="description">{ts}Select Saved Mapping or Leave blank to create a new One.{/ts}</span></td>
-         </tr>
-        { /if}
-
-        {if $form.disableUSPS}
-         <tr  class="crm-import-datasource-form-block-disableUSPS">
-              <td class="label"></td>
-              <td>{$form.disableUSPS.html} <label for="disableUSPS">{$form.disableUSPS.label}</label></td>
-         </tr>
+    <h3>{ts}Import Options{/ts}</h3>
+    <table class="form-layout-compressed">
+      <tr class="crm-import-datasource-form-block-contactType">
+        <td class="label">{$form.contactType.label}</td>
+        <td>{$form.contactType.html} {help id='contact-type'}&nbsp;&nbsp;&nbsp;
+          <span id="contact-subtype">{$form.contactSubType.label}&nbsp;&nbsp;&nbsp;{$form.contactSubType.html} {help id='contact-sub-type'}</span>
+        </td>
+      </tr>
+      <tr class="crm-import-datasource-form-block-onDuplicate">
+        <td class="label">{$form.onDuplicate.label}</td>
+        <td>{$form.onDuplicate.html} {help id='dupes'}</td>
+      </tr>
+      <tr class="crm-import-datasource-form-block-dedupe">
+        <td class="label">{$form.dedupe_rule_id.label}</td>
+        <td><span id="contact-dedupe_rule_id">{$form.dedupe_rule_id.html}</span> {help id='id-dedupe_rule'}</td>
+      </tr>
+      <tr class="crm-import-datasource-form-block-fieldSeparator">
+        <td class="label">{$form.fieldSeparator.label}</td>
+        <td>{$form.fieldSeparator.html} {help id='id-fieldSeparator'}</td>
+      </tr>
+      <tr>{include file="CRM/Core/Date.tpl"}</tr>
+      <tr>
+        <td></td><td class="description">{ts}Select the format that is used for date fields in your import data.{/ts}</td>
+      </tr>
+
+      {if $geoCode}
+        <tr class="crm-import-datasource-form-block-doGeocodeAddress">
+          <td class="label"></td>
+          <td>{$form.doGeocodeAddress.html} {$form.doGeocodeAddress.label}<br />
+            <span class="description">
+              {ts}This option is not recommended for large imports. Use the command-line geocoding script instead.{/ts}
+            </span>
+            {docURL page="user/initial-set-up/scheduled-jobs"}
+          </td>
+        </tr>
+      {/if}
+
+      {if $savedMapping}
+        <tr class="crm-import-datasource-form-block-savedMapping">
+          <td class="label"><label for="savedMapping">{$form.savedMapping.label}</label></td>
+          <td>{$form.savedMapping.html}<br />
+    &nbsp;&nbsp;&nbsp;     <span class="description">{ts}Select Saved Mapping or Leave blank to create a new One.{/ts}</span>
+          </td>
+        </tr>
+      {/if}
 
-        {/if}
- </table>
+      {if $form.disableUSPS}
+        <tr class="crm-import-datasource-form-block-disableUSPS">
+          <td class="label"></td>
+          <td>{$form.disableUSPS.html} <label for="disableUSPS">{$form.disableUSPS.label}</label></td>
+        </tr>
+      {/if}
+    </table>
   </div>
 
   <div class="crm-submit-buttons">{include file="CRM/common/formButtons.tpl" location="bottom"} </div>
-
   {literal}
     <script type="text/javascript">
       CRM.$(function($) {
-         //build data source form block
-         buildDataSourceFormBlock();
-         buildSubTypes();
-         buildDedupeRules();
-      });
-
-      function buildDataSourceFormBlock(dataSource)
-      {
-        var dataUrl = {/literal}"{crmURL p=$urlPath h=0 q=$urlPathVar|smarty:nodefaults}"{literal};
-
-        if (!dataSource ) {
-          var dataSource = cj("#dataSource").val();
-        }
-
-        if ( dataSource ) {
-          dataUrl = dataUrl + '&dataSource=' + dataSource;
-        } else {
-          cj("#data-source-form-block").html( '' );
-          return;
-        }
-
-        cj("#data-source-form-block").load( dataUrl );
-      }
-
-      function buildSubTypes( )
-      {
-        element = cj('input[name="contactType"]:checked').val( );
-        var postUrl = {/literal}"{crmURL p='civicrm/ajax/subtype' h=0 }"{literal};
-        var param = 'parentId='+ element;
-        cj.ajax({ type: "POST", url: postUrl, data: param, async: false, dataType: 'json',
-
-                        success: function(subtype){
-                                                   if ( subtype.length == 0 ) {
-                                                      cj("#contactSubType").empty();
-                                                      cj("#contact-subtype").hide();
-                                                   } else {
-                                                       cj("#contact-subtype").show();
-                                                       cj("#contactSubType").empty();
-
-                                                       cj("#contactSubType").append("<option value=''>- {/literal}{ts escape='js'}select{/ts}{literal} -</option>");
-                                                       for ( var key in  subtype ) {
-                                                           // stick these new options in the subtype select
-                                                           cj("#contactSubType").append("<option value="+key+">"+subtype[key]+" </option>");
-                                                       }
-                                                   }
-
-
-                                                 }
-  });
-
+       //build data source form block
+       buildDataSourceFormBlock();
+       buildSubTypes();
+       buildDedupeRules();
+    });
+
+    function buildDataSourceFormBlock(dataSource)
+    {
+      var dataUrl = {/literal}"{crmURL p=$urlPath h=0 q=$urlPathVar|smarty:nodefaults}"{literal};
+
+      if (!dataSource ) {
+        var dataSource = cj("#dataSource").val();
       }
 
-      function buildDedupeRules( )
-      {
-        element = cj("input[name=contactType]:checked").val();
-        var postUrl = {/literal}"{crmURL p='civicrm/ajax/dedupeRules' h=0 }"{literal};
-        var param = 'parentId='+ element;
-        cj.ajax({ type: "POST", url: postUrl, data: param, async: false, dataType: 'json',
-
-                        success: function(dedupe){
-                                                   if ( dedupe.length == 0 ) {
-                                                      cj("#dedupe_rule_id").empty();
-                                                      cj("#contact-dedupe").hide();
-                                                   } else {
-                                                       cj("#contact-dedupe").show();
-                                                       cj("#dedupe_rule_id").empty();
-
-                                                       cj("#dedupe_rule_id").append("<option value=''>- {/literal}{ts escape='js'}select{/ts}{literal} -</option>");
-                                                       for ( var key in  dedupe ) {
-                                                           // stick these new options in the dedupe select
-                                                           cj("#dedupe_rule_id").append("<option value="+key+">"+dedupe[key]+" </option>");
-                                                       }
-                                                   }
-
-
-                                                 }
-  });
-
+      if ( dataSource ) {
+        dataUrl = dataUrl + '&dataSource=' + dataSource;
+      } else {
+        cj("#data-source-form-block").html( '' );
+        return;
       }
 
+      cj("#data-source-form-block").load( dataUrl );
+    }
+
+    function buildSubTypes( )
+    {
+      element = cj('input[name="contactType"]:checked').val( );
+      var postUrl = {/literal}"{crmURL p='civicrm/ajax/subtype' h=0 }"{literal};
+      var param = 'parentId='+ element;
+      cj.ajax({ type: "POST", url: postUrl, data: param, async: false, dataType: 'json',
+        success: function(subtype)
+        {
+          if ( subtype.length === 0 ) {
+            cj("#contactSubType").empty();
+            cj("#contact-subtype").hide();
+          }
+          else {
+            cj("#contact-subtype").show();
+            cj("#contactSubType").empty();
+            cj("#contactSubType").append("<option value=''>- {/literal}{ts escape='js'}select{/ts}{literal} -</option>");
+            for ( var key in  subtype ) {
+              // stick these new options in the subtype select
+              cj("#contactSubType").append("<option value="+key+">"+subtype[key]+" </option>");
+            }
+          }
+        }
+      });
+    }
+
+    function buildDedupeRules( )
+    {
+      element = cj("input[name=contactType]:checked").val();
+      var postUrl = {/literal}"{crmURL p='civicrm/ajax/dedupeRules' h=0 }"{literal};
+      var param = 'parentId='+ element;
+      cj.ajax({ type: "POST", url: postUrl, data: param, async: false, dataType: 'json',
+        success: function(dedupe){
+          if ( dedupe.length === 0 ) {
+            cj("#dedupe_rule_id").empty();
+            cj("#contact-dedupe").hide();
+          } else {
+            cj("#contact-dedupe").show();
+            cj("#dedupe_rule_id").empty();
+
+            cj("#dedupe_rule_id").append("<option value=''>- {/literal}{ts escape='js'}select{/ts}{literal} -</option>");
+            for ( var key in  dedupe ) {
+              // stick these new options in the dedupe select
+              cj("#dedupe_rule_id").append("<option value="+key+">"+dedupe[key]+" </option>");
+            }
+          }
+        }
+      });
+    }
     </script>
   {/literal}
-
 </div>
index c35f0d2ad38a430123985c124081ac6eaba09326..64bb783794d125690180c7d37e2124ef91f47df6 100644 (file)
             <td class="crm-note-note">
                 {$note.note|mb_truncate:80:"...":false|nl2br}
                 {* Include '(more)' link to view entire note if it has been truncated *}
-                {assign var="noteSize" value=$note.note|count_characters:true}
+                {assign var="noteSize" value=$note.note|crmCountCharacters:true}
                 {if $noteSize GT 80}
                   <a class="crm-popup" href="{crmURL p='civicrm/contact/view/note' q="action=view&selectedChild=note&reset=1&cid=`$contactId`&id=`$note.id`"}">{ts}(more){/ts}</a>
                 {/if}
index 817b2a0c48100ec2c682b669b334c2b0b162a801..6ab1ca05856f9d4486717366db4271ae2b99cba4 100644 (file)
@@ -19,7 +19,7 @@
 </div>
 <div class="crm-submit-buttons">{include file="CRM/common/formButtons.tpl" location="top"}</div>
 {* Table for mapping data to CRM fields *}
- {include file="CRM/Contribute/Import/Form/MapTable.tpl}
+ {include file="CRM/Contribute/Import/Form/MapTable.tpl"}
 
 <div class="crm-submit-buttons">{include file="CRM/common/formButtons.tpl" location="bottom"}</div>
  {$initHideBoxes|smarty:nodefaults}
index 41c6fb239938cb0c2838ff6d8881d21f1bd11aa8..458bf3b4fe768107eddbcd7033705a5b3437b186 100644 (file)
                 {* Display mapper <select> field for 'Map Fields', and mapper value for 'Preview' *}
                 <td class="form-item even-row{if $wizard.currentStepName == 'Preview'} labels{/if}">
                     {if $wizard.currentStepName == 'Preview'}
-          {if $softCreditFields && $softCreditFields[$i] != ''}
-          {$mapper[$i]} - {$softCreditFields[$i]} {if $mapperSoftCreditType[$i]}({$mapperSoftCreditType[$i].label}){/if}
-      {else}
           {$mapper[$i]}
-      {/if}
                     {else}
                         {$form.mapper[$i].html|smarty:nodefaults}
                     {/if}
index 19f582d30bf243810932b8406eb15f655af76333..b1b0308c7c06c4d254aaa2b4351ac78148749cc7 100644 (file)
            </span>
          </td>
        </tr>
-       <tr class="crm-import-uploadfile-from-block-contactType">
-         <td class="label">{$form.contactType.label}</td>
-         <td>{$form.contactType.html}<br />
-           <span class="description">
-             {ts 1=$importEntities}Select 'Individual' if you are importing %1 made by individual persons.{/ts}
-             {ts 1=$importEntities}Select 'Organization' or 'Household' if you are importing %1 made by contacts of that type. (NOTE: Some built-in contact types may not be enabled for your site.){/ts}
-           </span>
-         </td>
-       </tr>
-       <tr class="crm-import-uploadfile-from-block-onDuplicate">
-         <td class="label">{$form.onDuplicate.label}</td>
-         <td>{$form.onDuplicate.html} {help id="id-onDuplicate"}</td>
-       </tr>
+      {if array_key_exists('contactType', $form)}
+        <tr class="crm-import-uploadfile-from-block-contactType">
+          <td class="label">{$form.contactType.label}</td>
+          <td>{$form.contactType.html}<br />
+            <span class="description">
+              {ts 1=$importEntities}Select 'Individual' if you are importing %1 made by individual persons.{/ts}
+              {ts 1=$importEntities}Select 'Organization' or 'Household' if you are importing %1 made by contacts of that type. (NOTE: Some built-in contact types may not be enabled for your site.){/ts}
+            </span>
+          </td>
+        </tr>
+      {/if}
+      {if array_key_exists('onDuplicate', $form)}
+        <tr class="crm-import-uploadfile-from-block-onDuplicate">
+          <td class="label">{$form.onDuplicate.label}</td>
+          <td>{$form.onDuplicate.html} {help id="id-onDuplicate"}</td>
+        </tr>
+      {/if}
         <tr class="crm-import-datasource-form-block-fieldSeparator">
           <td class="label">{$form.fieldSeparator.label} {help id='id-fieldSeparator' file='CRM/Contact/Import/Form/DataSource'}</td>
           <td>{$form.fieldSeparator.html}</td>
         </tr>
-       <tr class="crm-import-uploadfile-from-block-date">{include file="CRM/Core/Date.tpl"}</tr>
+       <tr class="crm-import-uploadfile-form-block-date">{include file="CRM/Core/Date.tpl"}</tr>
        {if $savedMapping}
-         <tr class="crm-import-uploadfile-from-block-savedMapping">
+         <tr class="crm-import-uploadfile-form-block-savedMapping">
            <td>{$form.savedMapping.label}</td>
            <td>{$form.savedMapping.html}<br />
              <span class="description">{ts}If you want to use a previously saved import field mapping - select it here.{/ts}</span>
index de4d09b101d17522364aaceb6bc1217070ab7d95..39515dfd59aee6b1a9cb56cef9d73626ac1972e9 100644 (file)
@@ -296,10 +296,7 @@ class CRM_Activity_Import_Parser_ActivityTest extends CiviUnitTestCase {
         ],
         'expected_error' => '',
       ],
-
-      // @todo This is also inconsistent. The map UI requires target contact
-      // but import is fine leaving it blank. In general civi is fine with
-      // a blank target so possibly map UI should not require it.
+      // a way to find the contact id is required.
       15 => [
         'input' => [
           'target_contact_id' => '',
@@ -307,7 +304,7 @@ class CRM_Activity_Import_Parser_ActivityTest extends CiviUnitTestCase {
           'activity_date_time' => $some_date,
           'activity_subject' => 'asubj',
         ],
-        'expected_error' => '',
+        'expected_error' => 'No matching Contact found for ()',
       ],
 
     ];
index 201a0986982c5a6da7e0198d6ea29f101c574aa2..021eb09094e4e4a65c188b1bad4427ead39c27d5 100644 (file)
@@ -11,7 +11,7 @@ class CRM_Contact_BAO_ContactTest extends CiviUnitTestCase {
    *
    * test with empty params.
    */
-  public function testAddWithEmptyParams() {
+  public function testAddWithEmptyParams(): void {
     $params = [];
     $contact = CRM_Contact_BAO_Contact::add($params);
 
@@ -24,7 +24,7 @@ class CRM_Contact_BAO_ContactTest extends CiviUnitTestCase {
    *
    * Test with names (create and update modes)
    */
-  public function testAddWithNames() {
+  public function testAddWithNames(): void {
     $firstName = 'Shane';
     $lastName = 'Whatson';
     $params = [
@@ -66,17 +66,17 @@ class CRM_Contact_BAO_ContactTest extends CiviUnitTestCase {
    * Test with all contact params
    * (create and update modes)
    */
-  public function testAddWithAll() {
+  public function testAddWithAll(): void {
     // Take the common contact params.
     $params = $this->contactParams();
 
     unset($params['location']);
-    $prefComm = $params['preferred_communication_method'];
+
     $contact = CRM_Contact_BAO_Contact::add($params);
     $contactId = $contact->id;
 
     $this->assertInstanceOf('CRM_Contact_DAO_Contact', $contact, 'Check for created object');
-
+    $createdContact = $this->callAPISuccessGetSingle('Contact', ['id' => $contact->id]);
     $this->assertEquals($params['first_name'], $contact->first_name, 'Check for first name creation.');
     $this->assertEquals($params['last_name'], $contact->last_name, 'Check for last name creation.');
     $this->assertEquals($params['middle_name'], $contact->middle_name, 'Check for middle name creation.');
@@ -88,31 +88,16 @@ class CRM_Contact_BAO_ContactTest extends CiviUnitTestCase {
     $this->assertEquals('1', $contact->is_opt_out, 'Check for is_opt_out creation.');
     $this->assertEquals($params['external_identifier'], $contact->external_identifier, 'Check for external_identifier creation.');
     $this->assertEquals($params['last_name'] . ', ' . $params['first_name'], $contact->sort_name, 'Check for sort_name creation.');
-    $this->assertEquals($params['preferred_mail_format'], $contact->preferred_mail_format,
-      'Check for preferred_mail_format creation.'
-    );
+
     $this->assertEquals($params['contact_source'], $contact->source, 'Check for contact_source creation.');
     $this->assertEquals($params['prefix_id'], $contact->prefix_id, 'Check for prefix_id creation.');
     $this->assertEquals($params['suffix_id'], $contact->suffix_id, 'Check for suffix_id creation.');
     $this->assertEquals($params['job_title'], $contact->job_title, 'Check for job_title creation.');
     $this->assertEquals($params['gender_id'], $contact->gender_id, 'Check for gender_id creation.');
-    $this->assertEquals('1', $contact->is_deceased, 'Check for is_deceased creation.');
-    $this->assertEquals(CRM_Utils_Date::processDate($params['birth_date']),
-      $contact->birth_date, 'Check for birth_date creation.'
-    );
-    $this->assertEquals(CRM_Utils_Date::processDate($params['deceased_date']),
-      $contact->deceased_date, 'Check for deceased_date creation.'
-    );
-    $dbPrefComm = explode(CRM_Core_DAO::VALUE_SEPARATOR,
-      $contact->preferred_communication_method
-    );
-    $checkPrefComm = [];
-    foreach ($dbPrefComm as $key => $value) {
-      if ($value) {
-        $checkPrefComm[$value] = 1;
-      }
-    }
-    $this->assertAttributesEquals($checkPrefComm, $prefComm);
+    $this->assertEquals('\ 11\ 13\ 15\ 1', $contact->preferred_communication_method);
+    $this->assertEquals(1, $createdContact['is_deceased'], 'Check is_deceased');
+    $this->assertEquals('1961-06-06', $createdContact['birth_date'], 'Check birth_date');
+    $this->assertEquals('1991-07-07', $createdContact['deceased_date'], 'Check deceased_date');
 
     $updateParams = [
       'contact_type' => 'Individual',
@@ -133,7 +118,6 @@ class CRM_Contact_BAO_ContactTest extends CiviUnitTestCase {
       ],
       'contact_source' => 'test update contact',
       'external_identifier' => 111111111,
-      'preferred_mail_format' => 'Both',
       'is_opt_out' => 0,
       'deceased_date' => '1981-03-03',
       'birth_date' => '1951-04-04',
@@ -143,70 +127,35 @@ class CRM_Contact_BAO_ContactTest extends CiviUnitTestCase {
         'do_not_mail' => 0,
         'do_not_trade' => 0,
       ],
-      'preferred_communication_method' => [
-        '1' => 0,
-        '2' => 1,
-        '3' => 0,
-        '4' => 1,
-        '5' => 0,
-      ],
+      'preferred_communication_method' => [2, 4],
     ];
 
-    $prefComm = $updateParams['preferred_communication_method'];
     $updateParams['contact_id'] = $contactId;
-    $contact = CRM_Contact_BAO_Contact::create($updateParams);
-    $contactId = $contact->id;
-
-    $this->assertInstanceOf('CRM_Contact_DAO_Contact', $contact, 'Check for created object');
-
-    $this->assertEquals($updateParams['first_name'], $contact->first_name, 'Check for first name creation.');
-    $this->assertEquals($updateParams['last_name'], $contact->last_name, 'Check for last name creation.');
-    $this->assertEquals($updateParams['middle_name'], $contact->middle_name, 'Check for middle name creation.');
-    $this->assertEquals($updateParams['contact_type'], $contact->contact_type, 'Check for contact type creation.');
-    $this->assertEquals('0', $contact->do_not_email, 'Check for do_not_email creation.');
-    $this->assertEquals('0', $contact->do_not_phone, 'Check for do_not_phone creation.');
-    $this->assertEquals('0', $contact->do_not_mail, 'Check for do_not_mail creation.');
-    $this->assertEquals('0', $contact->do_not_trade, 'Check for do_not_trade creation.');
-    $this->assertEquals('0', $contact->is_opt_out, 'Check for is_opt_out creation.');
-    $this->assertEquals($updateParams['external_identifier'], $contact->external_identifier,
-      'Check for external_identifier creation.'
-    );
-    $this->assertEquals($updateParams['last_name'] . ', ' . $updateParams['first_name'],
-      $contact->sort_name, 'Check for sort_name creation.'
-    );
-    $this->assertEquals($updateParams['preferred_mail_format'], $contact->preferred_mail_format,
-      'Check for preferred_mail_format creation.'
-    );
-    $this->assertEquals($updateParams['contact_source'], $contact->source, 'Check for contact_source creation.');
-    $this->assertEquals($updateParams['prefix_id'], $contact->prefix_id, 'Check for prefix_id creation.');
-    $this->assertEquals($updateParams['suffix_id'], $contact->suffix_id, 'Check for suffix_id creation.');
-    $this->assertEquals($updateParams['job_title'], $contact->job_title, 'Check for job_title creation.');
-    $this->assertEquals($updateParams['gender_id'], $contact->gender_id, 'Check for gender_id creation.');
-    $this->assertEquals('1', $contact->is_deceased, 'Check for is_deceased creation.');
-    $this->assertEquals(CRM_Utils_Date::processDate($updateParams['birth_date']),
-      date('YmdHis', strtotime($contact->birth_date)), 'Check for birth_date creation.'
-    );
-    $this->assertEquals(CRM_Utils_Date::processDate($updateParams['deceased_date']),
-      date('YmdHis', strtotime($contact->deceased_date)), 'Check for deceased_date creation.'
-    );
-    $dbPrefComm = explode(CRM_Core_DAO::VALUE_SEPARATOR,
-      $contact->preferred_communication_method
-    );
-    $checkPrefComm = [];
-    foreach ($dbPrefComm as $key => $value) {
-      if ($value) {
-        $checkPrefComm[$value] = 1;
+    // Annoyingly `create` alters params
+    $preUpdateParams = $updateParams;
+    CRM_Contact_BAO_Contact::create($updateParams);
+    $return = array_merge(array_keys($updateParams), ['do_not_phone', 'do_not_email', 'do_not_trade', 'do_not_mail']);
+    $contact = $this->callAPISuccessGetSingle('Contact', ['id' => $contactId, 'return' => $return]);
+    foreach ($preUpdateParams as $key => $value) {
+      if ($key === 'website') {
+        continue;
+      }
+      if ($key === 'privacy') {
+        foreach ($value as $privacyKey => $privacyValue) {
+          $this->assertEquals($privacyValue, $contact[$privacyKey], $key);
+        }
+      }
+      else {
+        $this->assertEquals($value, $contact[$key], $key);
       }
     }
-    $this->assertAttributesEquals($checkPrefComm, $prefComm);
-
     $this->contactDelete($contactId);
   }
 
   /**
    * Test case for add( ) with All contact types.
    */
-  public function testAddWithAllContactTypes() {
+  public function testAddWithAllContactTypes(): void {
     $firstName = 'Bill';
     $lastName = 'Adams';
     $params = [
@@ -512,10 +461,6 @@ class CRM_Contact_BAO_ContactTest extends CiviUnitTestCase {
     CRM_Contact_BAO_Contact::resolveDefaults($params);
 
     $this->assertEquals(1004, $params['address'][1]['state_province_id']);
-    $this->assertEquals(CRM_Core_PseudoConstant::country($params['address'][1]['country_id']),
-      $params['address'][1]['country'],
-      'Check for country.'
-    );
   }
 
   /**
@@ -782,7 +727,7 @@ class CRM_Contact_BAO_ContactTest extends CiviUnitTestCase {
     //check the values in DB.
     foreach ($params as $key => $val) {
       if (!is_array($params[$key])) {
-        if ($key == 'contact_source') {
+        if ($key === 'contact_source') {
           $this->assertDBCompareValue('CRM_Contact_DAO_Contact', $contactId, 'source',
             'id', $params[$key], "Check for {$key} creation."
           );
@@ -817,16 +762,10 @@ class CRM_Contact_BAO_ContactTest extends CiviUnitTestCase {
       'id', $params['deceased_date'], 'Check for deceased_date creation.'
     );
 
-    $dbPrefComm = explode(CRM_Core_DAO::VALUE_SEPARATOR,
+    $dbPrefComm = array_values(array_filter(explode(CRM_Core_DAO::VALUE_SEPARATOR,
       CRM_Core_DAO::getFieldValue('CRM_Contact_DAO_Contact', $contactId, 'preferred_communication_method', 'id', TRUE)
-    );
-    $checkPrefComm = [];
-    foreach ($dbPrefComm as $key => $value) {
-      if ($value) {
-        $checkPrefComm[$value] = 1;
-      }
-    }
-    $this->assertAttributesEquals($checkPrefComm, $params['preferred_communication_method']);
+    )));
+    $this->assertEquals($dbPrefComm, $params['preferred_communication_method']);
 
     //Now check DB for Address
     $searchParams = [
@@ -931,13 +870,7 @@ class CRM_Contact_BAO_ContactTest extends CiviUnitTestCase {
         'do_not_phone' => 1,
         'do_not_email' => 1,
       ],
-      'preferred_communication_method' => [
-        '1' => 0,
-        '2' => 1,
-        '3' => 0,
-        '4' => 1,
-        '5' => 0,
-      ],
+      'preferred_communication_method' => [2, 4],
     ];
 
     $updatePfParams = [
@@ -1004,7 +937,7 @@ class CRM_Contact_BAO_ContactTest extends CiviUnitTestCase {
     //check the values in DB.
     foreach ($updateCParams as $key => $val) {
       if (!is_array($updateCParams[$key])) {
-        if ($key == 'contact_source') {
+        if ($key === 'contact_source') {
           $this->assertDBCompareValue('CRM_Contact_DAO_Contact', $contactId, 'source',
             'id', $updateCParams[$key], "Check for {$key} creation."
           );
@@ -1038,17 +971,8 @@ class CRM_Contact_BAO_ContactTest extends CiviUnitTestCase {
     $this->assertDBCompareValue('CRM_Contact_DAO_Contact', $contactId, 'deceased_date', 'id',
       $updateCParams['deceased_date'], 'Check for deceased_date creation.'
     );
-
-    $dbPrefComm = explode(CRM_Core_DAO::VALUE_SEPARATOR,
-      CRM_Core_DAO::getFieldValue('CRM_Contact_DAO_Contact', $contactId, 'preferred_communication_method', 'id', TRUE)
-    );
-    $checkPrefComm = [];
-    foreach ($dbPrefComm as $key => $value) {
-      if ($value) {
-        $checkPrefComm[$value] = 1;
-      }
-    }
-    $this->assertAttributesEquals($checkPrefComm, $updateCParams['preferred_communication_method']);
+    $created = $this->callAPISuccessGetSingle('Contact', ['id' => $contactId]);
+    $this->assertEquals($created['preferred_communication_method'], $updateCParams['preferred_communication_method']);
 
     //Now check DB for Address
     $searchParams = [
@@ -1319,7 +1243,6 @@ class CRM_Contact_BAO_ContactTest extends CiviUnitTestCase {
       ],
       'contact_source' => 'test contact',
       'external_identifier' => 123456789,
-      'preferred_mail_format' => 'Both',
       'is_opt_out' => 1,
       'legal_identifier' => '123456789',
       'image_URL' => 'http://image.com',
@@ -1331,13 +1254,7 @@ class CRM_Contact_BAO_ContactTest extends CiviUnitTestCase {
         'do_not_mail' => 1,
         'do_not_trade' => 1,
       ],
-      'preferred_communication_method' => [
-        '1' => 1,
-        '2' => 0,
-        '3' => 1,
-        '4' => 0,
-        '5' => 1,
-      ],
+      'preferred_communication_method' => [1, 3, 5],
     ];
 
     $params['address'] = [];
index 6e1870772757f428865d3fd46bd3f57824a09a3d..5e20414e7e7e33238fe0dd3ed48073c39787dd59 100644 (file)
@@ -237,31 +237,28 @@ class CRM_Contact_Import_Form_MapFieldTest extends CiviUnitTestCase {
     return [
       [
         ['name' => 'first_name', 'contact_type' => 'Individual', 'column_number' => 0],
-        "document.forms.MapField['mapper[0][1]'].style.display = 'none';
-document.forms.MapField['mapper[0][2]'].style.display = 'none';
-document.forms.MapField['mapper[0][3]'].style.display = 'none';\n",
-        ['mapper[0]' => ['first_name', 0, NULL]],
+        "swapOptions(document.forms.MapField, 'mapper[0]', 0, 4, 'hs_mapper_0_');\n",
+        ['mapper[0]' => ['first_name']],
       ],
       [
         ['name' => 'phone', 'contact_type' => 'Individual', 'column_number' => 0, 'phone_type_id' => 1, 'location_type_id' => 2],
-        "document.forms.MapField['mapper[0][3]'].style.display = 'none';\n",
+        "swapOptions(document.forms.MapField, 'mapper[0]', 2, 4, 'hs_mapper_0_');\n",
         ['mapper[0]' => ['phone', 2, 1]],
       ],
       [
         ['name' => 'im', 'contact_type' => 'Individual', 'column_number' => 0, 'im_provider_id' => 1, 'location_type_id' => 2],
-        "document.forms.MapField['mapper[0][3]'].style.display = 'none';\n",
+        "swapOptions(document.forms.MapField, 'mapper[0]', 2, 4, 'hs_mapper_0_');\n",
         ['mapper[0]' => ['im', 2, 1]],
       ],
       [
         ['name' => 'url', 'contact_type' => 'Individual', 'column_number' => 0, 'website_type_id' => 1],
-        "document.forms.MapField['mapper[0][2]'].style.display = 'none';
-document.forms.MapField['mapper[0][3]'].style.display = 'none';\n",
+        "swapOptions(document.forms.MapField, 'mapper[0]', 1, 4, 'hs_mapper_0_');\n",
         ['mapper[0]' => ['url', 1]],
       ],
       [
         // Yes, the relationship mapping really does use url whereas non relationship uses website because... legacy
         ['name' => 'url', 'contact_type' => 'Individual', 'column_number' => 0, 'website_type_id' => 1, 'relationship_type_id' => 1, 'relationship_direction' => 'a_b'],
-        "document.forms.MapField['mapper[0][3]'].style.display = 'none';\n",
+        "swapOptions(document.forms.MapField, 'mapper[0]', 2, 4, 'hs_mapper_0_');\n",
         ['mapper[0]' => ['1_a_b', 'url', 1]],
       ],
       [
@@ -271,9 +268,7 @@ document.forms.MapField['mapper[0][3]'].style.display = 'none';\n",
       ],
       [
         ['name' => 'do_not_import', 'contact_type' => 'Individual', 'column_number' => 0],
-        "document.forms.MapField['mapper[0][1]'].style.display = 'none';
-document.forms.MapField['mapper[0][2]'].style.display = 'none';
-document.forms.MapField['mapper[0][3]'].style.display = 'none';\n",
+        "swapOptions(document.forms.MapField, 'mapper[0]', 0, 4, 'hs_mapper_0_');\n",
         ['mapper[0]' => []],
       ],
     ];
@@ -354,9 +349,8 @@ document.forms.MapField['mapper[0][3]'].style.display = 'none';\n",
 
     $defaults = [];
     $defaults["mapper[$columnNumber]"] = $processor->getSavedQuickformDefaultsForColumn($columnNumber);
-    $js = $processor->getQuickFormJSForField($columnNumber);
 
-    return ['defaults' => $defaults, 'js' => $js];
+    return ['defaults' => $defaults];
   }
 
   /**
diff --git a/tests/phpunit/CRM/Contact/Import/Form/data/individual_country_state_county_with_related.csv b/tests/phpunit/CRM/Contact/Import/Form/data/individual_country_state_county_with_related.csv
new file mode 100644 (file)
index 0000000..42a7d22
--- /dev/null
@@ -0,0 +1,8 @@
+First Name,Last Name,Email,County,Country,State,Custom field state,Custom Field Country,Address Custom Field Country,Address Custom field state,Mum Name,Mum Last name,Mum email,Mum State,Mum Country,Mum County,Address Mum Custom Field Country,Address Mum Custom field state,Mum Custom Field Country,Mum Custom field State,expected,error_value
+Susie,Jones,susie@example.com,,ABC,,,,,,Mum,Jones,mum@example.com,,,,,,,,Invalid,ABC
+Susie,Jones,susie@example.com,,,,,,,,Mum,Jones,mum@example.com,NSW,ABC,,,,,,Invalid,ABC
+Susie,Jones,susie@example.com,,Australia,NSW,NSW,Australia,Australia,NSW,Mum,Jones,mum@example.com,NSW,Australia,,Australia,NSW,Australia,NSW,Valid,
+Susie,Jones,susie@example.com,,AU,New South Wales,New South Wales,AU,AU,New South Wales,Mum,Jones,mum@example.com,New South Wales,AU,,AU,New South Wales,Australia,New South Wales,Valid,
+Susie,Jones,susie@example.com,,1013,New South Wales,,1013,1013,New South Wales,Mum,Jones,mum@example.com,New South Wales,1013,,1013,New South Wales,1013,New South Wales,Valid,
+Susie,Jones,susie@example.com,,AUSTRALIA,,,,,,Mum,Jones,mum@example.com,,austRalia,,,,,,Valid,
+Susie,Jones,susie@example.com,,AU,NEW South Wales,NEW South Wales,AU,AU,NEW South Wales,Mum,Jones,mum@example.com,NEW South Wales,AU,,AU,NEW South Wales,Australia,NEW South Wales,Valid,
diff --git a/tests/phpunit/CRM/Contact/Import/Form/data/individual_geocode.csv b/tests/phpunit/CRM/Contact/Import/Form/data/individual_geocode.csv
new file mode 100644 (file)
index 0000000..f9c9e03
--- /dev/null
@@ -0,0 +1,4 @@
+first_name,last_name,geocodeone,GeoCodetwo,expected
+Madame,1,1,-1,Valid
+Madame,2,a,b,Invalid
+Madame,3,1.1123,-1.1123,Valid
diff --git a/tests/phpunit/CRM/Contact/Import/Form/data/individual_import_related_extid.csv b/tests/phpunit/CRM/Contact/Import/Form/data/individual_import_related_extid.csv
new file mode 100644 (file)
index 0000000..1f7b96f
--- /dev/null
@@ -0,0 +1,2 @@
+Main Contact First Name,Main Contact LastName,Employer ext id
+Bob,Smith,qwerty
index e5dd31fcdbda03ad56d80629248e6c5488a8ef93..783f25a293d3fc4ab87598f802b7d7a915ab6811 100644 (file)
@@ -1,2 +1,2 @@
-First Name,Last Name,Birth Date,Street Address,City,Postal Code,Country,State,Email,Signature Text,IM,Website,Phone,Phone Ext,Mum’s first name,Last Name,Mum-Street Address,Mum-City,Mum-Country,Mum-State,Mum-email,Mum-signature Test,Mum-IM,Mum-website,Mum-phone,Mum-phone-ext,Mum-home-phone,Mum-home-mobile,Sister-Street Address,Sister-City,Sister-Country,Sister-State,Sister-email,Sister-signature Test,Sister-IM,Sister-website,Sister-phone,Sister-phone-ext,Team,Team Website,Team email ,Team Reference,Team Address1,Team Address 2,Team address Validity Date,Team Backup Website
-Susie,Jones,2002-01-08,24 Adelaide Road,Sydney,90210,Australia,NSW,susie@example.com,Regards,susiej,https://susie.example.com,999-4445,123,Mum,Jones,The Green House,,,,mum@example.com,,Mum-IM,http://mum.example.com,911,1,88-999,99-888,,,New Zealand,,sis@example.com,,,,555-666,,Soccer Superstars,https://super.example.org,tt@example.org,T-882,PO Box 999,Marion Square,2022-03-04,http://super-t.example.org
+First Name,Last Name,Birth Date,Street Address,City,Postal Code,Country,State,Email,Signature Text,IM,Website,Phone,Phone Ext,Mum’s first name,Last Name,Mum-Street Address,Mum-City,Mum-Country,Mum-State,Mum-email,Mum-signature Test,Mum-IM,Mum-website,Mum-phone,Mum-phone-ext,Mum-home-phone,Mum-home-mobile,Sister-Street Address,Sister-City,Sister-Country,Sister-State,Sister-email,Sister-signature Test,Sister-IM,Sister-website,Sister-phone,Sister-phone-ext,Team,Team Website,Team email ,Team Reference,Team Address1,Team Address 2,Team address Validity Date,Team Backup Website,Team open
+Susie,Jones,2002-01-08,24 Adelaide Road,Sydney,90210,Australia,NSW,susie@example.com,Regards,susiej,https://susie.example.com,999-4445,123,Mum,Jones,The Green House,,,,mum@example.com,,Mum-IM,http://mum.example.com,911,1,88-999,99-888,,,New Zealand,,sis@example.com,,,,555-666,,Soccer Superstars,https://super.example.org,tt@example.org,T-882,PO Box 999,Marion Square,2022-03-04,http://super-t.example.org,team-id
diff --git a/tests/phpunit/CRM/Contact/Import/Form/data/individual_parse_failure.csv b/tests/phpunit/CRM/Contact/Import/Form/data/individual_parse_failure.csv
new file mode 100644 (file)
index 0000000..9c18f0c
--- /dev/null
@@ -0,0 +1,2 @@
+First Name,Last Name,Street Address
+Sally,Smith,Grange House
index 57ce7109044206ec6cfa0f370becb1b0d6a2cbf5..02bdc43963c60947291470821b698dec504e324c 100644 (file)
 use Civi\Api4\Address;
 use Civi\Api4\Contact;
 use Civi\Api4\ContactType;
+use Civi\Api4\Email;
+use Civi\Api4\IM;
 use Civi\Api4\LocationType;
+use Civi\Api4\OpenID;
+use Civi\Api4\Phone;
 use Civi\Api4\RelationshipType;
 use Civi\Api4\UserJob;
+use Civi\Api4\Website;
 
 /**
  *  Test contact import parser.
@@ -37,11 +42,18 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
    */
   protected $entity = 'Contact';
 
+  /**
+   * Array of existing relationships.
+   *
+   * @var array
+   */
+  private $relationships = [];
+
   /**
    * Tear down after test.
    */
   public function tearDown(): void {
-    $this->quickCleanup(['civicrm_address', 'civicrm_phone', 'civicrm_email', 'civicrm_user_job', 'civicrm_relationship'], TRUE);
+    $this->quickCleanup(['civicrm_address', 'civicrm_phone', 'civicrm_openid', 'civicrm_email', 'civicrm_user_job', 'civicrm_relationship', 'civicrm_im', 'civicrm_website'], TRUE);
     RelationshipType::delete()->addWhere('name_a_b', '=', 'Dad to')->execute();
     ContactType::delete()->addWhere('name', '=', 'baby')->execute();
     parent::tearDown();
@@ -53,7 +65,6 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
    * @throws \API_Exception
    * @throws \CRM_Core_Exception
    * @throws \CiviCRM_API3_Exception
-   * @throws \Civi\API\Exception\UnauthorizedException
    */
   public function testImportParserWithEmployeeOfRelationship(): void {
     $this->organizationCreate([
@@ -70,15 +81,15 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
     $values = array_values($contactImportValues);
     $userJobID = $this->getUserJobID([
       'mapper' => [['first_name'], ['last_name'], ['5_a_b', 'organization_name']],
+      'onDuplicate' => CRM_Import_Parser::DUPLICATE_UPDATE,
     ]);
 
     $parser = new CRM_Contact_Import_Parser_Contact($fields);
     $parser->setUserJobID($userJobID);
-    $parser->_onDuplicate = CRM_Import_Parser::DUPLICATE_UPDATE;
     $parser->init();
 
     $this->assertEquals(CRM_Import_Parser::VALID, $parser->import(CRM_Import_Parser::DUPLICATE_UPDATE, $values), 'Return code from parser import was not as expected');
-    $this->callAPISuccess('Contact', 'get', [
+    $this->callAPISuccessGetSingle('Contact', [
       'first_name' => 'Alok',
       'last_name' => 'Patel',
       'organization_name' => 'Agileware',
@@ -289,7 +300,7 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
       'external_identifier' => 'billy',
       'nick_name' => 'Old Bill',
       'contact_sub_type' => 'Staff',
-    ], CRM_Import_Parser::DUPLICATE_UPDATE, NULL);
+    ], CRM_Import_Parser::DUPLICATE_UPDATE, FALSE);
     $contact = $this->callAPISuccessGetSingle('Contact', ['id' => $contactID]);
     $this->assertEquals('', $contact['nick_name']);
     $this->assertEquals(['Parent'], $contact['contact_sub_type']);
@@ -365,7 +376,7 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
     // This is some deep weirdness - this sets a flag for updatingBlankLocinfo - allowing input to be blanked
     // (which IS a good thing but it's pretty weird & all to do with legacy profile stuff).
     CRM_Core_Session::singleton()->set('authSrc', CRM_Core_Permission::AUTH_SRC_CHECKSUM);
-    $this->runImport($updateValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID, [NULL, 1]);
+    $this->runImport($updateValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID);
     $originalValues['id'] = $result['id'];
     $this->callAPISuccessGetSingle('Email', ['contact_id' => $originalValues['id'], 'is_primary' => 1]);
     $this->callAPISuccessGetSingle('Contact', $originalValues);
@@ -391,7 +402,7 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
    *
    * @throws \Exception
    */
-  public function testImportParserWithUpdateWithChangedExternalIdentifier() {
+  public function testImportParserWithUpdateWithChangedExternalIdentifier(): void {
     [$contactValues, $result] = $this->setUpBaseContact(['external_identifier' => 'windows']);
     $contact_id = $result['id'];
     $contactValues['nick_name'] = 'Old Bill';
@@ -415,7 +426,8 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
     $contactValues['external_identifier'] = 'android';
     $contactValues['street_address'] = 'Big Mansion';
     $contactValues['phone'] = '911';
-    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID, [0 => NULL, 1 => NULL, 2 => NULL, 3 => NULL, 4 => NULL, 5 => 2, 6 => 2]);
+    $mapper = $this->getFieldMappingFromInput($contactValues, 2);
+    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID, $mapper);
     $address = $this->callAPISuccessGetSingle('Address', ['street_address' => 'Big Mansion']);
     $this->assertEquals(2, $address['location_type_id']);
 
@@ -429,7 +441,7 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
   /**
    * Test that the not-really-encouraged way of creating locations via contact.create doesn't mess up primaries.
    */
-  public function testContactLocationBlockHandling() {
+  public function testContactLocationBlockHandling(): void {
     $id = $this->individualCreate([
       'phone' => [
         1 => [
@@ -519,13 +531,14 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
    *
    * @throws \Exception
    */
-  public function testImportPrimaryAddress() {
+  public function testImportPrimaryAddress(): void {
     [$contactValues] = $this->setUpBaseContact();
     $contactValues['nick_name'] = 'Old Bill';
     $contactValues['external_identifier'] = 'android';
     $contactValues['street_address'] = 'Big Mansion';
     $contactValues['phone'] = 12334;
-    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID, [0 => NULL, 1 => NULL, 2 => 'Primary', 3 => NULL, 4 => NULL, 5 => 'Primary', 6 => 'Primary']);
+    $mapper = $this->getFieldMappingFromInput($contactValues);
+    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID, $mapper);
     $address = $this->callAPISuccessGetSingle('Address', ['street_address' => 'Big Mansion']);
     $this->assertEquals(1, $address['location_type_id']);
     $this->assertEquals(1, $address['is_primary']);
@@ -544,7 +557,7 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
    *
    * @throws \Exception
    */
-  public function testIgnoreLocationTypeId() {
+  public function testIgnoreLocationTypeId(): void {
     // Create a rule that matches on last name and street address.
     $rgid = $this->createRuleGroup()['id'];
     $this->callAPISuccess('Rule', 'create', [
@@ -579,8 +592,8 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
     ];
 
     // We want to import with a location_type_id of 4.
-    $importLocationTypeId = '4';
-    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_SKIP, CRM_Import_Parser::DUPLICATE, [0 => NULL, 1 => NULL, 2 => $importLocationTypeId], NULL, $rgid);
+    $fieldMapping = $this->getFieldMappingFromInput($contactValues, 4);
+    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_SKIP, CRM_Import_Parser::DUPLICATE, $fieldMapping, NULL, $rgid);
     $address = $this->callAPISuccessGetSingle('Address', ['street_address' => 'Big Mansion']);
     $this->assertEquals(1, $address['location_type_id']);
     $contact = $this->callAPISuccessGetSingle('Contact', $contact1Params);
@@ -593,14 +606,14 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
    *
    * @throws \CRM_Core_Exception
    */
-  public function testAddressWithCustomData() {
+  public function testAddressWithCustomData(): void {
     $ids = $this->entityCustomGroupWithSingleFieldCreate('Address', 'AddressTest.php');
     [$contactValues] = $this->setUpBaseContact();
     $contactValues['nick_name'] = 'Old Bill';
     $contactValues['external_identifier'] = 'android';
     $contactValues['street_address'] = 'Big Mansion';
     $contactValues['custom_' . $ids['custom_field_id']] = 'Update';
-    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID, [0 => NULL, 1 => NULL, 2 => NULL, 3 => NULL, 4 => NULL, 5 => 'Primary', 6 => 'Primary']);
+    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID);
     $address = $this->callAPISuccessGetSingle('Address', ['street_address' => 'Big Mansion', 'return' => 'custom_' . $ids['custom_field_id']]);
     $this->assertEquals('Update', $address['custom_' . $ids['custom_field_id']]);
   }
@@ -620,10 +633,53 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
       'nick_name' => 'Billy-boy',
       'gender_id' => 'Female',
     ];
-    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID, [NULL, NULL, 'Primary', NULL, NULL]);
+    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID);
     $this->callAPISuccessGetSingle('Contact', $contactValues);
   }
 
+  /**
+   * Test greeting imports.
+   *
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   * @throws \CiviCRM_API3_Exception
+   */
+  public function testGreetings(): void {
+    $contactValues = [
+      'first_name' => 'Bill',
+      'last_name' => 'Gates',
+      // id = 2
+      'email_greeting' => 'Dear {contact.prefix_id:label} {contact.first_name} {contact.last_name}',
+      // id = 3
+      'postal_greeting' => 'Dear {contact.prefix_id:label} {contact.last_name}',
+      // id = 1
+      'addressee' => '{contact.prefix_id:label}{ }{contact.first_name}{ }{contact.middle_name}{ }{contact.last_name}{ }{contact.suffix_id:label}',
+      5 => 1,
+    ];
+    $userJobID = $this->getUserJobID(['mapper' => [['first_name'], ['last_name'], ['email_greeting'], ['postal_greeting'], ['addressee']]]);
+    $parser = new CRM_Contact_Import_Parser_Contact(array_keys($contactValues));
+    $parser->setUserJobID($userJobID);
+    $values = array_values($contactValues);
+    $parser->import(CRM_Import_Parser::DUPLICATE_UPDATE, $values);
+    $contact = Contact::get(FALSE)->addWhere('last_name', '=', 'Gates')->addSelect('email_greeting_id', 'postal_greeting_id', 'addressee_id')->execute()->first();
+    $this->assertEquals(2, $contact['email_greeting_id']);
+    $this->assertEquals(3, $contact['postal_greeting_id']);
+    $this->assertEquals(1, $contact['addressee_id']);
+
+    Contact::delete()->addWhere('id', '=', $contact['id'])->setUseTrash(TRUE)->execute();
+
+    // Now try again with numbers.
+    $values[2] = 2;
+    $values[3] = 3;
+    $values[4] = 1;
+    $parser->import(CRM_Import_Parser::DUPLICATE_UPDATE, $values);
+    $contact = Contact::get(FALSE)->addWhere('last_name', '=', 'Gates')->addSelect('email_greeting_id', 'postal_greeting_id', 'addressee_id')->execute()->first();
+    $this->assertEquals(2, $contact['email_greeting_id']);
+    $this->assertEquals(3, $contact['postal_greeting_id']);
+    $this->assertEquals(1, $contact['addressee_id']);
+
+  }
+
   /**
    * Test prefix & suffix work when you specify the label.
    *
@@ -679,7 +735,7 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
       'nick_name' => 'Billy-boy',
       $this->getCustomFieldName('select') => 'Yellow',
     ];
-    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID, [NULL, NULL, 'Primary', NULL, NULL]);
+    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID);
     $contact = $this->callAPISuccessGetSingle('Contact', array_merge($contactValues, ['return' => $this->getCustomFieldName('select')]));
     $this->assertEquals('Y', $contact[$this->getCustomFieldName('select')]);
   }
@@ -698,7 +754,7 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
       'nick_name' => 'Billy-boy',
       $this->getCustomFieldName('select') => 'Y',
     ];
-    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID, [NULL, NULL, 'Primary', NULL, NULL]);
+    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID);
     $contact = $this->callAPISuccessGetSingle('Contact', array_merge($contactValues, ['return' => $this->getCustomFieldName('select')]));
     $this->assertEquals('Y', $contact[$this->getCustomFieldName('select')]);
   }
@@ -716,7 +772,7 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
       'nick_name' => 'Billy-boy',
       'preferred_language' => 'English (Australia)',
     ];
-    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID, [NULL, NULL, 'Primary', NULL, NULL]);
+    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID);
   }
 
   /**
@@ -742,18 +798,21 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
    *
    * @throws \Exception
    */
-  public function testImportTwoAddressFirstPrimary() {
+  public function testImportTwoAddressFirstPrimary(): void {
     [$contactValues] = $this->setUpBaseContact();
     $contactValues['nick_name'] = 'Old Bill';
     $contactValues['external_identifier'] = 'android';
+
     $contactValues['street_address'] = 'Big Mansion';
     $contactValues['phone'] = 12334;
-    $fields = array_keys($contactValues);
+
+    $fieldMapping = $this->getFieldMappingFromInput($contactValues);
     $contactValues['street_address_2'] = 'Teeny Mansion';
+    $fieldMapping[] = ['name' => 'street_address', 'location_type_id' => 3];
     $contactValues['phone_2'] = 4444;
-    $fields[] = 'street_address';
-    $fields[] = 'phone';
-    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID, [0 => NULL, 1 => NULL, 2 => NULL, 3 => NULL, 4 => NULL, 5 => 'Primary', 6 => 'Primary', 7 => 3, 8 => 3], $fields);
+    $fieldMapping[] = ['name' => 'phone', 'location_type_id' => 3, 'phone_type_id' => 1];
+
+    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID, $fieldMapping);
     $contact = $this->callAPISuccessGetSingle('Contact', ['external_identifier' => 'android']);
     $address = $this->callAPISuccess('Address', 'get', ['contact_id' => $contact['id'], 'sequential' => 1]);
 
@@ -810,18 +869,21 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
    *
    * @throws \Exception
    */
-  public function testImportTwoAddressSecondPrimary() {
+  public function testImportTwoAddressSecondPrimary(): void {
     [$contactValues] = $this->setUpBaseContact();
     $contactValues['nick_name'] = 'Old Bill';
     $contactValues['external_identifier'] = 'android';
     $contactValues['street_address'] = 'Big Mansion';
     $contactValues['phone'] = 12334;
-    $fields = array_keys($contactValues);
+
+    $fieldMapping = $this->getFieldMappingFromInput($contactValues, 3);
+
     $contactValues['street_address_2'] = 'Teeny Mansion';
+    $fieldMapping[] = ['name' => 'street_address', 'location_type_id' => 'Primary'];
     $contactValues['phone_2'] = 4444;
-    $fields[] = 'street_address';
-    $fields[] = 'phone';
-    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID, [0 => NULL, 1 => NULL, 2 => NULL, 3 => NULL, 4 => NULL, 5 => 3, 6 => 3, 7 => 'Primary', 8 => 'Primary'], $fields);
+    $fieldMapping[] = ['name' => 'phone', 'location_type_id' => 'Primary', 'phone_type_id' => 1];
+
+    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID, $fieldMapping);
     $contact = $this->callAPISuccessGetSingle('Contact', ['external_identifier' => 'android']);
     $address = $this->callAPISuccess('Address', 'get', ['contact_id' => $contact['id'], 'sequential' => 1])['values'];
 
@@ -849,7 +911,7 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
    *
    * @throws \Exception
    */
-  public function testImportPrimaryAddressUpdate() {
+  public function testImportPrimaryAddressUpdate(): void {
     [$contactValues] = $this->setUpBaseContact(['external_identifier' => 'android']);
     $contactValues['email'] = 'melinda.gates@microsoft.com';
     $contactValues['phone'] = '98765';
@@ -884,7 +946,8 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
     $params = [
       'custom_' . $customField['id'] => 'Label1|Label2',
     ];
-    CRM_Contact_Import_Parser_Contact::isErrorInCustomData($params, $errorMessage);
+    $parser = new CRM_Contact_Import_Parser_Contact();
+    $parser->isErrorInCustomData($params, $errorMessage);
     $this->assertEquals(NULL, $errorMessage);
   }
 
@@ -941,12 +1004,12 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
       'individual_bad_email' => [
         'csv' => 'individual_invalid_email.csv',
         'mapper' => [['email', 1], ['first_name'], ['last_name']],
-        'expected_error' => 'Invalid value for field(s) : email',
+        'expected_error' => 'Invalid value for field(s) : Email',
       ],
       'individual_related_bad_email' => [
         'csv' => 'individual_invalid_related_email.csv',
         'mapper' => [['1_a_b', 'email', 1], ['first_name'], ['last_name']],
-        'expected_error' => 'Invalid value for field(s) : email',
+        'expected_error' => 'Invalid value for field(s) : (Child of) Email',
       ],
       'individual_invalid_external_identifier_only' => [
         // External identifier is only enough in upgrade mode.
@@ -1050,6 +1113,66 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
     $this->assertCount(8, $contacts);
   }
 
+  /**
+   * Test importing state country & county.
+   *
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   */
+  public function testImportCountryStateCounty(): void {
+    $childKey = $this->getRelationships()['Child of']['id'] . '_a_b';
+    // @todo - rows that don't work yet are set to do_not_import.
+    $addressCustomGroupID = $this->createCustomGroup(['extends' => 'Address', 'name' => 'Address']);
+    $contactCustomGroupID = $this->createCustomGroup(['extends' => 'Contact', 'name' => 'Contact']);
+    $addressCustomFieldID = $this->createCountryCustomField(['custom_group_id' => $addressCustomGroupID])['id'];
+    $contactCustomFieldID = $this->createMultiCountryCustomField(['custom_group_id' => $contactCustomGroupID])['id'];
+    $contactStateCustomFieldID = $this->createStateCustomField(['custom_group_id' => $contactCustomGroupID])['id'];
+    $customField = 'custom_' . $contactCustomFieldID;
+    $addressCustomField = 'custom_' . $addressCustomFieldID;
+    $contactStateCustomField = 'custom_' . $contactStateCustomFieldID;
+
+    $mapper = [
+      ['first_name'],
+      ['last_name'],
+      ['email'],
+      ['county'],
+      ['country'],
+      ['state_province'],
+      [$contactStateCustomField],
+      [$customField],
+      [$addressCustomField],
+      // [$addressCustomField, 'state_province'],
+      ['do_not_import'],
+      [$childKey, 'first_name'],
+      [$childKey, 'last_name'],
+      [$childKey, 'email'],
+      [$childKey, 'state_province'],
+      [$childKey, 'country'],
+      [$childKey, 'county'],
+      // [$childKey, $addressCustomField, 'country'],
+      ['do_not_import'],
+      // [$childKey, $addressCustomField, 'state_province'],
+      ['do_not_import'],
+      // [$childKey, $customField, 'country'],
+      ['do_not_import'],
+      // [$childKey, $customField, 'state_province'],
+      ['do_not_import'],
+      // mapField Form expects all fields to be mapped.
+      ['do_not_import'],
+      ['do_not_import'],
+    ];
+    $csv = 'individual_country_state_county_with_related.csv';
+    $this->validateMultiRowCsv($csv, $mapper, 'error_value');
+
+    $this->importCSV($csv, $mapper);
+    $contacts = $this->getImportedContacts();
+    foreach ($contacts as $contact) {
+      $this->assertEquals(1013, $contact['address'][0]['country_id']);
+      $this->assertEquals(1640, $contact['address'][0]['state_province_id']);
+    }
+    $this->assertCount(2, $contacts);
+  }
+
   /**
    * Test date validation.
    *
@@ -1114,6 +1237,10 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
       ['5_a_b', 'organization_name'],
       ['contact_sub_type'],
       ['5_a_b', 'contact_sub_type'],
+      // mapField Form expects all fields to be mapped.
+      ['do_not_import'],
+      ['do_not_import'],
+      ['do_not_import'],
     ];
     $csv = 'individual_contact_sub_types.csv';
     $field = 'contact_sub_type';
@@ -1148,16 +1275,11 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
   /**
    * Test location importing, including for related contacts.
    *
-   * @throws \CRM_Core_Exception
    * @throws \API_Exception
    */
   public function testImportLocations(): void {
     $csv = 'individual_locations_with_related.csv';
-    $relationships = (array) RelationshipType::get()->addSelect('name_a_b', 'id')->addWhere('name_a_b', 'IN', [
-      'Child of',
-      'Sibling of',
-      'Employee of',
-    ])->execute()->indexBy('name_a_b');
+    $relationships = $this->getRelationships();
 
     $childKey = $relationships['Child of']['id'] . '_a_b';
     $siblingKey = $relationships['Sibling of']['id'] . '_a_b';
@@ -1207,7 +1329,7 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
       [$siblingKey, 'state_province', $homeID],
       [$siblingKey, 'email', $homeID],
       [$siblingKey, 'signature_text', $homeID],
-      [$childKey, 'im', $homeID, $skypeTypeID],
+      [$siblingKey, 'im', $homeID, $skypeTypeID],
       // The 2 is website_type_id (yes, small hard-coding cheat)
       [$siblingKey, 'url', $linkedInTypeID],
       [$siblingKey, 'phone', $workID, $phoneTypeID],
@@ -1221,8 +1343,36 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
       [$employeeKey, 'do_not_import'],
       // Second website, different type.
       [$employeeKey, 'url', $linkedInTypeID],
+      ['openid'],
     ];
     $this->validateCSV($csv, $mapper);
+
+    $this->importCSV($csv, $mapper);
+    $contacts = $this->getImportedContacts();
+    $this->assertCount(4, $contacts);
+    $this->assertCount(1, $contacts['Susie Jones']['phone']);
+    $this->assertEquals('123', $contacts['Susie Jones']['phone'][0]['phone_ext']);
+    $this->assertCount(2, $contacts['Mum Jones']['phone']);
+    $this->assertCount(1, $contacts['sis@example.com']['phone']);
+    $this->assertCount(0, $contacts['Soccer Superstars']['phone']);
+    $this->assertCount(1, $contacts['Susie Jones']['website']);
+    $this->assertCount(1, $contacts['Mum Jones']['website']);
+    $this->assertCount(0, $contacts['sis@example.com']['website']);
+    $this->assertCount(2, $contacts['Soccer Superstars']['website']);
+    $this->assertCount(1, $contacts['Susie Jones']['email']);
+    $this->assertEquals('Regards', $contacts['Susie Jones']['email'][0]['signature_text']);
+    $this->assertCount(1, $contacts['Mum Jones']['email']);
+    $this->assertCount(1, $contacts['sis@example.com']['email']);
+    $this->assertCount(1, $contacts['Soccer Superstars']['email']);
+    $this->assertCount(1, $contacts['Susie Jones']['im']);
+    $this->assertCount(1, $contacts['Mum Jones']['im']);
+    $this->assertCount(0, $contacts['sis@example.com']['im']);
+    $this->assertCount(0, $contacts['Soccer Superstars']['im']);
+    $this->assertCount(1, $contacts['Susie Jones']['address']);
+    $this->assertCount(1, $contacts['Mum Jones']['address']);
+    $this->assertCount(1, $contacts['sis@example.com']['address']);
+    $this->assertCount(1, $contacts['Soccer Superstars']['address']);
+    $this->assertCount(1, $contacts['Susie Jones']['openid']);
   }
 
   /**
@@ -1334,18 +1484,26 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
   }
 
   /**
-   * CRM-19888 default country should be used if ambigous.
+   * CRM-19888 default country should be used if ambiguous.
    *
+   * @throws \API_Exception
    * @throws \CRM_Core_Exception
+   * @throws \CiviCRM_API3_Exception
    */
   public function testImportAmbiguousStateCountry(): void {
     $this->callAPISuccess('Setting', 'create', ['defaultContactCountry' => 1228]);
     $countries = CRM_Core_PseudoConstant::country(FALSE, FALSE);
-    $this->callAPISuccess('Setting', 'create', ['countryLimit' => [array_search('United States', $countries), array_search('Guyana', $countries), array_search('Netherlands', $countries)]]);
-    $this->callAPISuccess('Setting', 'create', ['provinceLimit' => [array_search('United States', $countries), array_search('Guyana', $countries), array_search('Netherlands', $countries)]]);
-    $mapper = [0 => NULL, 1 => NULL, 2 => 'Primary', 3 => NULL];
+    $this->callAPISuccess('Setting', 'create', ['countryLimit' => [array_search('United States', $countries, TRUE), array_search('Guyana', $countries, TRUE), array_search('Netherlands', $countries, TRUE)]]);
+    $this->callAPISuccess('Setting', 'create', ['provinceLimit' => [array_search('United States', $countries, TRUE), array_search('Guyana', $countries, TRUE), array_search('Netherlands', $countries, TRUE)]]);
     [$contactValues] = $this->setUpBaseContact();
-    $fields = array_keys($contactValues);
+
+    // Set up the field mapping  - this looks like an array per mapping as saved in
+    // civicrm_mapping_field - eg ['name' => 'street_address', 'location_type_id' => 1],
+    $fieldMapping = [];
+    foreach (array_keys($contactValues) as $fieldName) {
+      $fieldMapping[] = ['name' => $fieldName];
+    }
+
     $addressValues = [
       'street_address' => 'PO Box 2716',
       'city' => 'Midway',
@@ -1353,23 +1511,23 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
       'postal_code' => 84049,
       'country' => 'United States',
     ];
-    $locationTypes = $this->callAPISuccess('Address', 'getoptions', ['field' => 'location_type_id']);
-    $locationTypes = $locationTypes['values'];
+
+    $homeLocationTypeID = CRM_Core_PseudoConstant::getKey('CRM_Core_BAO_Address', 'location_type_id', 'Home');
+    $workLocationTypeID = CRM_Core_PseudoConstant::getKey('CRM_Core_BAO_Address', 'location_type_id', 'Work');
     foreach ($addressValues as $field => $value) {
       $contactValues['home_' . $field] = $value;
-      $mapper[] = array_search('Home', $locationTypes);
       $contactValues['work_' . $field] = $value;
-      $mapper[] = array_search('Work', $locationTypes);
-      $fields[] = $field;
-      $fields[] = $field;
+      $fieldMapping[] = ['name' => $field, 'location_type_id' => $homeLocationTypeID];
+      $fieldMapping[] = ['name' => $field, 'location_type_id' => $workLocationTypeID];
     }
+    // The value is set to nothing to show it will be calculated.
     $contactValues['work_country'] = '';
 
-    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID, $mapper, $fields);
+    $this->runImport($contactValues, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Import_Parser::VALID, $fieldMapping);
     $addresses = $this->callAPISuccess('Address', 'get', ['contact_id' => ['>' => 2], 'sequential' => 1]);
     $this->assertEquals(2, $addresses['count']);
-    $this->assertEquals(array_search('United States', $countries), $addresses['values'][0]['country_id']);
-    $this->assertEquals(array_search('United States', $countries), $addresses['values'][1]['country_id']);
+    $this->assertEquals(array_search('United States', $countries, TRUE), $addresses['values'][0]['country_id']);
+    $this->assertEquals(array_search('United States', $countries, TRUE), $addresses['values'][1]['country_id']);
   }
 
   /**
@@ -1401,18 +1559,19 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
     $importer->import(CRM_Import_Parser::DUPLICATE_NOCHECK, $fields);
     $contact = $this->callAPISuccessGetSingle('Contact', ['last_name' => 'Texter']);
 
-    $this->assertEquals([4, 1], $contact['preferred_communication_method'], "Import multiple preferred communication methods using labels.");
-    $this->assertEquals(1, $contact['gender_id'], "Import gender with label.");
-    $this->assertEquals('da_DK', $contact['preferred_language'], "Import preferred language with label.");
+    $this->assertEquals([4, 1], $contact['preferred_communication_method'], 'Import multiple preferred communication methods using labels.');
+    $this->assertEquals(1, $contact['gender_id'], 'Import gender with label.');
+    $this->assertEquals('da_DK', $contact['preferred_language'], 'Import preferred language with label.');
+    $this->callAPISuccess('Contact', 'delete', ['id' => $contact['id']]);
 
     $importer = $processor->getImporterObject();
-    $fields = ['Ima', 'Texter', "4,1", "1", "da_DK"];
+    $fields = ['Ima', 'Texter', '4,1', '1', 'da_DK'];
     $importer->import(CRM_Import_Parser::DUPLICATE_NOCHECK, $fields);
     $contact = $this->callAPISuccessGetSingle('Contact', ['last_name' => 'Texter']);
 
-    $this->assertEquals([4, 1], $contact['preferred_communication_method'], "Import multiple preferred communication methods using values.");
-    $this->assertEquals(1, $contact['gender_id'], "Import gender with id.");
-    $this->assertEquals('da_DK', $contact['preferred_language'], "Import preferred language with value.");
+    $this->assertEquals([4, 1], $contact['preferred_communication_method'], 'Import multiple preferred communication methods using values.');
+    $this->assertEquals(1, $contact['gender_id'], 'Import gender with id.');
+    $this->assertEquals('da_DK', $contact['preferred_language'], 'Import preferred language with value.');
   }
 
   /**
@@ -1422,8 +1581,8 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
    *
    * @param int $onDuplicateAction
    * @param int $expectedResult
-   * @param array|null $mapperLocType
-   *   Array of location types that map to the input arrays.
+   * @param array|null $fieldMapping
+   *   Array of field mappings in the format used in civicrm_mapping_field.
    * @param array|null $fields
    *   Array of field names. Will be calculated from $originalValues if not passed in, but
    *   that method does not cope with duplicates.
@@ -1434,21 +1593,44 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
    * @throws \CRM_Core_Exception
    * @throws \CiviCRM_API3_Exception
    */
-  protected function runImport(array $originalValues, $onDuplicateAction, $expectedResult, $mapperLocType = [], $fields = NULL, int $ruleGroupId = NULL): void {
-    if (!$fields) {
-      $fields = array_keys($originalValues);
-    }
+  protected function runImport(array $originalValues, $onDuplicateAction, $expectedResult, $fieldMapping = [], $fields = NULL, int $ruleGroupId = NULL): void {
     $values = array_values($originalValues);
-    $mapper = [];
-    foreach ($fields as $index => $field) {
-      $mapper[] = [$field, $mapperLocType[$index] ?? NULL, $field === 'phone' ? 1 : NULL];
+    // Stand in for row number.
+    $values[] = 1;
+
+    if ($fieldMapping) {
+      $fields = [];
+      foreach ($fieldMapping as $mappedField) {
+        $fields[] = $mappedField['name'];
+      }
+      $mapper = $this->getMapperFromFieldMappingFormat($fieldMapping);
+    }
+    else {
+      if (!$fields) {
+        $fields = array_keys($originalValues);
+      }
+      $mapper = [];
+      foreach ($fields as $field) {
+        $mapper[] = [
+          $field,
+          in_array($field, ['phone', 'email'], TRUE) ? 'Primary' : NULL,
+          $field === 'phone' ? 1 : NULL,
+        ];
+      }
     }
     $userJobID = $this->getUserJobID(['mapper' => $mapper, 'onDuplicate' => $onDuplicateAction, 'dedupe_rule_id' => $ruleGroupId]);
     $parser = new CRM_Contact_Import_Parser_Contact($fields);
     $parser->setUserJobID($userJobID);
     $parser->_dedupeRuleGroupID = $ruleGroupId;
     $parser->init();
-    $this->assertEquals($expectedResult, $parser->import($onDuplicateAction, $values), 'Return code from parser import was not as expected');
+    $result = $parser->import($onDuplicateAction, $values);
+    $dataSource = new CRM_Import_DataSource_CSV($userJobID);
+    if ($result === FALSE && $expectedResult !== FALSE) {
+      // Import is moving away from returning a status - this is a better way to check
+      $this->assertGreaterThan(0, $dataSource->getRowCount([$expectedResult]));
+      return;
+    }
+    $this->assertEquals($expectedResult, $result, 'Return code from parser import was not as expected');
   }
 
   /**
@@ -1502,6 +1684,93 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
     ])->execute();
   }
 
+  /**
+   * @return array
+   * @throws \API_Exception
+   * @throws \Civi\API\Exception\UnauthorizedException
+   */
+  private function getRelationships(): array {
+    if (empty($this->relationships)) {
+      $this->relationships = (array) RelationshipType::get()
+        ->addSelect('name_a_b', 'id')
+        ->execute()
+        ->indexBy('name_a_b');
+    }
+    return $this->relationships;
+  }
+
+  /**
+   * Get the mapper array from the field mapping array format.
+   *
+   * The fieldMapping format is the same as the civicrm_mapping_field
+   * table and is readable  - eg ['name' => 'street_address', 'location_type_id' => 1].
+   *
+   * The mapper format is converted to the array that would be submitted by the form
+   * and is keyed by row number with the meaning of the fields depending on
+   * the selection.
+   *
+   * @param array $fieldMapping
+   *
+   * @return array
+   */
+  protected function getMapperFromFieldMappingFormat($fieldMapping): array {
+    $mapper = [];
+    foreach ($fieldMapping as $mapping) {
+      $mappedRow = [];
+      if (!empty($mapping['relationship_type_id'])) {
+        $mappedRow[] = $mapping['relationship_type_id'] . $mapping['relationship_direction'];
+      }
+      $mappedRow[] = $mapping['name'];
+      if (!empty($mapping['location_type_id'])) {
+        $mappedRow[] = $mapping['location_type_id'];
+      }
+      elseif (in_array($mapping['name'], ['email', 'phone'], TRUE)) {
+        // Lets make it easy on test writers by assuming primary if not specified.
+        $mappedRow[] = 'Primary';
+      }
+      if (!empty($mapping['im_provider_id'])) {
+        $mappedRow[] = $mapping['im_provider_id'];
+      }
+      if (!empty($mapping['phone_type_id'])) {
+        $mappedRow[] = $mapping['phone_type_id'];
+      }
+      if (!empty($mapping['website_type_id'])) {
+        $mappedRow[] = $mapping['website_type_id'];
+      }
+      $mapper[] = $mappedRow;
+    }
+    return $mapper;
+  }
+
+  /**
+   * Get a suitable mapper for the array with location defaults.
+   *
+   * This function is designed for when 'good assumptions' are required rather
+   * than careful mapping.
+   *
+   * @param array $contactValues
+   * @param string|int $defaultLocationType
+   *
+   * @return array
+   */
+  protected function getFieldMappingFromInput(array $contactValues, $defaultLocationType = 'Primary'): array {
+    $mapper = [];
+    foreach (array_keys($contactValues) as $fieldName) {
+      $mapping = ['name' => $fieldName];
+      $addressFields = $this->callAPISuccess('Address', 'getfields', [])['values'];
+      unset($addressFields['contact_id'], $addressFields['id'], $addressFields['location_type_id']);
+      $locationFields = array_merge(['email', 'phone', 'im', 'openid'], array_keys($addressFields));
+      if (in_array($fieldName, $locationFields, TRUE)) {
+        $mapping['location_type_id'] = $defaultLocationType;
+      }
+      if ($fieldName === 'phone') {
+        $mapping['phone_type_id'] = 1;
+      }
+      $mapper[] = $mapping;
+    }
+    return $mapper;
+  }
+
   /**
    * @param array $fields Array of fields to be imported
    * @param array $allfields Array of all fields which can be part of import
@@ -1564,44 +1833,89 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
     $this->assertEquals([
       'first_name' => 'Bob',
       'phone' => [
-        [
+        '1_1' => [
           'phone' => '123',
           'location_type_id' => 1,
           'phone_type_id' => 1,
         ],
       ],
-      '5_a_b' => [
-        'contact_type' => 'Organization',
-        'url' =>
-          [
-
-            [
+      'relationship' => [
+        '5_a_b' => [
+          'contact_type' => 'Organization',
+          'contact_sub_type' => NULL,
+          'website' => [
+            'https://example.org' => [
               'url' => 'https://example.org',
               'website_type_id' => 1,
             ],
           ],
-        'phone' =>
-          [
-            [
+          'phone' => [
+            '1_1' => [
               'phone' => '456',
               'location_type_id' => 1,
               'phone_type_id' => 1,
             ],
           ],
+        ],
       ],
-      'im' =>
-        [
-
-          [
-            'im' => 'my-handle',
-            'location_type_id' => 1,
-            'provider_id' => 1,
-          ],
+      'im' => [
+        '1_1' => [
+          'name' => 'my-handle',
+          'location_type_id' => 1,
+          'provider_id' => 1,
         ],
+      ],
       'contact_type' => 'Individual',
     ], $params);
   }
 
+  /**
+   * Test that import parser will not match the imported primary to
+   * an existing contact via the related contacts fields.
+   *
+   * Currently fails because CRM_Dedupe_Finder::formatParams($input, $contactType);
+   * called in getDuplicateContacts flattens the contact array adding the
+   * related contacts values to the primary contact.
+   *
+   * https://github.com/civicrm/civicrm-core/blob/ca13ec46eae2042604e4e106c6cb3dc0439db3e2/CRM/Dedupe/Finder.php#L238
+   *
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   * @throws \CiviCRM_API3_Exception
+   * @throws \Civi\API\Exception\UnauthorizedException
+   */
+  public function testImportParserDoesNotMatchPrimaryToRelated(): void {
+    $this->individualCreate([
+      'first_name' => 'Bob',
+      'last_name' => 'Dobbs',
+      'email' => 'tim.cook@apple.com',
+    ]);
+
+    $mapper = [
+      ['first_name'],
+      ['last_name'],
+      ['1_a_b', 'email'],
+    ];
+    $values = ['Alok', 'Patel', 'tim.cook@apple.com', 1];
+
+    $userJobID = $this->getUserJobID([
+      'mapper' => $mapper,
+      'onDuplicate' => CRM_Import_Parser::DUPLICATE_UPDATE,
+    ]);
+
+    $parser = new CRM_Contact_Import_Parser_Contact();
+    $parser->setUserJobID($userJobID);
+    $parser->init();
+    $parser->import(CRM_Import_Parser::DUPLICATE_UPDATE, $values);
+    $this->callAPISuccessGetSingle('Contact', [
+      'first_name' => 'Bob',
+      'last_name' => 'Dobbs',
+      'email' => 'tim.cook@apple.com',
+    ]);
+    $contact = $this->callAPISuccessGetSingle('Contact', ['first_name' => 'Alok', 'last_name' => 'Patel']);
+    $this->assertEmpty($contact['email']);
+  }
+
   /**
    * Set up the underlying contact.
    *
@@ -1655,6 +1969,23 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
     return $userJobID;
   }
 
+  /**
+   * Test geocode validation.
+   *
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   */
+  public function testImportGeocodes(): void {
+    $mapper = [
+      ['first_name'],
+      ['last_name'],
+      ['geo_code_1', 1],
+      ['geo_code_2', 1],
+    ];
+    $csv = 'individual_geocode.csv';
+    $this->validateMultiRowCsv($csv, $mapper, 'GeoCode2');
+  }
+
   /**
    * Validate the csv file values.
    *
@@ -1746,4 +2077,66 @@ class CRM_Contact_Import_Parser_ContactTest extends CiviUnitTestCase {
     }
   }
 
+  /**
+   * Get the contacts we imported (Susie Jones & family).
+   *
+   * @return array
+   * @throws \API_Exception
+   */
+  public function getImportedContacts(): array {
+    return (array) Contact::get()
+      ->addWhere('display_name', 'IN', [
+        'Susie Jones',
+        'Mum Jones',
+        'sis@example.com',
+        'Soccer Superstars',
+      ])
+      ->addChain('phone', Phone::get()->addWhere('contact_id', '=', '$id'))
+      ->addChain('address', Address::get()->addWhere('contact_id', '=', '$id'))
+      ->addChain('website', Website::get()->addWhere('contact_id', '=', '$id'))
+      ->addChain('im', IM::get()->addWhere('contact_id', '=', '$id'))
+      ->addChain('email', Email::get()->addWhere('contact_id', '=', '$id'))
+      ->addChain('openid', OpenID::get()->addWhere('contact_id', '=', '$id'))
+      ->execute()->indexBy('display_name');
+  }
+
+  /**
+   * Test that import parser will not throw error if Related Contact is not found via passed in External ID.
+   *
+   * If the organization is present it will create it - otherwise fail without error.
+   *
+   * @dataProvider getBooleanDataProvider
+   *
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   * @throws \CiviCRM_API3_Exception
+   */
+  public function testImportParserWithExternalIdForRelationship(bool $isOrganizationProvided): void {
+    $contactImportValues = [
+      'first_name' => 'Alok',
+      'last_name' => 'Patel',
+      'Employee of' => 'related external identifier',
+      'organization_name' => $isOrganizationProvided ? 'Big shop' : '',
+    ];
+
+    $mapper = [
+      ['first_name'],
+      ['last_name'],
+      ['5_a_b', 'external_identifier'],
+      ['5_a_b', 'organization_name'],
+    ];
+    $fields = array_keys($contactImportValues);
+    $values = array_values($contactImportValues);
+    $userJobID = $this->getUserJobID([
+      'mapper' => $mapper,
+    ]);
+
+    $parser = new CRM_Contact_Import_Parser_Contact($fields);
+    $parser->setUserJobID($userJobID);
+    $parser->init();
+
+    $parser->import(CRM_Import_Parser::DUPLICATE_UPDATE, $values);
+    $this->callAPISuccessGetCount('Contact', ['organization_name' => 'Big shop'], $isOrganizationProvided ? 2 : 0);
+  }
+
 }
index ef8a9946193cbbaccc4f91fe8e67250a967a579f..a89538d620e277dc9b4e781a160dba50cc8f1e1e 100644 (file)
@@ -469,7 +469,7 @@ class CRM_Contact_SelectorTest extends CiviUnitTestCase {
           'includeContactIds' => NULL,
           'searchDescendentGroups' => FALSE,
           'expected_query' => [
-            0 => 'SELECT contact_a.id as contact_id, contact_a.contact_type as `contact_type`, contact_a.contact_sub_type as `contact_sub_type`, contact_a.sort_name as `sort_name`, contact_a.display_name as `display_name`, contact_a.do_not_email as `do_not_email`, contact_a.do_not_phone as `do_not_phone`, contact_a.do_not_mail as `do_not_mail`, contact_a.do_not_sms as `do_not_sms`, contact_a.do_not_trade as `do_not_trade`, contact_a.is_opt_out as `is_opt_out`, contact_a.legal_identifier as `legal_identifier`, contact_a.external_identifier as `external_identifier`, contact_a.nick_name as `nick_name`, contact_a.legal_name as `legal_name`, contact_a.image_URL as `image_URL`, contact_a.preferred_communication_method as `preferred_communication_method`, contact_a.preferred_language as `preferred_language`, contact_a.preferred_mail_format as `preferred_mail_format`, contact_a.first_name as `first_name`, contact_a.middle_name as `middle_name`, contact_a.last_name as `last_name`, contact_a.prefix_id as `prefix_id`, contact_a.suffix_id as `suffix_id`, contact_a.formal_title as `formal_title`, contact_a.communication_style_id as `communication_style_id`, contact_a.job_title as `job_title`, contact_a.gender_id as `gender_id`, contact_a.birth_date as `birth_date`, contact_a.is_deceased as `is_deceased`, contact_a.deceased_date as `deceased_date`, contact_a.household_name as `household_name`, IF ( contact_a.contact_type = \'Individual\', NULL, contact_a.organization_name ) as organization_name, contact_a.sic_code as `sic_code`, contact_a.is_deleted as `contact_is_deleted`, IF ( contact_a.contact_type = \'Individual\', contact_a.organization_name, NULL ) as current_employer, civicrm_address.id as address_id, civicrm_address.street_address as `street_address`, civicrm_address.supplemental_address_1 as `supplemental_address_1`, civicrm_address.supplemental_address_2 as `supplemental_address_2`, civicrm_address.supplemental_address_3 as `supplemental_address_3`, civicrm_address.city as `city`, civicrm_address.postal_code_suffix as `postal_code_suffix`, civicrm_address.postal_code as `postal_code`, civicrm_address.geo_code_1 as `geo_code_1`, civicrm_address.geo_code_2 as `geo_code_2`, civicrm_address.state_province_id as state_province_id, civicrm_address.country_id as country_id, civicrm_phone.id as phone_id, civicrm_phone.phone_type_id as phone_type_id, civicrm_phone.phone as `phone`, civicrm_email.id as email_id, civicrm_email.email as `email`, civicrm_email.on_hold as `on_hold`, civicrm_im.id as im_id, civicrm_im.provider_id as provider_id, civicrm_im.name as `im`, civicrm_worldregion.id as worldregion_id, civicrm_worldregion.name as `world_region`',
+            0 => 'SELECT contact_a.id as contact_id, contact_a.contact_type as `contact_type`, contact_a.contact_sub_type as `contact_sub_type`, contact_a.sort_name as `sort_name`, contact_a.display_name as `display_name`, contact_a.do_not_email as `do_not_email`, contact_a.do_not_phone as `do_not_phone`, contact_a.do_not_mail as `do_not_mail`, contact_a.do_not_sms as `do_not_sms`, contact_a.do_not_trade as `do_not_trade`, contact_a.is_opt_out as `is_opt_out`, contact_a.legal_identifier as `legal_identifier`, contact_a.external_identifier as `external_identifier`, contact_a.nick_name as `nick_name`, contact_a.legal_name as `legal_name`, contact_a.image_URL as `image_URL`, contact_a.preferred_communication_method as `preferred_communication_method`, contact_a.preferred_language as `preferred_language`, contact_a.first_name as `first_name`, contact_a.middle_name as `middle_name`, contact_a.last_name as `last_name`, contact_a.prefix_id as `prefix_id`, contact_a.suffix_id as `suffix_id`, contact_a.formal_title as `formal_title`, contact_a.communication_style_id as `communication_style_id`, contact_a.job_title as `job_title`, contact_a.gender_id as `gender_id`, contact_a.birth_date as `birth_date`, contact_a.is_deceased as `is_deceased`, contact_a.deceased_date as `deceased_date`, contact_a.household_name as `household_name`, IF ( contact_a.contact_type = \'Individual\', NULL, contact_a.organization_name ) as organization_name, contact_a.sic_code as `sic_code`, contact_a.is_deleted as `contact_is_deleted`, IF ( contact_a.contact_type = \'Individual\', contact_a.organization_name, NULL ) as current_employer, civicrm_address.id as address_id, civicrm_address.street_address as `street_address`, civicrm_address.supplemental_address_1 as `supplemental_address_1`, civicrm_address.supplemental_address_2 as `supplemental_address_2`, civicrm_address.supplemental_address_3 as `supplemental_address_3`, civicrm_address.city as `city`, civicrm_address.postal_code_suffix as `postal_code_suffix`, civicrm_address.postal_code as `postal_code`, civicrm_address.geo_code_1 as `geo_code_1`, civicrm_address.geo_code_2 as `geo_code_2`, civicrm_address.state_province_id as state_province_id, civicrm_address.country_id as country_id, civicrm_phone.id as phone_id, civicrm_phone.phone_type_id as phone_type_id, civicrm_phone.phone as `phone`, civicrm_email.id as email_id, civicrm_email.email as `email`, civicrm_email.on_hold as `on_hold`, civicrm_im.id as im_id, civicrm_im.provider_id as provider_id, civicrm_im.name as `im`, civicrm_worldregion.id as worldregion_id, civicrm_worldregion.name as `world_region`',
             2 => 'WHERE displayRelType.relationship_type_id = 1
 AND   displayRelType.is_active = 1
 AND ( 1 ) AND (contact_a.is_deleted = 0)',
@@ -736,7 +736,7 @@ AND ( 1 ) AND (contact_a.is_deleted = 0)',
       . ' contact_a.do_not_sms  as `do_not_sms`, contact_a.do_not_trade as `do_not_trade`, contact_a.is_opt_out  as `is_opt_out`, contact_a.legal_identifier  as `legal_identifier`,'
       . ' contact_a.external_identifier  as `external_identifier`, contact_a.nick_name  as `nick_name`, contact_a.legal_name  as `legal_name`, contact_a.image_URL  as `image_URL`,'
       . ' contact_a.preferred_communication_method  as `preferred_communication_method`, contact_a.preferred_language  as `preferred_language`,'
-      . ' contact_a.preferred_mail_format  as `preferred_mail_format`, contact_a.first_name  as `first_name`, contact_a.middle_name  as `middle_name`, contact_a.last_name  as `last_name`,'
+      . ' contact_a.first_name  as `first_name`, contact_a.middle_name  as `middle_name`, contact_a.last_name  as `last_name`,'
       . ' contact_a.prefix_id  as `prefix_id`, contact_a.suffix_id  as `suffix_id`, contact_a.formal_title  as `formal_title`, contact_a.communication_style_id  as `communication_style_id`,'
       . ' contact_a.job_title  as `job_title`, contact_a.gender_id  as `gender_id`, contact_a.birth_date  as `birth_date`, contact_a.is_deceased  as `is_deceased`,'
       . ' contact_a.deceased_date  as `deceased_date`, contact_a.household_name  as `household_name`,'
index 44c47552be1686495ef12a9bbb6b7a5c922f2791..9ce73d2f0dd0f03007d5ed4e6476639bbda13bda 100644 (file)
@@ -41,24 +41,50 @@ class CRM_Contribute_ActionMapping_ByTypeTest extends \Civi\ActionSchedule\Abstr
   public function createTestCases() {
     $cs = [];
 
-    // FIXME: CRM-19415: The right email content goes out, but it appears that the dates are incorrect.
-    //    $cs[] = array(
-    //      '2015-02-01 00:00:00',
-    //      'addAliceDues scheduleForAny startOnTime useHelloFirstName alsoRecipientBob',
-    //      array(
-    //        array(
-    //          'time' => '2015-02-01 00:00:00',
-    //          'to' => array('alice@example.org'),
-    //          'subject' => '/Hello, Alice.*via subject/',
-    //        ),
-    //        array(
-    //          'time' => '2015-02-01 00:00:00',
-    //          'to' => array('bob@example.org'),
-    //          'subject' => '/Hello, Bob.*via subject/',
-    //          // It might make more sense to get Alice's details... but path of least resistance...
-    //        ),
-    //      ),
-    //    );
+    $cs[] = [
+      '2015-02-01 00:00:00',
+      'addAliceDues scheduleForAny startOnTime useHelloFirstNameStatus alsoRecipientBob',
+      [
+        [
+          'time' => '2015-01-20 00:00:00',
+          'to' => ['bob@example.org'],
+          'subject' => '/Hello, Bob. @. \(via subject\)/',
+          // I'm not sure this behavior is what I would expect.
+          // - INTUITION: As someone browsing the admin UI, my guess is that "Also Include" behaves like a "CC"
+          //   (where Alice's data drives the notification, and Bob gets a copy of the message).
+          // - REALITY: The "also include" recipient, Bob, is treated as a recipient on day #1 (even
+          //   before any reminder becomes ripe for the organic recipient, Alice). The `{contact.*}`
+          //   details are filled in with Bob's information. In effect, Bob gets an early/preview
+          //   message that hints at how messages will look for Alice. However, Bob doesn't have
+          //   a contribution record, so some tokens (`{contribution.contribution_status_id:name}`)
+          //   don't work.
+          // - WHAT SHOULD IT DO: I'm not sure. The reality seems quirky and vaguely broken.
+          //   The CC behavior would be more "clearly defined" IMHO. OTOH, CC would also be more noisy.
+          //   The present behavior (early/preview message) maybe serves a different+valid business-need,
+          //   but the problems+limits seem essential.
+        ],
+        [
+          'time' => '2015-02-01 00:00:00',
+          'to' => ['alice@example.org'],
+          'subject' => '/Hello, Alice. @Completed. \(via subject\)/',
+        ],
+      ],
+    ];
+
+    $cs[] = [
+      '2015-02-01 00:00:00',
+      'scheduleForAny startOnTime useHelloFirstNameStatus alsoRecipientBob',
+      [
+        [
+          'time' => '2015-01-20 00:00:00',
+          'to' => ['bob@example.org'],
+          'subject' => '/Hello, Bob. @. \(via subject\)/',
+          // This is consistent with example+analysis above - The "Also Include" recipient gets
+          // an early/preview message without `{contribution.*}` tokens. This may be good or bad behavior.
+          // The test helps to show what the behavior is.
+        ],
+      ],
+    ];
 
     $cs[] = [
       '2015-02-01 00:00:00',
index 03ff2e181d51306d368346c6b17017d7b8ad0da0..e89dc64557721ff3d4a6d7720850a1d8b1e4454e 100644 (file)
@@ -7,6 +7,7 @@
 use Civi\Api4\Contribution;
 use Civi\Api4\ContributionSoft;
 use Civi\Api4\OptionValue;
+use Civi\Api4\UserJob;
 
 /**
  *  Test Contribution import parser.
@@ -64,13 +65,17 @@ class CRM_Contribute_Import_Parser_ContributionTest extends CiviUnitTestCase {
     $contact2Id = $this->individualCreate($contact2Params);
     $values = [
       'total_amount' => $this->formatMoneyInput(1230.99),
-      'financial_type' => 'Donation',
+      'financial_type_id' => 'Donation',
       'external_identifier' => 'ext-1',
       'soft_credit' => 'ext-2',
     ];
-    $mapperSoftCredit = [NULL, NULL, NULL, 'external_identifier'];
-    $mapperSoftCreditType = [NULL, NULL, NULL, '1'];
-    $this->runImport($values, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Contribute_Import_Parser_Contribution::SOFT_CREDIT, $mapperSoftCredit, NULL, $mapperSoftCreditType);
+    $mapping = [
+      ['name' => 'total_amount'],
+      ['name' => 'financial_type_id'],
+      ['name' => 'external_identifier'],
+      ['name' => 'soft_credit', 'soft_credit_type_id' => 1, 'soft_credit_match_field' => 'external_identifier'],
+    ];
+    $this->runImport($values, CRM_Import_Parser::DUPLICATE_UPDATE, CRM_Contribute_Import_Parser_Contribution::SOFT_CREDIT, $mapping);
 
     $contributionsOfMainContact = Contribution::get()->addWhere('contact_id', '=', $contact1Id)->execute();
     $this->assertCount(1, $contributionsOfMainContact, 'Contribution not added for primary contact');
@@ -108,12 +113,12 @@ class CRM_Contribute_Import_Parser_ContributionTest extends CiviUnitTestCase {
     $this->addRandomOption();
     $contactID = $this->individualCreate();
 
-    $values = ['contribution_contact_id' => $contactID, 'total_amount' => 10, 'financial_type' => 'Donation', 'payment_instrument' => 'Check'];
+    $values = ['contribution_contact_id' => $contactID, 'total_amount' => 10, 'financial_type_id' => 'Donation', 'payment_instrument_id' => 'Check'];
     $this->runImport($values, CRM_Import_Parser::DUPLICATE_UPDATE, NULL);
     $contribution = $this->callAPISuccessGetSingle('Contribution', ['contact_id' => $contactID]);
     $this->assertEquals('Check', $contribution['payment_instrument']);
 
-    $values = ['contribution_contact_id' => $contactID, 'total_amount' => 10, 'financial_type' => 'Donation', 'payment_instrument' => 'not at all random'];
+    $values = ['contribution_contact_id' => $contactID, 'total_amount' => 10, 'financial_type_id' => 'Donation', 'payment_instrument_id' => 'not at all random'];
     $this->runImport($values, CRM_Import_Parser::DUPLICATE_UPDATE, NULL);
     $contribution = $this->callAPISuccessGetSingle('Contribution', ['contact_id' => $contactID, 'payment_instrument_id' => 'random']);
     $this->assertEquals('not at all random', $contribution['payment_instrument']);
@@ -124,7 +129,7 @@ class CRM_Contribute_Import_Parser_ContributionTest extends CiviUnitTestCase {
    */
   public function testContributionStatusLabel(): void {
     $contactID = $this->individualCreate();
-    $values = ['contribution_contact_id' => $contactID, 'total_amount' => 10, 'financial_type' => 'Donation', 'payment_instrument' => 'Check', 'contribution_status_id' => 'Pending'];
+    $values = ['contribution_contact_id' => $contactID, 'total_amount' => 10, 'financial_type_id' => 'Donation', 'payment_instrument_id' => 'Check', 'contribution_status_id' => 'Pending'];
     // Note that the expected result should logically be CRM_Import_Parser::valid but writing test to reflect not fix here
     $this->runImport($values, CRM_Import_Parser::DUPLICATE_UPDATE, NULL);
     $contribution = $this->callAPISuccessGetSingle('Contribution', ['contact_id' => $contactID]);
@@ -172,7 +177,7 @@ class CRM_Contribute_Import_Parser_ContributionTest extends CiviUnitTestCase {
 
   public function testParsedCustomOption(): void {
     $contactID = $this->individualCreate();
-    $values = ['contribution_contact_id' => $contactID, 'total_amount' => 10, 'financial_type' => 'Donation', 'payment_instrument' => 'Check', 'contribution_status_id' => 'Pending'];
+    $values = ['contribution_contact_id' => $contactID, 'total_amount' => 10, 'financial_type_id' => 'Donation', 'payment_instrument_id' => 'Check', 'contribution_status_id' => 'Pending'];
     // Note that the expected result should logically be CRM_Import_Parser::valid but writing test to reflect not fix here
     $this->runImport($values, CRM_Import_Parser::DUPLICATE_UPDATE, NULL);
     $contribution = $this->callAPISuccess('Contribution', 'getsingle', ['contact_id' => $contactID]);
@@ -227,7 +232,7 @@ class CRM_Contribute_Import_Parser_ContributionTest extends CiviUnitTestCase {
     $this->createCustomGroupWithFieldOfType([], 'checkbox');
     $customField = $this->getCustomFieldName('checkbox');
     $contactID = $this->individualCreate();
-    $values = ['contribution_contact_id' => $contactID, 'total_amount' => 10, 'financial_type' => 'Donation', $customField => 'L,V'];
+    $values = ['contribution_contact_id' => $contactID, 'total_amount' => 10, 'financial_type_id' => 'Donation', $customField => 'L,V'];
     $this->runImport($values, CRM_Import_Parser::DUPLICATE_SKIP, NULL);
     $initialContribution = $this->callAPISuccessGetSingle('Contribution', ['contact_id' => $contactID]);
     $this->assertContains('L', $initialContribution[$customField], "Contribution Duplicate Skip Import contains L");
@@ -251,23 +256,75 @@ class CRM_Contribute_Import_Parser_ContributionTest extends CiviUnitTestCase {
    *
    * @param int $onDuplicateAction
    * @param int|null $expectedResult
-   * @param array|null $mapperSoftCredit
-   * @param array|null $mapperPhoneType
-   * @param array|null $mapperSoftCreditType
+   * @param array|null $mappings
    * @param array|null $fields
    *   Array of field names. Will be calculated from $originalValues if not passed in.
    */
-  protected function runImport(array $originalValues, int $onDuplicateAction, ?int $expectedResult, array $mapperSoftCredit = NULL, array $mapperPhoneType = NULL, array $mapperSoftCreditType = NULL, array $fields = NULL): void {
+  protected function runImport(array $originalValues, int $onDuplicateAction, ?int $expectedResult, array $mappings = [], array $fields = NULL): void {
     if (!$fields) {
       $fields = array_keys($originalValues);
     }
+    $mapper = [];
+    if ($mappings) {
+      foreach ($mappings as $mapping) {
+        $fieldInput = [$mapping['name']];
+        if (!empty($mapping['soft_credit_type_id'])) {
+          $fieldInput[1] = $mapping['soft_credit_match_field'];
+          $fieldInput[2] = $mapping['soft_credit_type_id'];
+        }
+        $mapper[] = $fieldInput;
+      }
+    }
+    else {
+      foreach ($fields as $field) {
+        $mapper[] = [$field];
+      }
+    }
     $values = array_values($originalValues);
-    $parser = new CRM_Contribute_Import_Parser_Contribution($fields, $mapperSoftCredit, $mapperPhoneType, $mapperSoftCreditType);
-    $parser->_contactType = 'Individual';
+    $parser = new CRM_Contribute_Import_Parser_Contribution($fields);
+    $parser->setUserJobID($this->getUserJobID([
+      'onDuplicate' => $onDuplicateAction,
+      'mapper' => $mapper,
+    ]));
     $parser->init();
+
     $this->assertEquals($expectedResult, $parser->import($onDuplicateAction, $values), 'Return code from parser import was not as expected');
   }
 
+  /**
+   * @param array $submittedValues
+   *
+   * @return array
+   *
+   * @throws \API_Exception
+   * @throws \CRM_Core_Exception
+   */
+  protected function getUserJobID(array $submittedValues = []): int {
+    $userJobID = UserJob::create()->setValues([
+      'metadata' => [
+        'submitted_values' => array_merge([
+          'contactType' => CRM_Import_Parser::CONTACT_INDIVIDUAL,
+          'contactSubType' => '',
+          'dataSource' => 'CRM_Import_DataSource_SQL',
+          'sqlQuery' => 'SELECT first_name FROM civicrm_contact',
+          'onDuplicate' => CRM_Import_Parser::DUPLICATE_SKIP,
+          'dedupe_rule_id' => NULL,
+          'dateFormats' => CRM_Core_Form_Date::DATE_yyyy_mm_dd,
+        ], $submittedValues),
+      ],
+      'status_id:name' => 'draft',
+      'type_id:name' => 'contact_import',
+    ])->execute()->first()['id'];
+    if ($submittedValues['dataSource'] ?? NULL === 'CRM_Import_DataSource') {
+      $dataSource = new CRM_Import_DataSource_CSV($userJobID);
+    }
+    else {
+      $dataSource = new CRM_Import_DataSource_SQL($userJobID);
+    }
+    $dataSource->initialize();
+    return $userJobID;
+  }
+
   /**
    * Add a random extra option value
    *
diff --git a/tests/phpunit/CRM/Contribute/Import/Parser/data/contributions.CSV b/tests/phpunit/CRM/Contribute/Import/Parser/data/contributions.CSV
deleted file mode 100644 (file)
index ba8175d..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-External Identifier,Total Amount,Receive Date,Financial Type,Soft Credit to
-bob,65,2008-09-20,Donation,mum@example.com
diff --git a/tests/phpunit/CRM/Contribute/Import/Parser/data/contributions.txt b/tests/phpunit/CRM/Contribute/Import/Parser/data/contributions.txt
deleted file mode 100644 (file)
index ba8175d..0000000
+++ /dev/null
@@ -1,2 +0,0 @@
-External Identifier,Total Amount,Receive Date,Financial Type,Soft Credit to
-bob,65,2008-09-20,Donation,mum@example.com
index ea36a16eb550f832a9fafbfb151947470e724326..3c32268ceab817be8a1b9dfe01a479e353d7f1b2 100644 (file)
@@ -158,7 +158,6 @@ class CRM_Dedupe_BAO_RuleGroupTest extends CiviUnitTestCase {
           'postal_greeting_custom' => 'Postal Greeting Custom',
           'preferred_communication_method' => 'Preferred Communication Method',
           'preferred_language' => 'Preferred Language',
-          'preferred_mail_format' => 'Preferred Mail Format',
           'sic_code' => 'Sic Code',
           'user_unique_id' => 'Unique ID (OpenID)',
           'sort_name' => 'Sort Name',
index fae48c45960c5047f88e606e6e2732761c8d09c5..3403c2245820f275a26a7396bdbf327b8f61f286 100644 (file)
@@ -7,6 +7,8 @@
  */
 class CRM_Dedupe_MergerTest extends CiviUnitTestCase {
 
+  use CRMTraits_Custom_CustomDataTrait;
+
   protected $_groupId;
 
   protected $_contactIds = [];
@@ -705,7 +707,7 @@ class CRM_Dedupe_MergerTest extends CiviUnitTestCase {
    * @throws \CRM_Core_Exception
    * @throws \CiviCRM_API3_Exception
    */
-  public function testGetRowsElementsAndInfoSpecialInfo() {
+  public function testGetRowsElementsAndInfoSpecialInfo(): void {
     $contact1 = $this->individualCreate([
       'preferred_communication_method' => [],
       'communication_style_id' => 'Familiar',
@@ -1470,6 +1472,33 @@ WHERE
     $this->callAPISuccess('Contact', 'merge', ['to_keep_id' => $contact1, 'to_remove_id' => $contact2]);
   }
 
+  /**
+   * Test that a custom field attached to the relationship does not block merge.
+   *
+   * @throws \CRM_Core_Exception
+   */
+  public function testMergeWithRelationshipWithCustomFields(): void {
+    $contact1 = $this->individualCreate();
+    $this->createCustomGroupWithFieldsOfAllTypes(['extends' => 'Relationship']);
+    $contact2 = $this->createContactWithEmployerRelationship([
+      $this->getCustomFieldName('text') => 'blah',
+      $this->getCustomFieldName('boolean') => TRUE,
+    ]);
+    $this->callAPISuccess('Contact', 'merge', ['to_keep_id' => $contact1, 'to_remove_id' => $contact2]);
+    $this->callAPISuccessGetSingle('Relationship', [
+      'contact_id_a' => $contact1,
+    ]);
+
+    $contact2 = $this->createContactWithEmployerRelationship([
+      $this->getCustomFieldName('boolean') => TRUE,
+      $this->getCustomFieldName('text') => '',
+    ]);
+    $this->callAPISuccess('Contact', 'merge', ['to_keep_id' => $contact2, 'to_remove_id' => $contact1]);
+    $this->callAPISuccessGetSingle('Relationship', [
+      'contact_id_a' => $contact2,
+    ]);
+  }
+
   /**
    * Implements hook_civicrm_entityTypes().
    *
@@ -1496,4 +1525,24 @@ WHERE
     $links[] = new CRM_Core_Reference_Basic('civicrm_im', 'name', 'civicrm_contact', 'first_name');
   }
 
+  /**
+   * Create an individual with a relationship of type employee.
+   *
+   * @param array $params
+   *
+   * @return int
+   * @throws \CRM_Core_Exception
+   */
+  protected function createContactWithEmployerRelationship(array $params): int {
+    $contact2 = $this->individualCreate();
+    // Test the merge can also happen if the other contact has an empty text field.
+    $this->callAPISuccess('Relationship', 'create', array_merge([
+      'contact_id_a' => $contact2,
+      'contact_id_b' => CRM_Core_BAO_Domain::getDomain()->contact_id,
+      'relationship_type_id' => 'Employee of',
+      'is_current_employer' => TRUE,
+    ], $params));
+    return $contact2;
+  }
+
 }
index 1accc8e262596c595e6009a4456dbdb879769d60..37c649339d7f2ef2f8371a22672955dbda90ef18 100644 (file)
@@ -217,6 +217,159 @@ class CRM_Event_Form_Registration_ConfirmTest extends CiviUnitTestCase {
     $mut->clearMessages();
   }
 
+  /**
+   * Tests missing contactID when registering for paid event from waitlist
+   * https://github.com/civicrm/civicrm-core/pull/23358, https://lab.civicrm.org/extensions/stripe/-/issues/347
+   *
+   * @throws \CiviCRM_API3_Exception
+   */
+  public function testWaitlistRegistrationContactIdParam() {
+    // @todo - figure out why this doesn't pass validate financials
+    $this->isValidateFinancialsOnPostAssert = FALSE;
+    $paymentProcessorID = $this->processorCreate();
+    /* @var \CRM_Core_Payment_Dummy $processor */
+    $processor = Civi\Payment\System::singleton()->getById($paymentProcessorID);
+    $processor->setDoDirectPaymentResult(['fee_amount' => 1.67]);
+    $params = ['is_monetary' => 1, 'financial_type_id' => 1];
+    $event = $this->eventCreatePaid($params, [['name' => 'test', 'amount' => 8000.67]]);
+    $individualID = $this->individualCreate();
+    //$this->submitForm($event['id'], [
+    $form = CRM_Event_Form_Registration_Confirm::testSubmit([
+      'id' => $event['id'],
+      'contributeMode' => 'direct',
+      'registerByID' => $individualID,
+      'paymentProcessorObj' => CRM_Financial_BAO_PaymentProcessor::getPayment($paymentProcessorID),
+      'amount' => 8000.67,
+      'amount_level' => '\ 1Tiny-tots (ages 5-8) - 1\ 1',
+      'params' => [
+        [
+          'qfKey' => 'e6eb2903eae63d4c5c6cc70bfdda8741_2801',
+          'entryURL' => 'http://dmaster.local/civicrm/event/register?reset=1&amp;id=3',
+          'first_name' => 'k',
+          'last_name' => 'p',
+          'email-Primary' => 'demo@example.com',
+          'hidden_processor' => '1',
+          'credit_card_number' => '4111111111111111',
+          'cvv2' => '123',
+          'credit_card_exp_date' => [
+            'M' => '1',
+            'Y' => date('Y') + 1,
+          ],
+          'credit_card_type' => 'Visa',
+          'billing_first_name' => 'p',
+          'billing_middle_name' => '',
+          'billing_last_name' => 'p',
+          'billing_street_address-5' => 'p',
+          'billing_city-5' => 'p',
+          'billing_state_province_id-5' => '1061',
+          'billing_postal_code-5' => '7',
+          'billing_country_id-5' => '1228',
+          'priceSetId' => '6',
+          'price_7' => [
+            13 => 1,
+          ],
+          'payment_processor_id' => $paymentProcessorID,
+          'bypass_payment' => '',
+          'is_primary' => 1,
+          'is_pay_later' => 0,
+          'contact_id' => $individualID,
+          'campaign_id' => NULL,
+          'defaultRole' => 1,
+          'participant_role_id' => '1',
+          'currencyID' => 'USD',
+          'amount_level' => '\ 1Tiny-tots (ages 5-8) - 1\ 1',
+          'amount' => $this->formatMoneyInput(8000.67),
+          'tax_amount' => NULL,
+          'year' => '2019',
+          'month' => '1',
+          'ip_address' => '127.0.0.1',
+          'invoiceID' => '57adc34957a29171948e8643ce906332',
+          'button' => '_qf_Register_upload',
+          'billing_state_province-5' => 'AP',
+          'billing_country-5' => 'US',
+        ],
+      ],
+    ]);
+    $this->callAPISuccessGetCount('Participant', [], 1);
+
+    $value = $form->get('value');
+    $this->assertArrayHasKey('contact_id', $value, 'contact_id missing in $value array');
+    $this->assertEquals($value['contact_id'], $individualID, 'Invalid contact_id in $value array.');
+
+    // Add someone to the waitlist.
+    $waitlistContactId = $this->individualCreate();
+    $waitlistContact   = $this->callAPISuccess('Contact', 'getsingle', ['id' => $waitlistContactId]);
+    $waitlistParticipantId = $this->participantCreate(['event_id' => $event['id'], 'contact_id' => $waitlistContactId, 'status_id' => 'On waitlist']);
+
+    $waitlistParticipant = $this->callAPISuccess('Participant', 'getsingle', ['id' => $waitlistParticipantId, 'return' => ["participant_status"]]);
+    $this->assertEquals($waitlistParticipant['participant_status'], 'On waitlist', 'Invalid participant status. Expecting: On waitlist');
+
+    $form = CRM_Event_Form_Registration_Confirm::testSubmit([
+      'id' => $event['id'],
+      'contributeMode' => 'direct',
+      'registerByID' => $waitlistContactId,
+      'paymentProcessorObj' => CRM_Financial_BAO_PaymentProcessor::getPayment($paymentProcessorID),
+      'amount' => 8000.67,
+      'amount_level' => '\ 1Tiny-tots (ages 5-8) - 1\ 1',
+      'params' => [
+        [
+          'qfKey' => 'e6eb2903eae63d4c5c6cc70bfdda8741_2801',
+          'entryURL' => 'http://dmaster.local/civicrm/event/register?reset=1&amp;id=3',
+          'first_name' => $waitlistContact['first_name'],
+          'last_name' => $waitlistContact['last_name'],
+          'email-Primary' => $waitlistContact['email'],
+          'hidden_processor' => '1',
+          'credit_card_number' => '4111111111111111',
+          'cvv2' => '123',
+          'credit_card_exp_date' => [
+            'M' => '1',
+            'Y' => date('Y') + 1,
+          ],
+          'credit_card_type' => 'Visa',
+          'billing_first_name' => $waitlistContact['first_name'],
+          'billing_middle_name' => '',
+          'billing_last_name' => $waitlistContact['last_name'],
+          'billing_street_address-5' => 'p',
+          'billing_city-5' => 'p',
+          'billing_state_province_id-5' => '1061',
+          'billing_postal_code-5' => '7',
+          'billing_country_id-5' => '1228',
+          'priceSetId' => '6',
+          'price_7' => [
+            13 => 1,
+          ],
+          'payment_processor_id' => $paymentProcessorID,
+          'bypass_payment' => '',
+          'is_primary' => 1,
+          'is_pay_later' => 0,
+          'participant_id' => $waitlistParticipantId,
+          'campaign_id' => NULL,
+          'defaultRole' => 1,
+          'participant_role_id' => '1',
+          'currencyID' => 'USD',
+          'amount_level' => '\ 1Tiny-tots (ages 5-8) - 1\ 1',
+          'amount' => $this->formatMoneyInput(8000.67),
+          'tax_amount' => NULL,
+          'year' => '2019',
+          'month' => '1',
+          'ip_address' => '127.0.0.1',
+          'invoiceID' => '68adc34957a29171948e8643ce906332',
+          'button' => '_qf_Register_upload',
+          'billing_state_province-5' => 'AP',
+          'billing_country-5' => 'US',
+        ],
+      ],
+    ]);
+    $this->callAPISuccessGetCount('Participant', [], 2);
+
+    $waitlistParticipant = $this->callAPISuccess('Participant', 'getsingle', ['id' => $waitlistParticipantId, 'return' => ["participant_status"]]);
+    $this->assertEquals($waitlistParticipant['participant_status'], 'Registered', 'Invalid participant status. Expecting: Registered');
+
+    $value = $form->get('value');
+    $this->assertArrayHasKey('contactID', $value, 'contactID missing in waitlist registration $value array');
+    $this->assertEquals($value['contactID'], $waitlistParticipant['contact_id'], 'Invalid contactID in waitlist $value array.');
+  }
+
   /**
    * Test for Tax amount for multiple participant.
    *
index 26b84bf2ccabb06d02d53274368e0a89969eded0..887bc02ff1796aaf6dca9f85aca0b69bd4e596c9 100644 (file)
@@ -236,7 +236,6 @@ class CRM_Export_BAO_ExportTest extends CiviUnitTestCase {
       'Image Url' => '',
       'Preferred Communication Method' => '',
       'Preferred Language' => 'en_US',
-      'Preferred Mail Format' => 'Both',
       'Contact Hash' => '059023a02d27d4e7f285a40ee0e30be8',
       'Contact Source' => '',
       'First Name' => 'Anthony',
@@ -1114,7 +1113,6 @@ class CRM_Export_BAO_ExportTest extends CiviUnitTestCase {
       'Image Url' => '',
       'Preferred Communication Method' => '',
       'Preferred Language' => 'en_US',
-      'Preferred Mail Format' => 'Both',
       'Contact Hash' => 'e9bd0913cc05cc5aeae69ba04ee3be84',
       'Contact Source' => '',
       'First Name' => 'Anthony',
@@ -1571,7 +1569,6 @@ class CRM_Export_BAO_ExportTest extends CiviUnitTestCase {
       'image_URL' => 1,
       'preferred_communication_method' => 1,
       'preferred_language' => 1,
-      'preferred_mail_format' => 1,
       'hash' => 1,
       'contact_source' => 1,
       'first_name' => 1,
@@ -2262,79 +2259,78 @@ class CRM_Export_BAO_ExportTest extends CiviUnitTestCase {
       15 => 'Image Url',
       16 => 'Preferred Communication Method',
       17 => 'Preferred Language',
-      18 => 'Preferred Mail Format',
-      19 => 'Contact Hash',
-      20 => 'Contact Source',
-      21 => 'First Name',
-      22 => 'Middle Name',
-      23 => 'Last Name',
-      24 => 'Individual Prefix',
-      25 => 'Individual Suffix',
-      26 => 'Formal Title',
-      27 => 'Communication Style',
-      28 => 'Email Greeting ID',
-      29 => 'Postal Greeting ID',
-      30 => 'Addressee ID',
-      31 => 'Job Title',
-      32 => 'Gender',
-      33 => 'Birth Date',
-      34 => 'Deceased',
-      35 => 'Deceased Date',
-      36 => 'Household Name',
-      37 => 'Organization Name',
-      38 => 'Sic Code',
-      39 => 'Unique ID (OpenID)',
-      40 => 'Current Employer ID',
-      41 => 'Contact is in Trash',
-      42 => 'Created Date',
-      43 => 'Modified Date',
-      44 => 'Addressee',
-      45 => 'Email Greeting',
-      46 => 'Postal Greeting',
-      47 => 'Current Employer',
-      48 => 'Location Type',
-      49 => 'Address ID',
-      50 => 'Street Address',
-      51 => 'Street Number',
-      52 => 'Street Number Suffix',
-      53 => 'Street Name',
-      54 => 'Street Unit',
-      55 => 'Supplemental Address 1',
-      56 => 'Supplemental Address 2',
-      57 => 'Supplemental Address 3',
-      58 => 'City',
-      59 => 'Postal Code Suffix',
-      60 => 'Postal Code',
-      61 => 'Latitude',
-      62 => 'Longitude',
-      63 => 'Is Manually Geocoded',
-      64 => 'Address Name',
-      65 => 'Master Address ID',
-      66 => 'County',
-      67 => 'State',
-      68 => 'Country',
-      69 => 'Phone',
-      70 => 'Phone Extension',
-      71 => 'Phone Type ID',
-      72 => 'Phone Type',
-      73 => 'Email',
-      74 => 'On Hold',
-      75 => 'Use for Bulk Mail',
-      76 => 'Signature Text',
-      77 => 'Signature Html',
-      78 => 'IM Provider',
-      79 => 'IM Screen Name',
-      80 => 'OpenID',
-      81 => 'World Region',
-      82 => 'Website',
-      83 => 'Group(s)',
-      84 => 'Tag(s)',
-      85 => 'Note(s)',
+      18 => 'Contact Hash',
+      19 => 'Contact Source',
+      20 => 'First Name',
+      21 => 'Middle Name',
+      22 => 'Last Name',
+      23 => 'Individual Prefix',
+      24 => 'Individual Suffix',
+      25 => 'Formal Title',
+      26 => 'Communication Style',
+      27 => 'Email Greeting ID',
+      28 => 'Postal Greeting ID',
+      29 => 'Addressee ID',
+      30 => 'Job Title',
+      31 => 'Gender',
+      32 => 'Birth Date',
+      33 => 'Deceased',
+      34 => 'Deceased Date',
+      35 => 'Household Name',
+      36 => 'Organization Name',
+      37 => 'Sic Code',
+      38 => 'Unique ID (OpenID)',
+      39 => 'Current Employer ID',
+      40 => 'Contact is in Trash',
+      41 => 'Created Date',
+      42 => 'Modified Date',
+      43 => 'Addressee',
+      44 => 'Email Greeting',
+      45 => 'Postal Greeting',
+      46 => 'Current Employer',
+      47 => 'Location Type',
+      48 => 'Address ID',
+      49 => 'Street Address',
+      50 => 'Street Number',
+      51 => 'Street Number Suffix',
+      52 => 'Street Name',
+      53 => 'Street Unit',
+      54 => 'Supplemental Address 1',
+      55 => 'Supplemental Address 2',
+      56 => 'Supplemental Address 3',
+      57 => 'City',
+      58 => 'Postal Code Suffix',
+      59 => 'Postal Code',
+      60 => 'Latitude',
+      61 => 'Longitude',
+      62 => 'Is Manually Geocoded',
+      63 => 'Address Name',
+      64 => 'Master Address ID',
+      65 => 'County',
+      66 => 'State',
+      67 => 'Country',
+      68 => 'Phone',
+      69 => 'Phone Extension',
+      70 => 'Phone Type ID',
+      71 => 'Phone Type',
+      72 => 'Email',
+      73 => 'On Hold',
+      74 => 'Use for Bulk Mail',
+      75 => 'Signature Text',
+      76 => 'Signature Html',
+      77 => 'IM Provider',
+      78 => 'IM Screen Name',
+      79 => 'OpenID',
+      80 => 'World Region',
+      81 => 'Website',
+      82 => 'Group(s)',
+      83 => 'Tag(s)',
+      84 => 'Note(s)',
     ];
     if (!$isContactExport) {
+      unset($headers[82]);
       unset($headers[83]);
       unset($headers[84]);
-      unset($headers[85]);
     }
     return $headers;
   }
@@ -2553,7 +2549,6 @@ class CRM_Export_BAO_ExportTest extends CiviUnitTestCase {
       'image_url' => '`image_url` longtext',
       'preferred_communication_method' => '`preferred_communication_method` varchar(255)',
       'preferred_language' => '`preferred_language` varchar(5)',
-      'preferred_mail_format' => '`preferred_mail_format` text(16)',
       'hash' => '`hash` varchar(32)',
       'contact_source' => '`contact_source` varchar(255)',
       'first_name' => '`first_name` varchar(64)',
@@ -2741,7 +2736,6 @@ class CRM_Export_BAO_ExportTest extends CiviUnitTestCase {
       'image_url' => '`image_url` longtext',
       'preferred_communication_method' => '`preferred_communication_method` varchar(255)',
       'preferred_language' => '`preferred_language` varchar(5)',
-      'preferred_mail_format' => '`preferred_mail_format` text(16)',
       'hash' => '`hash` varchar(32)',
       'contact_source' => '`contact_source` varchar(255)',
       'first_name' => '`first_name` varchar(64)',
index abd90c5c3f6e9dd0e797f7ce67c5549906932247..4c2ff9bfc3fa49df00db575b96dc748c3889948a 100644 (file)
@@ -113,11 +113,8 @@ class CRM_Member_Import_Parser_MembershipTest extends CiviUnitTestCase {
 
   /**
    *  Test Import.
-   *
-   * @throws \CRM_Core_Exception
-   * @throws \CiviCRM_API3_Exception
    */
-  public function testImport() {
+  public function testImport(): void {
     $this->individualCreate();
     $contact2Params = [
       'first_name' => 'Anthonita',
index 38c8bd2b8d1ebde076ac44228d6da710b3e88488..43912aaeae72b58d035a0643d48c7222bf820d89 100644 (file)
@@ -62,6 +62,51 @@ class CRM_Queue_QueueTest extends CiviUnitTestCase {
     parent::tearDown();
   }
 
+  /**
+   * If the queue has an automatic background runner (`runner`), then it
+   * must also have an `error` policy.
+   */
+  public function testRunnerRequiresErrorPolicy() {
+    try {
+      $q1 = Civi::queue('test/incomplete/1', [
+        'type' => 'Sql',
+        'runner' => 'task',
+      ]);
+      $this->fail('Should fail without error policy');
+    }
+    catch (CRM_Core_Exception $e) {
+      $this->assertRegExp('/Invalid error mode/', $e->getMessage());
+    }
+
+    $q2 = Civi::queue('test/complete/2', [
+      'type' => 'Sql',
+      'runner' => 'task',
+      'error' => 'delete',
+    ]);
+    $this->assertTrue($q2 instanceof CRM_Queue_Queue_Sql);
+  }
+
+  public function testStatuses() {
+    $q1 = Civi::queue('test/valid/default', [
+      'type' => 'Sql',
+      'runner' => 'task',
+      'error' => 'delete',
+    ]);
+    $this->assertTrue($q1 instanceof CRM_Queue_Queue_Sql);
+    $this->assertDBQuery('active', "SELECT status FROM civicrm_queue WHERE name = 'test/valid/default'");
+
+    foreach (['draft', 'active', 'complete', 'aborted'] as $n => $exampleStatus) {
+      $q1 = Civi::queue("test/valid/$n", [
+        'type' => 'Sql',
+        'runner' => 'task',
+        'error' => 'delete',
+        'status' => $exampleStatus,
+      ]);
+      $this->assertTrue($q1 instanceof CRM_Queue_Queue_Sql);
+      $this->assertDBQuery($exampleStatus, "SELECT status FROM civicrm_queue WHERE name = 'test/valid/$n'");
+    }
+  }
+
   /**
    * Create a few queue items; alternately enqueue and dequeue various
    *
@@ -89,11 +134,13 @@ class CRM_Queue_QueueTest extends CiviUnitTestCase {
     $this->assertEquals(3, $this->queue->numberOfItems());
     $item = $this->queue->claimItem();
     $this->assertEquals('a', $item->data['test-key']);
+    $this->assertEquals(1, $item->run_count);
     $this->queue->deleteItem($item);
 
     $this->assertEquals(2, $this->queue->numberOfItems());
     $item = $this->queue->claimItem();
     $this->assertEquals('b', $item->data['test-key']);
+    $this->assertEquals(1, $item->run_count);
     $this->queue->deleteItem($item);
 
     $this->queue->createItem([
@@ -103,11 +150,13 @@ class CRM_Queue_QueueTest extends CiviUnitTestCase {
     $this->assertEquals(2, $this->queue->numberOfItems());
     $item = $this->queue->claimItem();
     $this->assertEquals('c', $item->data['test-key']);
+    $this->assertEquals(1, $item->run_count);
     $this->queue->deleteItem($item);
 
     $this->assertEquals(1, $this->queue->numberOfItems());
     $item = $this->queue->claimItem();
     $this->assertEquals('d', $item->data['test-key']);
+    $this->assertEquals(1, $item->run_count);
     $this->queue->deleteItem($item);
 
     $this->assertEquals(0, $this->queue->numberOfItems());
@@ -129,12 +178,14 @@ class CRM_Queue_QueueTest extends CiviUnitTestCase {
 
     $item = $this->queue->claimItem();
     $this->assertEquals('a', $item->data['test-key']);
+    $this->assertEquals(1, $item->run_count);
     $this->assertEquals(1, $this->queue->numberOfItems());
     $this->queue->releaseItem($item);
 
     $this->assertEquals(1, $this->queue->numberOfItems());
     $item = $this->queue->claimItem();
     $this->assertEquals('a', $item->data['test-key']);
+    $this->assertEquals(2, $item->run_count);
     $this->queue->deleteItem($item);
 
     $this->assertEquals(0, $this->queue->numberOfItems());
@@ -158,6 +209,7 @@ class CRM_Queue_QueueTest extends CiviUnitTestCase {
 
     $item = $this->queue->claimItem();
     $this->assertEquals('a', $item->data['test-key']);
+    $this->assertEquals(1, $item->run_count);
     $this->assertEquals(1, $this->queue->numberOfItems());
     // forget to release
 
@@ -170,6 +222,7 @@ class CRM_Queue_QueueTest extends CiviUnitTestCase {
     CRM_Utils_Time::setTime('2012-04-01 2:00:03');
     $item3 = $this->queue->claimItem();
     $this->assertEquals('a', $item3->data['test-key']);
+    $this->assertEquals(2, $item3->run_count);
     $this->assertEquals(1, $this->queue->numberOfItems());
     $this->queue->deleteItem($item3);
 
@@ -193,6 +246,7 @@ class CRM_Queue_QueueTest extends CiviUnitTestCase {
 
     $item = $this->queue->claimItem();
     $this->assertEquals('a', $item->data['test-key']);
+    $this->assertEquals(1, $item->run_count);
     $this->assertEquals(1, $this->queue->numberOfItems());
     // forget to release
 
@@ -204,6 +258,7 @@ class CRM_Queue_QueueTest extends CiviUnitTestCase {
     // but stealItem works
     $item3 = $this->queue->stealItem();
     $this->assertEquals('a', $item3->data['test-key']);
+    $this->assertEquals(2, $item3->run_count);
     $this->assertEquals(1, $this->queue->numberOfItems());
     $this->queue->deleteItem($item3);
 
@@ -357,4 +412,54 @@ class CRM_Queue_QueueTest extends CiviUnitTestCase {
     $queue2->releaseItem($item);
   }
 
+  /**
+   * Grab items from a queue in batches.
+   *
+   * @dataProvider getQueueSpecs
+   * @param $queueSpec
+   */
+  public function testBatchClaim($queueSpec) {
+    $this->queue = $this->queueService->create($queueSpec);
+    $this->assertTrue($this->queue instanceof CRM_Queue_Queue);
+    if (!($this->queue instanceof CRM_Queue_Queue_BatchQueueInterface)) {
+      $this->markTestSkipped("Queue class does not support batch interface: " . get_class($this->queue));
+    }
+
+    for ($i = 0; $i < 9; $i++) {
+      $this->queue->createItem('x' . $i);
+    }
+    $this->assertEquals(9, $this->queue->numberOfItems());
+
+    // We expect this driver to be fully compliant with batching.
+    $claimsA = $this->queue->claimItems(3);
+    $claimsB = $this->queue->claimItems(3);
+    $this->assertEquals(9, $this->queue->numberOfItems());
+
+    $this->assertEquals(['x0', 'x1', 'x2'], CRM_Utils_Array::collect('data', $claimsA));
+    $this->assertEquals(['x3', 'x4', 'x5'], CRM_Utils_Array::collect('data', $claimsB));
+
+    $this->queue->deleteItems([$claimsA[0], $claimsA[1]]); /* x0, x1 */
+    $this->queue->releaseItems([$claimsA[2]]); /* x2: will retry with next claimItems() */
+    $this->queue->deleteItems([$claimsB[0], $claimsB[1]]); /* x3, x4 */
+    /* claimsB[2]: x5: Oops, we're gonna take some time to finish this one. */
+    $this->assertEquals(5, $this->queue->numberOfItems());
+
+    $claimsC = $this->queue->claimItems(3);
+    $this->assertEquals(['x2', 'x6', 'x7'], CRM_Utils_Array::collect('data', $claimsC));
+    $this->queue->deleteItem($claimsC[0]); /* x2 */
+    $this->queue->releaseItem($claimsC[1]); /* x6: will retry with next claimItems() */
+    $this->queue->deleteItem($claimsC[2]); /* x7 */
+    $this->assertEquals(3, $this->queue->numberOfItems());
+
+    $claimsD = $this->queue->claimItems(3);
+    $this->assertEquals(['x6', 'x8'], CRM_Utils_Array::collect('data', $claimsD));
+    $this->queue->deleteItem($claimsD[0]); /* x6 */
+    $this->queue->deleteItem($claimsD[1]); /* x8 */
+    $this->assertEquals(1, $this->queue->numberOfItems());
+
+    // claimsB took a while to wrap-up. But it finally did!
+    $this->queue->deleteItem($claimsB[2]); /* x5 */
+    $this->assertEquals(0, $this->queue->numberOfItems());
+  }
+
 }
index fa81cecad305129b74eb7f2cd3bbe2dc58d53346..c3dd21950f48dbb4427d319f9414f89fbff65b98 100644 (file)
@@ -27,7 +27,7 @@ class CRM_SMS_PreviewTest extends CiviUnitTestCase {
   /**
    * Test SMS preview.
    */
-  public function testSMSPreview() {
+  public function testSMSPreview(): void {
     $result = $this->callAPISuccess('SmsProvider', 'create', [
       'title' => 'test SMS provider',
       'username' => 'test',
@@ -41,10 +41,10 @@ class CRM_SMS_PreviewTest extends CiviUnitTestCase {
     $provider_id = $result['id'];
     $result = $this->callAPISuccess('Mailing', 'create', [
       'name' => "Test1",
-      'from_name' => "+12223334444",
-      'from_email' => "test@test.com",
+      'from_name' => '+12223334444',
+      'from_email' => 'test@test.com',
       'replyto_email' => "test@test.com",
-      'body_text' => "Testing body",
+      'body_text' => 'Testing body',
       'sms_provider_id' => $provider_id,
       'header_id' => NULL,
       'footer_id' => NULL,
index fca9ec36fbbbfbaea35a3c75e190df8659bf7300..580ffaa3186932f6c9c4d3162f5a7016c3fd4a87 100644 (file)
@@ -3,9 +3,25 @@
 class CRM_Utils_Geocode_TestProvider {
 
   public static function format(&$values, $stateName = FALSE) {
-    if ($values['street_address'] == 'Does not exist') {
-      $values['geo_code_1'] = $values['geo_code_2'] = 'null';
+    $address = ($values['street_address'] ?? '') . ($values['city'] ?? '');
+
+    $coord = self::getCoordinates($address);
+
+    $values['geo_code_1'] = $coord['geo_code_1'] ?? 'null';
+    $values['geo_code_2'] = $coord['geo_code_2'] ?? 'null';
+
+    if (isset($coord['geo_code_error'])) {
+      $values['geo_code_error'] = $coord['geo_code_error'];
+    }
+
+    return isset($coord['geo_code_1'], $coord['geo_code_2']);
+  }
+
+  public static function getCoordinates($address): array {
+    if (strpos($address, '600 Pennsylvania Avenue NW, Washington') === 0) {
+      return ['geo_code_1' => '38.897957', 'geo_code_2' => '-77.036560'];
     }
+    return [];
   }
 
 }
index b2e5aab5e46e0f9bb059bd02fbdc0654ca9b6a02..4fa1aa3148b8b3166f822f9d4a5391ce543b1358 100644 (file)
@@ -4,7 +4,12 @@
  * Class CRM_Utils_RestTest
  * @group headless
  */
-class CRM_Utils_RestTest extends CiviUnitTestCase {
+class CRM_Utils_RestTest extends CiviUnitTestCase  {
+
+  public function setUp() :void {
+    parent::setUp();
+    $this->useTransaction(TRUE);
+  }
 
   public function testProcessMultiple() {
     $_SERVER['REQUEST_METHOD'] = 'POST';
@@ -32,4 +37,95 @@ class CRM_Utils_RestTest extends CiviUnitTestCase {
     $this->assertGreaterThan($output['cow']['id'], $output['sheep']['id']);
   }
 
+  /**
+   * Check that check_permissions passed in in chained api calls is ignored.
+   */
+  public function testSecurityIssue116() {
+    $this->hookClass->setHook('civicrm_alterAPIPermissions', [$this, 'alterAPIPermissions']);
+
+    $config = CRM_Core_Config::singleton();
+    $config->userPermissionClass->permissions = [];
+
+    $contactID = \Civi\Api4\Contact::create(FALSE)
+      ->addValue('display_name', 'Wilma')
+      ->addValue('contact_type', 'Individual')
+      ->execute()->first()['id'];
+
+    $jobLogID = civicrm_api3('JobLog', 'create', [
+      'name' => 'test',
+      'domain_id' => 1,
+    ])['id'];
+    $params = [ 'id' => $jobLogID, 'version' => 3, 'sequential' => 1, 'check_permissions' => 0 ];
+    $args = ['civicrm', 'JobLog', 'get'];
+
+    // Check we can load the email without checking perms.
+    $r = civicrm_api('JobLog', 'get', $params);
+    $this->assertEquals(1, $r['count']);
+
+    // Check we can still load it with checking permission (because we allow it in hook)
+    $r = civicrm_api('JobLog', 'get', ['check_permissions' => 1] + $params);
+    $this->assertEquals(1, $r['count']);
+
+    // Now check we can load it via the rest endpoint which should enforce permissions.
+    $output = CRM_Utils_REST::process($args, $params);
+    $this->assertEquals(1, $output['count']);
+
+    // Now add a chain, naughtily passing in a check_permissions
+    // We do not have permission to access this contact.
+    $params['api.contact.get'] = [
+      'id' => $contactID,
+      'check_permissions' => 0,
+      'return' => 'display_name',
+    ];
+    $output = CRM_Utils_REST::process($args, $params);
+    $this->assertEquals($jobLogID, $output['id']);
+    $chain = $output['values'][0]['api.contact.get'];
+    $this->assertEquals(0, $chain['count'], "Vulnerable.");
+
+    // There is a different codepath when the chained api call is an array
+    // (This is designed for multiple chained create/delete calls, but
+    // we can just use get for testing.)
+    $params['api.contact.get'] = [$params['api.contact.get']];
+    $output = CRM_Utils_REST::process($args, $params);
+    $this->assertEquals($jobLogID, $output['id']);
+    $chainResult = $output['values'][0]['api.contact.get'];
+    $this->assertIsArray($chainResult);
+    $this->assertCount(1, $chainResult);
+    $this->assertEquals(0, $chainResult[0]['count'], "Vulnerable.");
+
+    // Try create call AND using different api chain syntax.
+    unset($params['api.contact.get']);
+    $params['api_contact_create'] = [
+      ['contact_type' => 'Individual', 'display_name' => 'Sad Face', 'check_permissions' => 0]
+    ];
+    $output = CRM_Utils_REST::process($args, $params);
+    $this->assertEquals(1, $output['is_error']);
+    $this->assertEquals('unauthorized', $output['error_code']);
+
+    // Test that a nested chain is also forced to use permissions.
+    unset($params['api_contact_create']);
+    $params['api.job_log.get'] = [
+      'id' => $jobLogID,
+      'check_permissions' => 0,
+      'api.contact.get' => [
+        'id' => $contactID,
+        'check_permissions' => 0,
+        'return' => 'display_name',
+      ]];
+    $output = CRM_Utils_REST::process($args, $params);
+    $this->assertEquals($jobLogID, $output['id']);
+    $chain = $output['values'][0]['api.job_log.get'];
+    $this->assertEquals(1, $chain['count'], "Expected the first chain to work.");
+    // Check the inner contact.get returned nothing
+    $chain = $chain['values'][0]['api.contact.get'];
+    $this->assertEquals(0, $chain['count'], "Vulnerable.");
+  }
+
+  /**
+   */
+  public function alterAPIPermissions($entity, $action, &$params, &$permissions) {
+    if ($entity === 'job_log' && $action === 'get') {
+      $permissions['job_log']['get'] = [];
+    }
+  }
 }
index f8a55cde2aad150185df9b2244ba882c9eba764a..6ea58248e58bfdaaad2dc92bd2d870036da5031d 100644 (file)
@@ -52,6 +52,7 @@ class api_v3_AddressTest extends CiviUnitTestCase {
     $this->locationTypeDelete($this->_locationTypeID);
     $this->contactDelete($this->_contactID);
     $this->quickCleanup(['civicrm_address', 'civicrm_relationship']);
+    $this->callAPISuccess('Setting', 'create', ['geoProvider' => NULL]);
     parent::tearDown();
   }
 
index ffaf9cb7309a56ece1e142af0d0f54a0aee6f479..673527b70d9d59bf2a090d23a00b20c2ff81d8c1 100644 (file)
@@ -2347,7 +2347,6 @@ class api_v3_ContactTest extends CiviUnitTestCase {
     $contact = $this->callAPISuccess('contact', 'create', array_merge($this->_params, $params));
 
     $result = $this->callAPISuccess('contact', 'getsingle', ['id' => $contact['id']]);
-    $this->assertEquals('Both', $result['preferred_mail_format']);
 
     $this->assertEquals('en_US', $result['preferred_language']);
     $this->assertEquals(1, $result['communication_style_id']);
index 982c49ea20cc3c6da9e8fb02dd12e62f14a920b1..6607c9385fd5e54dc95dfd9cf85791b17d0cae02 100644 (file)
@@ -104,4 +104,48 @@ class api_v3_CountryTest extends CiviUnitTestCase {
     $this->assertEquals(1, $check);
   }
 
+  /**
+   * Test that the list of states is in the correct format when chaining
+   * and using sequential.
+   */
+  public function testCountryStateChainSequential() {
+    // first without specifying
+    $result = $this->callAPISuccess('Country', 'getsingle', [
+      'iso_code' => 'US',
+      'api.Address.getoptions' => [
+        'field' => 'state_province_id',
+        'country_id' => '$value.id',
+      ],
+    ]);
+    $this->assertSame(['key' => 1000, 'value' => 'Alabama'], $result['api.Address.getoptions']['values'][0]);
+    $this->assertSame(['key' => 1001, 'value' => 'Alaska'], $result['api.Address.getoptions']['values'][1]);
+    $this->assertSame(['key' => 1049, 'value' => 'Wyoming'], $result['api.Address.getoptions']['values'][59]);
+
+    // now specifying sequential
+    $result = $this->callAPISuccess('Country', 'getsingle', [
+      'iso_code' => 'US',
+      'api.Address.getoptions' => [
+        'field' => 'state_province_id',
+        'country_id' => '$value.id',
+        'sequential' => 1,
+      ],
+    ]);
+    $this->assertSame(['key' => 1000, 'value' => 'Alabama'], $result['api.Address.getoptions']['values'][0]);
+    $this->assertSame(['key' => 1001, 'value' => 'Alaska'], $result['api.Address.getoptions']['values'][1]);
+    $this->assertSame(['key' => 1049, 'value' => 'Wyoming'], $result['api.Address.getoptions']['values'][59]);
+
+    // now specifying keyed
+    $result = $this->callAPISuccess('Country', 'getsingle', [
+      'iso_code' => 'US',
+      'api.Address.getoptions' => [
+        'field' => 'state_province_id',
+        'country_id' => '$value.id',
+        'sequential' => 0,
+      ],
+    ]);
+    $this->assertSame('Alabama', $result['api.Address.getoptions']['values'][1000]);
+    $this->assertSame('Alaska', $result['api.Address.getoptions']['values'][1001]);
+    $this->assertSame('Wyoming', $result['api.Address.getoptions']['values'][1049]);
+  }
+
 }
index 8250199962cd6019230611dfec6968afb0178db2..04402fe9841ca986cb5f4e2b5e4a253edf8118b5 100644 (file)
@@ -113,7 +113,7 @@ class api_v3_EntityBatchTest extends CiviUnitTestCase {
       'entity_table' => 'civicrm_financial_trxn',
     ];
     $result = $this->callAPIFailure($this->_entity, 'create', $secondEntityBatchParams);
-    $this->assertEquals('You can not add items of two different currencies to a single contribution batch.', $result['error_message']);
+    $this->assertEquals("You cannot add items of two different currencies to a single contribution batch. Batch id {$batchId} currency: USD. Entity id {$secondFinancialTrxnId} currency: CAD.", $result['error_message']);
   }
 
 }
index b66b2b0b2a0873ed897099f037bb2d6870a14160..bba9dc803c0280982d37509c93f670e5441c387f 100644 (file)
@@ -923,4 +923,16 @@ class api_v3_EventTest extends CiviUnitTestCase {
     }
   }
 
+  public function testGetListLeadingZero() {
+    $this->callAPISuccess('Event', 'create', [
+      'title' => "0765",
+      'start_date' => "2022-04-04",
+      'event_type_id' => "Conference",
+    ]);
+    $result = $this->callAPISuccess('Event', 'getlist', [
+      'input' => "0765",
+    ]);
+    $this->assertEquals(1, $result['count']);
+  }
+
 }
index 3d09f5a2771ba0df973401946162f1c12c6c8bc6..6fe9020efa2b0c0942cf57b5e05ea86e54b5ee0a 100644 (file)
@@ -652,11 +652,10 @@ class api_v3_RelationshipTest extends CiviUnitTestCase {
 
     $params_2 = array_merge($params_1, $custom_params_2);
 
-    $this->callAPISuccess('relationship', 'create', $params_1);
-    $result_2 = $this->callAPISuccess('relationship', 'create', $params_2);
+    $this->callAPISuccess('Relationship', 'create', $params_1);
+    $result_2 = $this->callAPISuccess('Relationship', 'create', $params_2);
 
     $this->assertNotNull($result_2['id']);
-    $this->assertEquals(0, $result_2['is_error']);
   }
 
   /**
diff --git a/tests/phpunit/api/v4/Action/AddressGetCoordinatesTest.php b/tests/phpunit/api/v4/Action/AddressGetCoordinatesTest.php
new file mode 100644 (file)
index 0000000..ed0a37d
--- /dev/null
@@ -0,0 +1,56 @@
+<?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\Action;
+
+use api\v4\Api4TestBase;
+use Civi\Api4\Address;
+use Civi\Test\TransactionalInterface;
+
+/**
+ * @group headless
+ */
+class AddressGetCoordinatesTest extends Api4TestBase implements TransactionalInterface {
+
+  public function setUp(): void {
+    parent::setUp();
+    \Civi\Api4\Setting::set()
+      ->addValue('geoProvider', 'TestProvider')
+      ->execute();
+  }
+
+  public function tearDown(): void {
+    parent::tearDown();
+    \Civi\Api4\Setting::revert()
+      ->addSelect('geoProvider')
+      ->execute();
+  }
+
+  public function testGetCoordinatesWhiteHouse(): void {
+    $coordinates = Address::getCoordinates()->setAddress('600 Pennsylvania Avenue NW, Washington, DC, USA')->execute()->first();
+    $this->assertEquals('38.897957', $coordinates['geo_code_1']);
+    $this->assertEquals('-77.036560', $coordinates['geo_code_2']);
+  }
+
+  public function testGetCoordinatesNoAddress(): void {
+    $coorindates = Address::getCoordinates()->setAddress('Does not exist, Washington, DC, USA')->execute()->first();
+    $this->assertEmpty($coorindates);
+  }
+
+}
index c12421fdfe2ad2fac88a1890250c4eb4679c3a57..41137824eea5a0643bab7cc77d0db920116dcc8f 100644 (file)
@@ -95,6 +95,25 @@ class BasicCustomFieldTest extends CustomTestBase {
       ->execute()
       ->first();
     $this->assertEquals(NULL, $contact['MyIndividualFields.FavColor']);
+
+    // Disable the field and it disappears from getFields and from the API output.
+    CustomField::update(FALSE)
+      ->addWhere('custom_group_id:name', '=', 'MyIndividualFields')
+      ->addWhere('name', '=', 'FavColor')
+      ->addValue('is_active', FALSE)
+      ->execute();
+
+    $getFields = Contact::getFields(FALSE)
+      ->execute()->column('name');
+    $this->assertContains('first_name', $getFields);
+    $this->assertNotContains('MyIndividualFields.FavColor', $getFields);
+
+    $contact = Contact::get(FALSE)
+      ->addSelect('MyIndividualFields.FavColor')
+      ->addWhere('id', '=', $contactId)
+      ->execute()
+      ->first();
+    $this->assertArrayNotHasKey('MyIndividualFields.FavColor', $contact);
   }
 
   public function testWithTwoFields() {
index c2074cd32dfbb7cfeeb90736756295d94e999904..5b2e3a91a100a71821d4e926a117454e18430ec5 100644 (file)
@@ -86,28 +86,42 @@ class CustomFieldGetFieldsTest extends CustomTestBase {
       ->addValue('html_type', 'Text')
       ->execute();
 
+    // Unconditional Contact CustomGroup
+    CustomGroup::create(FALSE)
+      ->addValue('extends', 'Contact')
+      ->addValue('title', 'always')
+      ->addChain('field', CustomField::create()
+        ->addValue('custom_group_id', '$id')
+        ->addValue('label', 'on')
+        ->addValue('html_type', 'Text')
+      )->execute();
+
     $allFields = Contact::getFields(FALSE)
       ->execute()->indexBy('name');
     $this->assertArrayHasKey('contact_sub.sub_field', $allFields);
     $this->assertArrayHasKey('org_group.sub_field', $allFields);
+    $this->assertArrayHasKey('always.on', $allFields);
 
     $fieldsWithSubtype = Contact::getFields(FALSE)
       ->addValue('id', $contact2['id'])
       ->execute()->indexBy('name');
     $this->assertArrayHasKey('contact_sub.sub_field', $fieldsWithSubtype);
     $this->assertArrayNotHasKey('org_group.sub_field', $fieldsWithSubtype);
+    $this->assertArrayHasKey('always.on', $fieldsWithSubtype);
 
     $fieldsNoSubtype = Contact::getFields(FALSE)
       ->addValue('id', $contact1['id'])
       ->execute()->indexBy('name');
     $this->assertArrayNotHasKey('contact_sub.sub_field', $fieldsNoSubtype);
     $this->assertArrayNotHasKey('org_group.sub_field', $fieldsNoSubtype);
+    $this->assertArrayHasKey('always.on', $fieldsNoSubtype);
 
     $groupFields = Contact::getFields(FALSE)
       ->addValue('id', $org['id'])
       ->execute()->indexBy('name');
     $this->assertArrayNotHasKey('contact_sub.sub_field', $groupFields);
     $this->assertArrayHasKey('org_group.sub_field', $groupFields);
+    $this->assertArrayHasKey('always.on', $groupFields);
   }
 
   public function testCustomGetFieldsForParticipantSubTypes() {
@@ -185,10 +199,22 @@ class CustomFieldGetFieldsTest extends CustomTestBase {
       )
       ->execute();
 
+    // Unconditional Participant CustomGroup
+    CustomGroup::create(FALSE)
+      ->addValue('extends', 'Participant')
+      ->addValue('title', 'always')
+      ->addChain('field', CustomField::create()
+        ->addValue('custom_group_id', '$id')
+        ->addValue('label', 'on')
+        ->addValue('html_type', 'Text')
+      )
+      ->execute();
+
     $allFields = Participant::getFields(FALSE)->execute()->indexBy('name');
     $this->assertArrayHasKey('meeting_conference.sub_field', $allFields);
     $this->assertArrayHasKey('volunteer_host.sub_field', $allFields);
     $this->assertArrayHasKey('event_2_and_3.sub_field', $allFields);
+    $this->assertArrayHasKey('always.on', $allFields);
 
     $participant0Fields = Participant::getFields(FALSE)
       ->addValue('id', $participants[0]['id'])
@@ -196,6 +222,7 @@ class CustomFieldGetFieldsTest extends CustomTestBase {
     $this->assertArrayHasKey('meeting_conference.sub_field', $participant0Fields);
     $this->assertArrayNotHasKey('volunteer_host.sub_field', $participant0Fields);
     $this->assertArrayNotHasKey('event_2_and_3.sub_field', $participant0Fields);
+    $this->assertArrayHasKey('always.on', $participant0Fields);
 
     $participant1Fields = Participant::getFields(FALSE)
       ->addValue('id', $participants[1]['id'])
@@ -203,6 +230,7 @@ class CustomFieldGetFieldsTest extends CustomTestBase {
     $this->assertArrayHasKey('meeting_conference.sub_field', $participant1Fields);
     $this->assertArrayHasKey('volunteer_host.sub_field', $participant1Fields);
     $this->assertArrayHasKey('event_2_and_3.sub_field', $participant1Fields);
+    $this->assertArrayHasKey('always.on', $participant1Fields);
 
     $participant2Fields = Participant::getFields(FALSE)
       ->addValue('id', $participants[2]['id'])
@@ -217,6 +245,7 @@ class CustomFieldGetFieldsTest extends CustomTestBase {
     $this->assertArrayNotHasKey('meeting_conference.sub_field', $participant3Fields);
     $this->assertArrayHasKey('volunteer_host.sub_field', $participant3Fields);
     $this->assertArrayNotHasKey('event_3_and_3.sub_field', $participant3Fields);
+    $this->assertArrayHasKey('always.on', $participant3Fields);
   }
 
 }
index dd632c2f6f0d74fe6449108c5b5ca88ea2774fbf..ffe2f517d00fcb4af2e1c38aa92d90fa148a81da 100644 (file)
@@ -90,7 +90,7 @@ class CustomValueTest extends CustomTestBase {
     $this->assertEquals('secondary', $entity['searchable']);
 
     // Retrieve and check the fields of CustomValue = Custom_$group
-    $fields = CustomValue::getFields($group)->setLoadOptions(TRUE)->setCheckPermissions(FALSE)->execute();
+    $fields = CustomValue::getFields($group, FALSE)->setLoadOptions(TRUE)->execute();
     $expectedResult = [
       [
         'custom_group' => $group,
@@ -252,6 +252,16 @@ class CustomValueTest extends CustomTestBase {
       }
     }
 
+    // Disable a field
+    CustomField::update(FALSE)
+      ->addValue('is_active', FALSE)
+      ->addWhere('id', '=', $multiField['id'])
+      ->execute();
+
+    $result = CustomValue::get($group)->execute()->single();
+    $this->assertArrayHasKey($colorFieldName, $result);
+    $this->assertArrayNotHasKey($multiFieldName, $result);
+
     // CASE 4: Test CustomValue::delete
     // There is only record left whose id = 3, delete that record on basis of criteria id = 3
     CustomValue::delete($group)->addWhere("id", "=", 3)->execute();
index 19c21e40447249d5b06d20afc5fc72fada0d3c6b..c03f6a14e42c69824bc5d4ca4f4a85bbf329fbea 100644 (file)
@@ -30,6 +30,13 @@ use Civi\Test\TransactionalInterface;
  */
 class AddressTest extends Api4TestBase implements TransactionalInterface {
 
+  public function setUp():void {
+    \Civi\Api4\Setting::revert()
+      ->addSelect('geoProvider')
+      ->execute();
+    parent::setUp();
+  }
+
   /**
    * Check that 2 addresses for the same contact can't both be primary
    */
@@ -59,4 +66,29 @@ class AddressTest extends Api4TestBase implements TransactionalInterface {
     $this->assertTrue($addresses[1]['is_primary']);
   }
 
+  public function testSearchProximity() {
+    $cid = $this->createTestRecord('Contact')['id'];
+    $sampleData = [
+      ['geo_code_1' => 20, 'geo_code_2' => 20],
+      ['geo_code_1' => 21, 'geo_code_2' => 21],
+      ['geo_code_1' => 19, 'geo_code_2' => 19],
+      ['geo_code_1' => 15, 'geo_code_2' => 15],
+    ];
+    $addreses = $this->saveTestRecords('Address', [
+      'records' => $sampleData,
+      'defaults' => ['contact_id' => $cid],
+    ])->column('id');
+
+    $result = Address::get(FALSE)
+      ->addWhere('contact_id', '=', $cid)
+      ->addWhere('proximity', '<=', ['distance' => 600, 'geo_code_1' => 20, 'geo_code_2' => 20])
+      ->execute()->column('id');
+
+    $this->assertCount(3, $result);
+    $this->assertContains($addreses[0], $result);
+    $this->assertContains($addreses[1], $result);
+    $this->assertContains($addreses[2], $result);
+    $this->assertNotContains($addreses[3], $result);
+  }
+
 }
diff --git a/tests/phpunit/api/v4/Entity/QueueTest.php b/tests/phpunit/api/v4/Entity/QueueTest.php
new file mode 100644 (file)
index 0000000..1e0f705
--- /dev/null
@@ -0,0 +1,409 @@
+<?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\Api4TestBase;
+use Civi\Api4\Queue;
+use Civi\Core\Event\GenericHookEvent;
+
+/**
+ * @group headless
+ * @group queue
+ */
+class QueueTest extends Api4TestBase {
+
+  protected function setUp(): void {
+    \Civi::$statics[__CLASS__] = [
+      'doSomethingResult' => TRUE,
+      'doSomethingLog' => [],
+      'onHookQueueRunLog' => [],
+    ];
+    parent::setUp();
+  }
+
+  /**
+   * Setup a queue with a line of back-to-back tasks.
+   *
+   * The first task runs normally. The second task fails at first, but it is retried, and then
+   * succeeds.
+   *
+   * @throws \API_Exception
+   * @throws \Civi\API\Exception\UnauthorizedException
+   */
+  public function testBasicLinearPolling() {
+    $queueName = 'QueueTest_' . md5(random_bytes(32)) . '_linear';
+    $queue = \Civi::queue($queueName, [
+      'type' => 'Sql',
+      'runner' => 'task',
+      'error' => 'delete',
+      'retry_limit' => 2,
+      'retry_interval' => 4,
+    ]);
+    $this->assertEquals(0, $queue->numberOfItems());
+
+    \Civi::queue($queueName)->createItem(new \CRM_Queue_Task(
+      [QueueTest::class, 'doSomething'],
+      ['first']
+    ));
+    \Civi::queue($queueName)->createItem(new \CRM_Queue_Task(
+      [QueueTest::class, 'doSomething'],
+      ['second']
+    ));
+
+    // Get item #1. Run it. Finish it.
+    $first = Queue::claimItems()->setQueue($queueName)->execute()->single();
+    $this->assertCallback('doSomething', ['first'], $first);
+    $this->assertEquals(0, count(Queue::claimItems()->setQueue($queueName)->execute()), 'Linear queue should not return more items while first item is pending.');
+    $firstResult = Queue::runItems(0)->setItems([$first])->execute()->single();
+    $this->assertEquals('ok', $firstResult['outcome']);
+    $this->assertEquals($first['id'], $firstResult['item']['id']);
+    $this->assertEquals($first['queue'], $firstResult['item']['queue']);
+    $this->assertEquals(['first_ok'], \Civi::$statics[__CLASS__]['doSomethingLog']);
+
+    // Get item #2. Run it - but fail!
+    $second = Queue::claimItems()->setQueue($queueName)->execute()->single();
+    $this->assertCallback('doSomething', ['second'], $second);
+    \Civi::$statics[__CLASS__]['doSomethingResult'] = FALSE;
+    $secondResult = Queue::runItems(0)->setItems([$second])->execute()->single();
+    \Civi::$statics[__CLASS__]['doSomethingResult'] = TRUE;
+    $this->assertEquals('retry', $secondResult['outcome']);
+    $this->assertEquals(['first_ok', 'second_err'], \Civi::$statics[__CLASS__]['doSomethingLog']);
+
+    // Item #2 is delayed... it'll take a few seconds to come up...
+    $waitCount = $this->waitFor(1.0, 10, function() use ($queueName, &$retrySecond): bool {
+      $retrySecond = Queue::claimItems()->setQueue($queueName)->execute()->first();
+      return !empty($retrySecond);
+    });
+    $this->assertTrue($waitCount > 0, 'Failed task should not become available immediately. It should take a few seconds.');
+    $this->assertCallback('doSomething', ['second'], $retrySecond);
+    $retrySecondResult = Queue::runItems(0)->setItems([$retrySecond])->execute()->single();
+    $this->assertEquals('ok', $retrySecondResult['outcome']);
+    $this->assertEquals(['first_ok', 'second_err', 'second_ok'], \Civi::$statics[__CLASS__]['doSomethingLog']);
+
+    // All done.
+    $this->assertEquals(0, $queue->numberOfItems());
+  }
+
+  public function testBasicParallelPolling() {
+    $queueName = 'QueueTest_' . md5(random_bytes(32)) . '_parallel';
+    $queue = \Civi::queue($queueName, ['type' => 'SqlParallel', 'runner' => 'task', 'error' => 'delete']);
+    $this->assertEquals(0, $queue->numberOfItems());
+
+    \Civi::queue($queueName)->createItem(new \CRM_Queue_Task(
+      [QueueTest::class, 'doSomething'],
+      ['first']
+    ));
+    \Civi::queue($queueName)->createItem(new \CRM_Queue_Task(
+      [QueueTest::class, 'doSomething'],
+      ['second']
+    ));
+
+    $first = Queue::claimItems()->setQueue($queueName)->execute()->single();
+    $second = Queue::claimItems()->setQueue($queueName)->execute()->single();
+
+    $this->assertCallback('doSomething', ['first'], $first);
+    $this->assertCallback('doSomething', ['second'], $second);
+
+    // Just for fun, let's run these tasks in opposite order.
+
+    Queue::runItems(0)->setItems([$second])->execute();
+    $this->assertEquals(['second_ok'], \Civi::$statics[__CLASS__]['doSomethingLog']);
+
+    Queue::runItems(0)->setItems([$first])->execute();
+    $this->assertEquals(['second_ok', 'first_ok'], \Civi::$statics[__CLASS__]['doSomethingLog']);
+
+    $this->assertEquals(0, $queue->numberOfItems());
+  }
+
+  /**
+   * Create a parallel queue. Claim and execute tasks as batches.
+   *
+   * Batches are executed via `hook_civicrm_queueRun_{runner}`.
+   *
+   * @throws \API_Exception
+   * @throws \Civi\API\Exception\UnauthorizedException
+   */
+  public function testBatchParallelPolling() {
+    $queueName = 'QueueTest_' . md5(random_bytes(32)) . '_parallel';
+    \Civi::dispatcher()->addListener('hook_civicrm_queueRun_testStuff', [$this, 'onHookQueueRun']);
+    $queue = \Civi::queue($queueName, [
+      'type' => 'SqlParallel',
+      'runner' => 'testStuff',
+      'error' => 'delete',
+      'batch_limit' => 3,
+    ]);
+    $this->assertEquals(0, $queue->numberOfItems());
+
+    for ($i = 0; $i < 7; $i++) {
+      \Civi::queue($queueName)->createItem(['thingy' => $i]);
+    }
+
+    $result = Queue::runItems(0)->setQueue($queueName)->execute();
+    $this->assertEquals(3, count($result));
+    $this->assertEquals([0, 1, 2], \Civi::$statics[__CLASS__]['onHookQueueRunLog'][0]);
+
+    $result = Queue::runItems(0)->setQueue($queueName)->execute();
+    $this->assertEquals(3, count($result));
+    $this->assertEquals([3, 4, 5], \Civi::$statics[__CLASS__]['onHookQueueRunLog'][1]);
+
+    $result = Queue::runItems(0)->setQueue($queueName)->execute();
+    $this->assertEquals(1, count($result));
+    $this->assertEquals([6], \Civi::$statics[__CLASS__]['onHookQueueRunLog'][2]);
+  }
+
+  /**
+   * @param \Civi\Core\Event\GenericHookEvent $e
+   * @see CRM_Utils_Hook::queueRun()
+   */
+  public function onHookQueueRun(GenericHookEvent $e): void {
+    \Civi::$statics[__CLASS__]['onHookQueueRunLog'][] = array_map(
+      function($item) {
+        return $item->data['thingy'];
+      },
+      $e->items
+    );
+
+    foreach ($e->items as $itemKey => $item) {
+      $e->outcomes[$itemKey] = 'ok';
+      $e->queue->deleteItem($item);
+    }
+  }
+
+  public function testSelect() {
+    $queueName = 'QueueTest_' . md5(random_bytes(32)) . '_parallel';
+    $queue = \Civi::queue($queueName, ['type' => 'SqlParallel', 'runner' => 'task', 'error' => 'delete']);
+    $this->assertEquals(0, $queue->numberOfItems());
+
+    \Civi::queue($queueName)->createItem(new \CRM_Queue_Task(
+      [QueueTest::class, 'doSomething'],
+      ['first']
+    ));
+
+    $first = Queue::claimItems()->setQueue($queueName)->setSelect(['id', 'queue'])->execute()->single();
+    $this->assertTrue(is_numeric($first['id']));
+    $this->assertEquals($queueName, $first['queue']);
+    $this->assertFalse(isset($first['data']));
+  }
+
+  public function testEmptyPoll() {
+    $queueName = 'QueueTest_' . md5(random_bytes(32)) . '_linear';
+    $queue = \Civi::queue($queueName, ['type' => 'Sql', 'runner' => 'task', 'error' => 'delete']);
+    $this->assertEquals(0, $queue->numberOfItems());
+
+    $startResult = Queue::claimItems()->setQueue($queueName)->execute();
+    $this->assertEquals(0, $startResult->count());
+  }
+
+  public function getErrorModes(): array {
+    return [
+      'delete' => ['delete'],
+      'abort' => ['abort'],
+    ];
+  }
+
+  /**
+   * Add a task which is never going to succeed. Try it multiple times (until we run out
+   * of retries).
+   *
+   * @param string $errorMode
+   *   Either 'delete' or 'abort'
+   * @dataProvider getErrorModes
+   */
+  public function testRetryWithPoliteExhaustion(string $errorMode) {
+    $queueName = 'QueueTest_' . md5(random_bytes(32)) . '_linear';
+    $queue = \Civi::queue($queueName, [
+      'type' => 'Sql',
+      'runner' => 'task',
+      'error' => $errorMode,
+      'retry_limit' => 2,
+      'retry_interval' => 1,
+    ]);
+    $this->assertEquals(0, $queue->numberOfItems());
+
+    \Civi::queue($queueName)->createItem(new \CRM_Queue_Task(
+      [QueueTest::class, 'doSomething'],
+      ['nogooddirtyscoundrel']
+    ));
+
+    \Civi::$statics[__CLASS__]['doSomethingResult'] = FALSE;
+    $outcomes = [];
+    $this->waitFor(0.5, 15, function() use ($queueName, &$outcomes) {
+      $claimed = Queue::claimItems(0)->setQueue($queueName)->execute()->first();
+      if (!$claimed) {
+        return FALSE;
+      }
+      $result = Queue::runItems(0)->setItems([$claimed])->execute()->first();
+      $outcomes[] = $result['outcome'];
+      return ($result['outcome'] !== 'retry');
+    });
+
+    $this->assertEquals(['retry', 'retry', $errorMode], $outcomes);
+    $this->assertEquals(
+      ['nogooddirtyscoundrel_err', 'nogooddirtyscoundrel_err', 'nogooddirtyscoundrel_err'],
+      \Civi::$statics[__CLASS__]['doSomethingLog']
+    );
+
+    $expectActive = ['delete' => TRUE, 'abort' => FALSE];
+    $this->assertEquals($expectActive[$errorMode], $queue->isActive());
+  }
+
+  /**
+   * Add a task. The task-running agent is a bit delinquent... so it forgets the first
+   * few tasks. But the third one works!
+   */
+  public function testRetryWithDelinquencyAndSuccess() {
+    $queueName = 'QueueTest_' . md5(random_bytes(32)) . '_linear';
+    $queue = \Civi::queue($queueName, [
+      'type' => 'Sql',
+      'runner' => 'task',
+      'error' => 'delete',
+      'retry_limit' => 2,
+      'retry_interval' => 0,
+      'lease_time' => 1,
+    ]);
+    $this->assertEquals(0, $queue->numberOfItems());
+
+    \Civi::queue($queueName)->createItem(new \CRM_Queue_Task(
+      [QueueTest::class, 'doSomething'],
+      ['playinghooky']
+    ));
+    $this->assertEquals(1, $queue->numberOfItems());
+
+    $claim1 = $this->waitForClaim(0.5, 5, $queueName);
+    // Oops, don't do anything with claim #1!
+    $this->assertEquals(1, $queue->numberOfItems());
+    $this->assertEquals([], \Civi::$statics[__CLASS__]['doSomethingLog']);
+
+    $claim2 = $this->waitForClaim(0.5, 5, $queueName);
+    // Oops, don't do anything with claim #2!
+    $this->assertEquals(1, $queue->numberOfItems());
+    $this->assertEquals([], \Civi::$statics[__CLASS__]['doSomethingLog']);
+
+    $claim3 = $this->waitForClaim(0.5, 5, $queueName);
+    $this->assertEquals(1, $queue->numberOfItems());
+    $result = Queue::runItems(0)->setItems([$claim3])->execute()->first();
+    $this->assertEquals(0, $queue->numberOfItems());
+    $this->assertEquals(['playinghooky_ok'], \Civi::$statics[__CLASS__]['doSomethingLog']);
+    $this->assertEquals('ok', $result['outcome']);
+  }
+
+  /**
+   * Add a task which is never going to succeed. The task fails every time, and eventually
+   * we either delete it or abort the queue.
+   *
+   * @param string $errorMode
+   *   Either 'delete' or 'abort'
+   * @dataProvider getErrorModes
+   */
+  public function testRetryWithEventualFailure(string $errorMode) {
+    \Civi::$statics[__CLASS__]['doSomethingResult'] = FALSE;
+
+    $queueName = 'QueueTest_' . md5(random_bytes(32)) . '_linear';
+    $queue = \Civi::queue($queueName, [
+      'type' => 'Sql',
+      'runner' => 'task',
+      'error' => $errorMode,
+      'retry_limit' => 2,
+      'retry_interval' => 0,
+      'lease_time' => 1,
+    ]);
+    $this->assertEquals(0, $queue->numberOfItems());
+
+    \Civi::queue($queueName)->createItem(new \CRM_Queue_Task(
+      [QueueTest::class, 'doSomething'],
+      ['playinghooky']
+    ));
+    $this->assertEquals(1, $queue->numberOfItems());
+
+    $claimAndRun = function($expectOutcome, $expectEndCount) use ($queue, $queueName) {
+      $claim = $this->waitForClaim(0.5, 5, $queueName);
+      $this->assertEquals(1, $queue->numberOfItems());
+      $result = Queue::runItems(0)->setItems([$claim])->execute()->first();
+      $this->assertEquals($expectEndCount, $queue->numberOfItems());
+      $this->assertEquals($expectOutcome, $result['outcome']);
+    };
+
+    $claimAndRun('retry', 1);
+    $claimAndRun('retry', 1);
+    switch ($errorMode) {
+      case 'delete':
+        $claimAndRun('delete', 0);
+        $this->assertEquals(TRUE, $queue->isActive());
+        break;
+
+      case 'abort':
+        $claimAndRun('abort', 1);
+        $this->assertEquals(FALSE, $queue->isActive());
+        break;
+    }
+
+    $this->assertEquals(['playinghooky_err', 'playinghooky_err', 'playinghooky_err'], \Civi::$statics[__CLASS__]['doSomethingLog']);
+  }
+
+  public static function doSomething(\CRM_Queue_TaskContext $ctx, string $something) {
+    $ok = \Civi::$statics[__CLASS__]['doSomethingResult'];
+    \Civi::$statics[__CLASS__]['doSomethingLog'][] = $something . ($ok ? '_ok' : '_err');
+    return $ok;
+  }
+
+  protected function assertCallback($expectMethod, $expectArgs, $actualTask) {
+    $this->assertEquals([QueueTest::class, $expectMethod], $actualTask['data']['callback'], 'Claimed task should have expected method');
+    $this->assertEquals($expectArgs, $actualTask['data']['arguments'], 'Claimed task should have expected arguments');
+  }
+
+  protected function waitForClaim(float $interval, float $timeout, string $queueName): ?array {
+    $claims = [];
+    $this->waitFor($interval, $timeout, function() use ($queueName, &$claims) {
+      $claimed = Queue::claimItems(0)->setQueue($queueName)->execute()->first();
+      if (!$claimed) {
+        return FALSE;
+      }
+      $claims[] = $claimed;
+      return TRUE;
+    });
+    return $claims[0] ?? NULL;
+  }
+
+  /**
+   * Repeatedly check $condition until it returns true (or until we exhaust timeout).
+   *
+   * @param float $interval
+   *   Seconds to wait between checks.
+   * @param float $timeout
+   *   Total maximum seconds to wait across all checks.
+   * @param callable $condition
+   *   The condition to check.
+   * @return int
+   *   Total number of intervals we had to wait/sleep.
+   */
+  protected function waitFor(float $interval, float $timeout, callable $condition): int {
+    $end = microtime(TRUE) + $timeout;
+    $interval *= round($interval * 1000 * 1000);
+    $waitCount = 0;
+    $ready = $condition();
+    while (!$ready && microtime(TRUE) <= $end) {
+      usleep($interval);
+      $waitCount++;
+      $ready = $condition();
+    }
+    $this->assertTrue($ready, 'Wait condition not met');
+    return $waitCount;
+  }
+
+}
index 3c8a18ff5559d9f51b8b3cff315097d76e22fd93..c6b9c3ef91609a112214a4efec6a67b09aacd9ba 100644 (file)
@@ -29,6 +29,9 @@
     <length>64</length>
     <required>true</required>
     <comment>Machine name for Case Type</comment>
+    <html>
+      <type>Text</type>
+    </html>
     <add>4.5</add>
   </field>
   <index>
@@ -45,6 +48,9 @@
     <required>true</required>
     <localizable>true</localizable>
     <comment>Natural language name for Case Type</comment>
+    <html>
+      <type>Text</type>
+    </html>
     <add>4.5</add>
   </field>
   <field>
@@ -54,6 +60,9 @@
     <length>255</length>
     <localizable>true</localizable>
     <comment>Description of the Case Type</comment>
+    <html>
+      <type>Text</type>
+    </html>
     <add>4.5</add>
   </field>
   <field>
@@ -61,6 +70,9 @@
     <title>Case Type Is Active</title>
     <type>boolean</type>
     <comment>Is this case type enabled?</comment>
+    <html>
+      <type>CheckBox</type>
+    </html>
     <default>1</default>
     <required>true</required>
     <add>4.5</add>
@@ -72,6 +84,9 @@
     <default>0</default>
     <required>true</required>
     <comment>Is this case type a predefined system type?</comment>
+    <html>
+      <type>CheckBox</type>
+    </html>
     <add>4.5</add>
   </field>
   <field>
@@ -81,6 +96,9 @@
     <required>true</required>
     <default>1</default>
     <comment>Ordering of the case types</comment>
+    <html>
+      <type>Number</type>
+    </html>
     <add>4.5</add>
   </field>
   <field>
index 6ebb5f7df9adb0c112032679bd594cd98f1e98b5..4b9c1019fdc9904923d121369084d93334361bc2 100644 (file)
     <type>varchar</type>
     <length>8</length>
     <default>"Both"</default>
-    <import>true</import>
+    <import>false</import>
     <headerPattern>/^p(ref\w*\s)?m(ail\s)?f(orm\w*)$/i</headerPattern>
     <comment>What is the preferred mode of sending an email.</comment>
     <add>1.1</add>
index 6aa5bc4e9a187199496d1362dd04a3d822bc6c88..c5d470abee1225e3b14e9a993be19667f27da3d8 100644 (file)
@@ -66,6 +66,7 @@
       <labelColumn>name</labelColumn>
     </pseudoconstant>
     <export>true</export>
+    <import>true</import>
     <html>
       <type>Select</type>
       <label>Financial Type</label>
     <type>int unsigned</type>
     <comment>FK to Payment Instrument</comment>
     <export>true</export>
+    <import>true</import>
     <headerPattern>/^payment|(p(ayment\s)?instrument)$/i</headerPattern>
     <pseudoconstant>
       <optionGroupName>payment_instrument</optionGroupName>
index d44b917e75d7f8f380b1d2095da9caf7a6359859..9f91aa5800ecf1bae1fe2f59969101ea3e3bfa13 100644 (file)
     </html>
     <add>5.48</add>
   </field>
+  <field>
+    <name>status</name>
+    <title>Status</title>
+    <type>varchar</type>
+    <length>16</length>
+    <comment>Execution status</comment>
+    <required>false</required>
+    <default>'active'</default>
+    <html>
+      <type>Text</type>
+    </html>
+    <add>5.51</add>
+    <pseudoconstant>
+      <callback>CRM_Queue_BAO_Queue::getStatuses</callback>
+    </pseudoconstant>
+  </field>
+  <field>
+    <name>error</name>
+    <title>Error Mode</title>
+    <type>varchar</type>
+    <length>16</length>
+    <comment>Fallback behavior for unhandled errors</comment>
+    <required>false</required>
+    <html>
+      <type>Text</type>
+    </html>
+    <add>5.51</add>
+    <pseudoconstant>
+      <callback>CRM_Queue_BAO_Queue::getErrorModes</callback>
+    </pseudoconstant>
+  </field>
 </table>
index afb0192fc690299090d00588521e13f2a87217aa..50a11fd18b8c203105ae700521cdd59b310b295e 100644 (file)
@@ -32,7 +32,7 @@ CREATE TABLE `{$table.name}` ({assign var='first' value=true}
 {foreach from=$table.fields item=field}
 {if ! $first},{/if}{assign var='first' value=false}
 
-  `{$field.name}` {$field.sqlType}{if $field.collate} COLLATE {$field.collate}{/if}{if $field.required} {if $field.required == "false"}NULL{else}NOT NULL{/if}{/if}{if isset($field.autoincrement)} AUTO_INCREMENT{/if}{if $field.default|count_characters} DEFAULT {$field.default}{/if}{if $field.comment} COMMENT '{ts escape=sql}{$field.comment}{/ts}'{/if}
+  `{$field.name}` {$field.sqlType}{if $field.collate} COLLATE {$field.collate}{/if}{if $field.required} {if $field.required == "false"}NULL{else}NOT NULL{/if}{/if}{if isset($field.autoincrement)} AUTO_INCREMENT{/if}{if $field.default|crmCountCharacters} DEFAULT {$field.default}{/if}{if $field.comment} COMMENT '{ts escape=sql}{$field.comment}{/ts}'{/if}
 {/foreach}{* table.fields *}{strip}
 
 {/strip}{if $table.primaryKey}{if !$first},