*/
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'),
- ]);
}
/**
*/
public function postProcess() {
$this->storeFormValues([
- 'onDuplicate',
'dateFormats',
'savedMapping',
]);
$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;
+ }
+
}
$this->controller->resetPage($this->_name);
return;
}
+ $this->updateUserJobMetadata('submitted_values', $this->getSubmittedValues());
- $mapperKeys = [];
$mapper = [];
$mapperKeys = $this->controller->exportValue($this->_name, 'mapper');
$mapperKeysMain = [];
}
$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
);
$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;
+ }
+
}
}
$parser = new CRM_Activity_Import_Parser_Activity($mapperKeys);
-
+ $parser->setUserJobID($this->getUserJobID());
$mapFields = $this->get('fields');
foreach ($mapper as $key => $value) {
}
}
+ /**
+ * @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;
+ }
+
}
$this->assign('errorFile', $this->get('errorFile'));
$totalRowCount = $this->get('totalRowCount');
- $relatedCount = $this->get('relatedCount');
- $totalRowCount += $relatedCount;
$this->set('totalRowCount', $totalRowCount);
$invalidRowCount = $this->get('invalidRowCount');
protected $_mapperKeys;
- private $_contactIdIndex;
-
/**
* Array of successfully imported activity id's
*
$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++;
- }
}
/**
}
}
- 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;
$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);
}
->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);
*
* Generated from xml/schema/CRM/Case/CaseType.xml
* DO NOT EDIT. Generated by CRM_Core_CodeGen
- * (GenCodeChecksum:7b3029a4b42f22a060fadb39b7b2c678)
+ * (GenCodeChecksum:92eb680369ce37591734a961f22ce831)
*/
/**
'entity' => 'CaseType',
'bao' => 'CRM_Case_BAO_CaseType',
'localizable' => 0,
+ 'html' => [
+ 'type' => 'Text',
+ ],
'add' => '4.5',
],
'title' => [
'entity' => 'CaseType',
'bao' => 'CRM_Case_BAO_CaseType',
'localizable' => 1,
+ 'html' => [
+ 'type' => 'Text',
+ ],
'add' => '4.5',
],
'description' => [
'entity' => 'CaseType',
'bao' => 'CRM_Case_BAO_CaseType',
'localizable' => 1,
+ 'html' => [
+ 'type' => 'Text',
+ ],
'add' => '4.5',
],
'is_active' => [
'entity' => 'CaseType',
'bao' => 'CRM_Case_BAO_CaseType',
'localizable' => 0,
+ 'html' => [
+ 'type' => 'CheckBox',
+ ],
'add' => '4.5',
],
'is_reserved' => [
'entity' => 'CaseType',
'bao' => 'CRM_Case_BAO_CaseType',
'localizable' => 0,
+ 'html' => [
+ 'type' => 'CheckBox',
+ ],
'add' => '4.5',
],
'weight' => [
'entity' => 'CaseType',
'bao' => 'CRM_Case_BAO_CaseType',
'localizable' => 0,
+ 'html' => [
+ 'type' => 'Number',
+ ],
'add' => '4.5',
],
'definition' => [
}
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];
*
*/
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);
- }
- }
}
/**
*
* @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'];
}
*/
use Civi\Api4\Contact;
+use Civi\Api4\Relationship;
/**
*
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],
// 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,
])) {
$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);
}
])) {
$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;
}
*
* Generated from xml/schema/CRM/Contact/Contact.xml
* DO NOT EDIT. Generated by CRM_Core_CodeGen
- * (GenCodeChecksum:67196fefde2ec151c97d463869102e21)
+ * (GenCodeChecksum:6c4b31481898fef1b087265d096c65f6)
*/
/**
'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',
/**
* 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.
*/
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
}
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();
}
/**
$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,
];
}
}
// 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]);
}
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);
}
/**
* @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
$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();
}
/**
$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;
}
}
$relatedContactIds = $this->_parser->getRelatedImportedContacts();
if ($relatedContactIds) {
$contactIds = array_merge($contactIds, $relatedContactIds);
- if ($form) {
- $form->set('relatedCount', count($relatedContactIds));
- }
}
if ($this->_newGroupName || count($this->_groups)) {
use Civi\Api4\Contact;
use Civi\Api4\RelationshipType;
+use Civi\Api4\StateProvince;
require_once 'api/v3/utils.php';
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.
*/
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.
*
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.
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);
}
/**
* 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);
}
* @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;
}
//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) {
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;
}
/**
}
/**
- * 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');
}
}
}
-
+ $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)) {
$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;
}
$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;
}
_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;
}
/**
- * 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);
+ }
}
}
* @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);
$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[] = [
$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 {
$loc = CRM_Utils_Array::key($index, $locationType);
- $blockName = $this->getLocationEntityForKey($fieldName);
+ $blockName = strtolower($this->getFieldEntity($fieldName));
$data[$blockName][$loc]['location_type_id'] = $locTypeId;
$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') {
$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;
}
}
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'];
}
}
}
- 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']))) {
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.
*
CRM_Core_BAO_CustomGroup::setDefaults($groupTree, $defaults, FALSE, FALSE);
$locationFields = [
- 'email' => 'email',
- 'phone' => 'phone',
- 'im' => 'name',
- 'website' => 'website',
'address' => 'address',
];
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]);
}
}
}
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]);
}
}
}
}
/**
- * 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 '';
}
/**
$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.
*/
$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.
*
$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;
}
/**
// Location
// Address
// Email
- // Phone
// IM
// Note
// Custom
// @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'])) {
/**
* 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
$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;
}
$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,
'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,
];
}
* @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();
*/
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;
}
/**
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.
*
*/
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;
}
}
*/
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
* 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) {
}
// 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];
+ }
+
}
$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.
$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;
}
$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;
+ }
}
}
}
*
* Generated from xml/schema/CRM/Contribute/Contribution.xml
* DO NOT EDIT. Generated by CRM_Core_CodeGen
- * (GenCodeChecksum:edd96be2e997a659ceeee0cf823c6f90)
+ * (GenCodeChecksum:0f869aa62eb1a94aedf6009a988cf01d)
*/
/**
'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',
'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,
*/
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]);
+ }
}
}
// 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
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;
$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;
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']);
}
}
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;
}
*
* Completing will trigger update of related entities and emails.
*
+ * @deprecated
+ *
* @param array $result
* @param int $contributionID
*
* @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', [
//pcp elements
if ($this->_pcpId) {
$qParams .= "&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]);
+ }
}
}
}
$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;
+ }
+
}
$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) {
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) {
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')) {
$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');
$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'),
* @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;
}
}
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
}
$properties = [
- 'mapper',
- 'softCreditFields',
- 'mapperSoftCreditType',
'dataValues',
'columnCount',
'totalRowCount',
'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');
}
}
+ /**
+ * @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;
+ }
+
}
$this->assign('errorFile', $this->get('errorFile'));
$totalRowCount = $this->get('totalRowCount');
- $relatedCount = $this->get('relatedCount');
- $totalRowCount += $relatedCount;
$this->set('totalRowCount', $totalRowCount);
$invalidRowCount = $this->get('invalidRowCount');
* @copyright CiviCRM LLC https://civicrm.org/licensing
*/
+use Civi\Api4\Contact;
+use Civi\Api4\Email;
+
/**
* Class to parse contribution csv files.
*/
protected $_mapperKeys;
- private $_contactIdIndex;
-
- protected $_mapperSoftCredit;
- //protected $_mapperPhoneType;
-
/**
* Array of successfully imported contribution id's
*
* 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;
}
/**
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();
}
/**
- * 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;
$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++;
- }
}
/**
* 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));
$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";
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 === '') {
) {
$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;
}
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']);
}
}
- if ($this->_contactIdIndex < 0) {
+ if (empty($formatted['contact_id'])) {
$error = $this->checkContactDuplicate($paramValues);
* @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';
}
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");
}
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':
}
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;
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;
* @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,
];
}
+ /**
+ * 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);
+ }
+
}
$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
--- /dev/null
+<?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: */
$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;
+ }
+
}
}
$parser = new $this->_parser($mapperKeys);
+ $parser->setUserJobID($this->getUserJobID());
$parser->setEntity($entity);
$mapFields = $this->get('fields');
}
}
+ /**
+ * @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;
+ }
+
}
$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) {
continue;
}
$individualTaxAmount = 0;
+ $append = '';
//display tax amount on confirmation page
$taxAmount += $v['tax_amount'];
if (is_array($v)) {
$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
$form->_paymentProcessor = $params['paymentProcessorObj'];
}
$form->postProcess();
+ return $form;
}
/**
$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;
+ }
+
}
$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;
+ }
+
}
}
$parser = new CRM_Event_Import_Parser_Participant($mapperKeys);
-
+ $parser->setUserJobID($this->getUserJobID());
$mapFields = $this->get('fields');
foreach ($mapper as $key => $value) {
}
}
+ /**
+ * @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;
+ }
+
}
$this->assign('errorFile', $this->get('errorFile'));
$totalRowCount = $this->get('totalRowCount');
- $relatedCount = $this->get('relatedCount');
- $totalRowCount += $relatedCount;
$this->set('totalRowCount', $totalRowCount);
$invalidRowCount = $this->get('invalidRowCount');
}
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;
}
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;
}
$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";
}
}
- //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 === '') {
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;
+ }
+
}
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 = [];
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();
+ }
+
}
*/
protected $userJob;
+ /**
+ * @var \CRM_Import_Parser
+ */
+ protected $parser;
+
/**
* Get User Job.
*
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;
+ }
+
}
'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';
$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.
*
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.
*
* @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;
}
/**
+--------------------------------------------------------------------+
*/
+use Civi\Api4\CustomField;
use Civi\Api4\UserJob;
/**
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
return $this;
}
+ /**
+ * Countries that the site is restricted to
+ *
+ * @var array|false
+ */
+ private $availableCountries;
+
/**
* Get User Job.
*
// Duplicates are being skipped so id matching is not availble.
continue;
}
- $return[$name] = $field['title'];
+ $return[$name] = $field['html']['label'] ?? $field['title'];
}
return $return;
}
* file
* @var array
*/
- protected $_activeFields;
+ protected $_activeFields = [];
/**
* Cache the count of active fields
*/
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;
}
// 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)) {
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');
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;
$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));
}
/**
* @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) {
$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;
}
/**
* @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;
}
* @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) {
}
return $fieldMetaData['label'];
}
- elseif ($dataType == 'Boolean') {
+ elseif ($dataType === 'Boolean') {
if (CRM_Utils_String::strtoboolstr($value) === FALSE) {
return $fieldMetaData['label'] . '::' . $fieldMetaData['groupTitle'];
}
// 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) {
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;
+ }
+
}
}
$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");
$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");
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;
}
/**
* @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();
$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'];
}
[$details] = CRM_Utils_Token::getTokenDetails(
$params,
$returnProperties,
- $skipOnHold, TRUE, NULL,
+ TRUE, TRUE, NULL,
$mailing->getFlattenedTokens(),
get_class($this),
$this->id
$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;
--- /dev/null
+<?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;
+ }
+
+}
// 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'));
}
// 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);
$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) {
$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;
+ }
+
}
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
$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;
+ }
+
}
$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);
}
}
+ /**
+ * @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;
+ }
+
}
$this->assign('errorFile', $this->get('errorFile'));
$totalRowCount = $this->get('totalRowCount');
- $relatedCount = $this->get('relatedCount');
- $totalRowCount += $relatedCount;
$this->set('totalRowCount', $totalRowCount);
$invalidRowCount = $this->get('invalidRowCount');
$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";
$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()) {
/**
* 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.
];
}
+ /**
+ * 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);
+ }
+ }
+
}
*
* Generated from xml/schema/CRM/Queue/Queue.xml
* DO NOT EDIT. Generated by CRM_Core_CodeGen
- * (GenCodeChecksum:3b50eca7549430727237a4b2e295df1f)
+ * (GenCodeChecksum:0b068d0a6ba5d6348f11706b3854feb1)
*/
/**
*/
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.
*/
],
'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']);
}
*/
abstract class CRM_Queue_Queue {
+ const DEFAULT_LEASE_TIME = 3600;
+
/**
* @var string
*/
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'],
+ ]);
}
/**
/**
* 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
* @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.
*/
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.
*
--- /dev/null
+<?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;
+
+}
*/
public $releaseTimes;
+ /**
+ * Number of times each queue item has been attempted.
+ *
+ * @var array
+ * array(queueItemId => int $count),
+ */
+ protected $runCounts;
+
public $nextQueueItemId = 1;
/**
public function createQueue() {
$this->items = [];
$this->releaseTimes = [];
+ $this->runCounts = [];
}
/**
public function deleteQueue() {
$this->items = NULL;
$this->releaseTimes = NULL;
+ $this->runCounts = NULL;
}
/**
$id = $this->nextQueueItemId++;
// force copy, no unintendedsharing effects from pointers
$this->items[$id] = serialize($data);
+ $this->runCounts[$id] = 0;
}
/**
/**
* 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 {
/**
* 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
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;
}
/**
* 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'];
+ }
}
}
/**
* 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
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));
/**
* 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
$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'],
/**
* 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;
}
/**
- * 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')) {
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;');
/**
* 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
$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;
/**
* 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();
+ }
+ }
}
}
* 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 {
* @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
* - 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.
if (!empty($queueSpec['is_persistent'])) {
$queueSpec = $this->findCreateQueueSpec($queueSpec);
}
+ $this->validateQueueSpec($queueSpec);
$queue = $this->instantiateQueueObject($queueSpec);
$exists = $queue->existsQueue();
if (!$exists) {
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'];
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);
+ }
+ }
+
}
--- /dev/null
+<?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;
+ }
+
+}
// 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);
}
*/
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
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;
}
/**
$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']])) {
* 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;
});
array_map(['CRM_Core_BAO_SchemaHandler', 'dropTable'], $oldTables);
+ return TRUE;
}
}
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';
+ }
+
}
}
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)}");
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];
}
return FALSE;
}
- $config = CRM_Core_Config::singleton();
-
$add = '';
if (!empty($values['street_address'])) {
$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);
}
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)) {
) {
$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 [];
}
}
}
/**
- * Reset geoprovider (after it has been disabled).
+ * Reset geoprovider (after settting has been changed).
*/
public static function reset() {
self::$providerClassName = NULL;
- self::getUsableClassName();
}
}
);
}
+ /**
+ * 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
*
}
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)
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();
}
* 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);
}
$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
$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
//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;
}
}
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) {
}
}
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'])) {
--- /dev/null
+<?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']);
+ }
+ }
+
+}
--- /dev/null
+<?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);
+ }
+
+}
--- /dev/null
+<?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];
+ }
+
+}
->setCheckPermissions($checkPermissions);
}
+ /**
+ * @param bool $checkPermissions
+ * @return Action\Address\GetCoordinates
+ */
+ public static function getCoordinates($checkPermissions = TRUE) {
+ return (new Action\Address\GetCoordinates(__CLASS__, __FUNCTION__))
+ ->setCheckPermissions($checkPermissions);
+ }
+
}
* This contains configuration settings for each type of CiviCase.
*
* @see \Civi\Api4\Case
- * @searchable none
+ * @searchable secondary
* @since 5.37
* @package Civi\Api4
*/
'Radio' => ts('Radio Buttons'),
'Select' => ts('Select'),
'Text' => ts('Text'),
+ 'Location' => ts('Address Location'),
],
],
[
*/
namespace Civi\Api4;
+use Civi\Api4\Action\Queue\ClaimItems;
+use Civi\Api4\Action\Queue\RunItems;
+
/**
* Track a list of durable/scannable queues.
*
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);
+ }
+
}
--- /dev/null
+<?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)';
+ }
+
+}
$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
$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];
}
}
}
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);
}
}
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();
$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;
$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];
}
- github : brianPHM
name : Brian Matemachani
+- github : briennekordis
+ name : Brienne Kordis
+ organization: Megaphone Technology Consulting
+
- github : brucew2013
name : Bruce Wolfe
organization: Alcohol Justice
*
* @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);
+}
</urls>
<releaseDate>2021-02-11</releaseDate>
<version>5.51.alpha1</version>
- <develStage>alpha</develStage>
+ <develStage>stable</develStage>
<compatibility>
<ver>5.51</ver>
</compatibility>
*/
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'),
];
}
$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);
}
$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'] : '',
}
/**
- * @return array|null
+ * @return array
*/
public function setDefaultValues() {
if (!empty($this->_formValues)) {
$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;
}
/**
'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')
return expr.indexOf('(') > -1;
};
+ this.areFunctionsAllowed = function(expr) {
+ return this.allowFunctions && ctrl.getField(expr).type !== 'Filter';
+ };
+
this.addGroup = function(op) {
ctrl.clauses.push([op, []]);
};
</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>
-<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>
},
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';
}
--- /dev/null
+<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>
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
- **[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
- **[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
- **[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
- **[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
--- /dev/null
+# 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`.
--- /dev/null
+# 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`.
--- /dev/null
+# 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`.
--- /dev/null
+# 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`.
--- /dev/null
+# 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`.
--- /dev/null
+# 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`.
--- /dev/null
+# 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`.
--- /dev/null
+# 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`.
--- /dev/null
+# 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`.
--- /dev/null
+# 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`.
--- /dev/null
+# 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`.
--- /dev/null
+# 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`.
--- /dev/null
+# 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`.
Released June 1, 2022
- **[Synopsis](#synopsis)**
+- **[Security advisories](#security)**
- **[Features](#features)**
- **[Bugs resolved](#bugs)**
- **[Miscellany](#misc)**
| *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 |
| **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
- **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))**
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))**
([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
([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))**
- **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))**
- **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
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
([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))**
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
'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.'),
| 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"}
<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}
</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'}
- <span id="contact-subtype">{$form.contactSubType.label} {$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 />
- <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'}
+ <span id="contact-subtype">{$form.contactSubType.label} {$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 />
+ <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>
<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}
</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}
{* 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}
</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>
],
'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' => '',
'activity_date_time' => $some_date,
'activity_subject' => 'asubj',
],
- 'expected_error' => '',
+ 'expected_error' => 'No matching Contact found for ()',
],
];
*
* test with empty params.
*/
- public function testAddWithEmptyParams() {
+ public function testAddWithEmptyParams(): void {
$params = [];
$contact = CRM_Contact_BAO_Contact::add($params);
*
* Test with names (create and update modes)
*/
- public function testAddWithNames() {
+ public function testAddWithNames(): void {
$firstName = 'Shane';
$lastName = 'Whatson';
$params = [
* 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.');
$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',
],
'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',
'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 = [
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.'
- );
}
/**
//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."
);
'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 = [
'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 = [
//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."
);
$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 = [
],
'contact_source' => 'test contact',
'external_identifier' => 123456789,
- 'preferred_mail_format' => 'Both',
'is_opt_out' => 1,
'legal_identifier' => '123456789',
'image_URL' => 'http://image.com',
'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'] = [];
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]],
],
[
],
[
['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]' => []],
],
];
$defaults = [];
$defaults["mapper[$columnNumber]"] = $processor->getSavedQuickformDefaultsForColumn($columnNumber);
- $js = $processor->getQuickFormJSForField($columnNumber);
- return ['defaults' => $defaults, 'js' => $js];
+ return ['defaults' => $defaults];
}
/**
--- /dev/null
+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,
--- /dev/null
+first_name,last_name,geocodeone,GeoCodetwo,expected
+Madame,1,1,-1,Valid
+Madame,2,a,b,Invalid
+Madame,3,1.1123,-1.1123,Valid
--- /dev/null
+Main Contact First Name,Main Contact LastName,Employer ext id
+Bob,Smith,qwerty
-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
--- /dev/null
+First Name,Last Name,Street Address
+Sally,Smith,Grange House
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.
*/
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();
* @throws \API_Exception
* @throws \CRM_Core_Exception
* @throws \CiviCRM_API3_Exception
- * @throws \Civi\API\Exception\UnauthorizedException
*/
public function testImportParserWithEmployeeOfRelationship(): void {
$this->organizationCreate([
$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',
'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']);
// 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);
*
* @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';
$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']);
/**
* 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 => [
*
* @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']);
*
* @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', [
];
// 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);
*
* @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']]);
}
'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.
*
'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')]);
}
'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')]);
}
'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);
}
/**
*
* @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]);
*
* @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'];
*
* @throws \Exception
*/
- public function testImportPrimaryAddressUpdate() {
+ public function testImportPrimaryAddressUpdate(): void {
[$contactValues] = $this->setUpBaseContact(['external_identifier' => 'android']);
$contactValues['email'] = 'melinda.gates@microsoft.com';
$contactValues['phone'] = '98765';
$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);
}
'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.
$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.
*
['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';
/**
* 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';
[$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],
[$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']);
}
/**
}
/**
- * 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',
'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']);
}
/**
$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.');
}
/**
*
* @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.
* @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');
}
/**
])->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
$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.
*
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.
*
}
}
+ /**
+ * 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);
+ }
+
}
'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)',
. ' 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`,'
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',
use Civi\Api4\Contribution;
use Civi\Api4\ContributionSoft;
use Civi\Api4\OptionValue;
+use Civi\Api4\UserJob;
/**
* Test Contribution import parser.
$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');
$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']);
*/
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]);
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]);
$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");
*
* @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
*
+++ /dev/null
-External Identifier,Total Amount,Receive Date,Financial Type,Soft Credit to
-bob,65,2008-09-20,Donation,mum@example.com
+++ /dev/null
-External Identifier,Total Amount,Receive Date,Financial Type,Soft Credit to
-bob,65,2008-09-20,Donation,mum@example.com
'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',
*/
class CRM_Dedupe_MergerTest extends CiviUnitTestCase {
+ use CRMTraits_Custom_CustomDataTrait;
+
protected $_groupId;
protected $_contactIds = [];
* @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',
$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().
*
$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;
+ }
+
}
$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&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&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.
*
'Image Url' => '',
'Preferred Communication Method' => '',
'Preferred Language' => 'en_US',
- 'Preferred Mail Format' => 'Both',
'Contact Hash' => '059023a02d27d4e7f285a40ee0e30be8',
'Contact Source' => '',
'First Name' => 'Anthony',
'Image Url' => '',
'Preferred Communication Method' => '',
'Preferred Language' => 'en_US',
- 'Preferred Mail Format' => 'Both',
'Contact Hash' => 'e9bd0913cc05cc5aeae69ba04ee3be84',
'Contact Source' => '',
'First Name' => 'Anthony',
'image_URL' => 1,
'preferred_communication_method' => 1,
'preferred_language' => 1,
- 'preferred_mail_format' => 1,
'hash' => 1,
'contact_source' => 1,
'first_name' => 1,
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;
}
'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)',
'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)',
/**
* Test Import.
- *
- * @throws \CRM_Core_Exception
- * @throws \CiviCRM_API3_Exception
*/
- public function testImport() {
+ public function testImport(): void {
$this->individualCreate();
$contact2Params = [
'first_name' => 'Anthonita',
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
*
$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([
$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());
$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());
$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
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);
$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
// 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);
$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());
+ }
+
}
/**
* Test SMS preview.
*/
- public function testSMSPreview() {
+ public function testSMSPreview(): void {
$result = $this->callAPISuccess('SmsProvider', 'create', [
'title' => 'test SMS provider',
'username' => 'test',
$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,
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 [];
}
}
* 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';
$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'] = [];
+ }
+ }
}
$this->locationTypeDelete($this->_locationTypeID);
$this->contactDelete($this->_contactID);
$this->quickCleanup(['civicrm_address', 'civicrm_relationship']);
+ $this->callAPISuccess('Setting', 'create', ['geoProvider' => NULL]);
parent::tearDown();
}
$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']);
$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]);
+ }
+
}
'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']);
}
}
}
}
+ 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']);
+ }
+
}
$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']);
}
/**
--- /dev/null
+<?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);
+ }
+
+}
->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() {
->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() {
)
->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'])
$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'])
$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'])
$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);
}
}
$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,
}
}
+ // 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();
*/
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
*/
$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);
+ }
+
}
--- /dev/null
+<?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;
+ }
+
+}
<length>64</length>
<required>true</required>
<comment>Machine name for Case Type</comment>
+ <html>
+ <type>Text</type>
+ </html>
<add>4.5</add>
</field>
<index>
<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>
<length>255</length>
<localizable>true</localizable>
<comment>Description of the Case Type</comment>
+ <html>
+ <type>Text</type>
+ </html>
<add>4.5</add>
</field>
<field>
<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>
<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>
<required>true</required>
<default>1</default>
<comment>Ordering of the case types</comment>
+ <html>
+ <type>Number</type>
+ </html>
<add>4.5</add>
</field>
<field>
<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>
<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>
</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>
{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},