3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
15 * @copyright CiviCRM LLC https://civicrm.org/licensing
18 use Civi\Api4\UserJob
;
19 use League\Csv\Writer
;
22 * This class helps the forms within the import flow access submitted & parsed values.
24 class CRM_Import_Forms
extends CRM_Core_Form
{
29 * This is the primary key of the civicrm_user_job table which is used to
39 public function getUserJobID(): ?
int {
40 if (!$this->userJobID
&& $this->get('user_job_id')) {
41 $this->userJobID
= $this->get('user_job_id');
43 return $this->userJobID
;
49 * @param int $userJobID
51 public function setUserJobID(int $userJobID): void
{
52 $this->userJobID
= $userJobID;
53 // This set allows other forms in the flow ot use $this->get('user_job_id').
54 $this->set('user_job_id', $userJobID);
60 * This is the relevant row from civicrm_user_job.
67 * @var \CRM_Import_Parser
74 * API call to retrieve the userJob row.
78 * @throws \API_Exception
80 protected function getUserJob(): array {
81 if (!$this->userJob
) {
82 $this->userJob
= UserJob
::get()
83 ->addWhere('id', '=', $this->getUserJobID())
87 return $this->userJob
;
91 * Get submitted values stored in the user job.
94 * @throws \API_Exception
96 protected function getUserJobSubmittedValues(): array {
97 return $this->getUserJob()['metadata']['submitted_values'];
101 * Fields that may be submitted on any form in the flow.
105 protected $submittableFields = [
106 // Skip column header is actually a field that would be added from the
107 // datasource - but currently only in contact, it is always there for
108 // other imports, ditto uploadFile.
109 'skipColumnHeader' => 'DataSource',
110 'fieldSeparator' => 'DataSource',
111 'uploadFile' => 'DataSource',
112 'contactType' => 'DataSource',
113 'contactSubType' => 'DataSource',
114 'dateFormats' => 'DataSource',
115 'savedMapping' => 'DataSource',
116 'dataSource' => 'DataSource',
117 'dedupe_rule_id' => 'DataSource',
118 'onDuplicate' => 'DataSource',
119 'disableUSPS' => 'DataSource',
120 'doGeocodeAddress' => 'DataSource',
121 'multipleCustomData' => 'DataSource',
122 // Note we don't add the save mapping instructions for MapField here
123 // (eg 'updateMapping') - as they really are an action for that form
124 // rather than part of the mapping config.
125 'mapper' => 'MapField',
129 * Get the submitted value, accessing it from whatever form in the flow it is
132 * @param string $fieldName
136 public function getSubmittedValue(string $fieldName) {
137 if ($fieldName === 'dataSource') {
138 // Hard-coded handling for DataSource as it affects the contents of
139 // getSubmittableFields and can cause a loop.
140 // Note that the non-contact imports are not currently sharing the DataSource.tpl
141 // that adds the CSV/SQL options & hence fall back on this hidden field.
142 // - todo - switch to the same DataSource.tpl for all.
143 return $this->controller
->exportValue('DataSource', 'dataSource') ??
$this->controller
->exportValue('DataSource', 'hidden_dataSource');
145 $mappedValues = $this->getSubmittableFields();
146 if (array_key_exists($fieldName, $mappedValues)) {
147 return $this->controller
->exportValue($mappedValues[$fieldName], $fieldName);
149 return parent
::getSubmittedValue($fieldName);
154 * Get values submitted on any form in the multi-page import flow.
158 public function getSubmittedValues(): array {
160 foreach (array_keys($this->getSubmittableFields()) as $key) {
161 $values[$key] = $this->getSubmittedValue($key);
167 * Get the available datasource.
169 * Permission dependent, this will look like
171 * 'CRM_Import_DataSource_CSV' => 'Comma-Separated Values (CSV)',
172 * 'CRM_Import_DataSource_SQL' => 'SQL Query',
175 * The label is translated.
179 protected function getDataSources(): array {
181 foreach (['CRM_Import_DataSource_SQL', 'CRM_Import_DataSource_CSV'] as $dataSourceClass) {
182 $object = new $dataSourceClass();
183 if ($object->checkPermission()) {
184 $dataSources[$dataSourceClass] = $object->getInfo()['title'];
191 * Get the name of the datasource class.
193 * This function prioritises retrieving from GET and POST over 'submitted'.
194 * The reason for this is the submitted array will hold the previous submissions
195 * data until after buildForm is called.
197 * This is problematic in the forward->back flow & option changing flow. As in....
199 * 1) Load DataSource form - initial default datasource is set to CSV and the
200 * form is via ajax (this calls DataSourceConfig to get the data).
201 * 2) User changes the source to SQL - the ajax updates the html but the
202 * form was built with the expectation that the csv-specific fields would be
204 * 3) When the user submits Quickform calls preProcess and buildForm and THEN
205 * retrieves the submitted values based on what has been added in buildForm.
206 * Only the submitted values for fields added in buildForm are available - but
207 * these have to be added BEFORE the submitted values are determined. Hence
208 * we look in the POST or GET to get the updated value.
210 * Note that an imminent refactor will involve storing the values in the
211 * civicrm_user_job table - this will hopefully help with a known (not new)
212 * issue whereby the previously submitted values (eg. skipColumnHeader has
213 * been checked or sql has been filled in) are not loaded via the ajax request.
215 * @return string|null
217 * @throws \CRM_Core_Exception
219 protected function getDataSourceClassName(): string {
220 $className = CRM_Utils_Request
::retrieveValue(
225 $className = $this->getSubmittedValue('dataSource');
228 $className = $this->getDefaultDataSource();
230 if ($this->getDataSources()[$className]) {
233 throw new CRM_Core_Exception('Invalid data source');
237 * Allow the datasource class to add fields.
239 * This is called as a snippet in DataSourceConfig and
240 * also from DataSource::buildForm to add the fields such
241 * that quick form picks them up.
243 * @throws \CRM_Core_Exception
245 protected function buildDataSourceFields(): void
{
246 $dataSourceClass = $this->getDataSourceObject();
247 if ($dataSourceClass) {
248 $dataSourceClass->buildQuickForm($this);
253 * Flush datasource on re-submission of the form.
255 * If the form has been re-submitted the datasource might have changed.
256 * We tell the dataSource class to remove any tables (and potentially files)
257 * created last form submission.
259 * If the DataSource in use is unchanged (ie still CSV or still SQL)
260 * we also pass in the new variables. In theory it could decide that they
261 * have not actually changed and it doesn't need to do any cleanup.
263 * In practice the datasource classes blast away as they always have for now
264 * - however, the sql class, for example, might realise the fields it cares
265 * about are unchanged and not flush the table.
267 * @throws \API_Exception
268 * @throws \CRM_Core_Exception
270 protected function flushDataSource(): void
{
271 // If the form has been resubmitted the datasource might have changed.
272 // We give the datasource a chance to clean up any tables it might have
273 // created. If we are still using the same type of datasource (e.g still
275 $oldDataSource = $this->getUserJobSubmittedValues()['dataSource'];
276 $oldDataSourceObject = new $oldDataSource($this->getUserJobID());
277 $newParams = $this->getSubmittedValue('dataSource') === $oldDataSource ?
$this->getSubmittedValues() : [];
278 $oldDataSourceObject->purge($newParams);
282 * Get the relevant datasource object.
284 * @return \CRM_Import_DataSource|null
286 * @throws \CRM_Core_Exception
288 protected function getDataSourceObject(): ?CRM_Import_DataSource
{
289 $className = $this->getDataSourceClassName();
291 /* @var CRM_Import_DataSource $dataSource */
292 return new $className($this->getUserJobID());
298 * Allow the datasource class to add fields.
300 * This is called as a snippet in DataSourceConfig and
301 * also from DataSource::buildForm to add the fields such
302 * that quick form picks them up.
304 protected function getDataSourceFields(): array {
305 $className = $this->getDataSourceClassName();
307 /* @var CRM_Import_DataSource $dataSourceClass */
308 $dataSourceClass = new $className();
309 return $dataSourceClass->getSubmittableFields();
315 * Get the default datasource.
319 protected function getDefaultDataSource(): string {
320 return 'CRM_Import_DataSource_CSV';
324 * Get the fields that can be submitted in the Import form flow.
326 * These could be on any form in the flow & are accessed the same way from
331 protected function getSubmittableFields(): array {
332 $dataSourceFields = array_fill_keys($this->getDataSourceFields(), 'DataSource');
333 return array_merge($this->submittableFields
, $dataSourceFields);
337 * Get the contact type selected for the import (on the datasource form).
340 * e.g Individual, Organization, Household.
342 * @throws \CRM_Core_Exception
344 protected function getContactType(): string {
345 $contactTypeMapping = [
346 CRM_Import_Parser
::CONTACT_INDIVIDUAL
=> 'Individual',
347 CRM_Import_Parser
::CONTACT_HOUSEHOLD
=> 'Household',
348 CRM_Import_Parser
::CONTACT_ORGANIZATION
=> 'Organization',
350 return $contactTypeMapping[$this->getSubmittedValue('contactType')];
354 * Get the contact sub type selected for the import (on the datasource form).
356 * @return string|null
359 * @throws \CRM_Core_Exception
361 protected function getContactSubType(): ?
string {
362 return $this->getSubmittedValue('contactSubType');
366 * Create a user job to track the import.
370 * @throws \API_Exception
372 protected function createUserJob(): int {
373 $id = UserJob
::create(FALSE)
375 'created_id' => CRM_Core_Session
::getLoggedInContactID(),
376 'type_id:name' => $this->getUserJobType(),
377 'status_id:name' => 'draft',
378 // This suggests the data could be cleaned up after this.
379 'expires_date' => '+ 1 week',
381 'submitted_values' => $this->getSubmittedValues(),
386 $this->setUserJobID($id);
394 * @throws \API_Exception
395 * @throws \Civi\API\Exception\UnauthorizedException
397 protected function updateUserJobMetadata(string $key, array $data): void
{
398 $metaData = array_merge(
399 $this->getUserJob()['metadata'],
402 UserJob
::update(FALSE)
403 ->addWhere('id', '=', $this->getUserJobID())
404 ->setValues(['metadata' => $metaData])
406 $this->userJob
['metadata'] = $metaData;
410 * Get column headers for the datasource or empty array if none apply.
412 * This would be the first row of a csv or the fields in an sql query.
414 * If the csv does not have a header row it will be empty.
418 * @throws \API_Exception
419 * @throws \CRM_Core_Exception
421 protected function getColumnHeaders(): array {
422 return $this->getDataSourceObject()->getColumnHeaders();
426 * Get the number of importable columns in the data source.
430 * @throws \API_Exception
431 * @throws \CRM_Core_Exception
433 protected function getNumberOfColumns(): int {
434 return $this->getDataSourceObject()->getNumberOfColumns();
438 * Get x data rows from the datasource.
440 * At this stage we are fetching from what has been stored in the form
441 * during `postProcess` on the DataSource form.
443 * In the future we will use the dataSource object, likely
444 * supporting offset as well.
447 * One or more of the statues available - e.g
448 * CRM_Import_Parser::VALID
449 * or [CRM_Import_Parser::ERROR, CRM_Import_Parser::VALID]
451 * @throws \CRM_Core_Exception
452 * @throws \API_Exception
454 protected function getDataRows($statuses = [], int $limit = 0): array {
455 $statuses = (array) $statuses;
456 return $this->getDataSourceObject()->setLimit($limit)->setStatuses($statuses)->getRows();
460 * Get the datasource rows ready for csv output.
462 * @param array $statuses
466 * @throws \API_Exception
467 * @throws \CRM_Core_Exception
469 protected function getOutputRows($statuses = [], int $limit = 0) {
470 $statuses = (array) $statuses;
471 return $this->getDataSourceObject()->setLimit($limit)->setStatuses($statuses)
472 ->setSelectFields(array_merge(['_id', '_status_message'], $this->getColumnHeaders()))
473 ->setStatuses($statuses)->getRows();
477 * Get the column headers for the output csv.
481 protected function getOutputColumnsHeaders(): array {
482 $headers = $this->getColumnHeaders();
483 array_unshift($headers, ts('Reason'));
484 array_unshift($headers, ts('Line Number'));
489 * Get the number of rows with the specified status.
491 * @param array|int $statuses
495 * @throws \API_Exception
496 * @throws \CRM_Core_Exception
498 protected function getRowCount($statuses = []) {
499 $statuses = (array) $statuses;
500 return $this->getDataSourceObject()->getRowCount($statuses);
504 * Outputs and downloads the csv of outcomes from an import job.
506 * This gets the rows from the temp table that match the relevant status
507 * and output them as a csv.
509 * @throws \API_Exception
510 * @throws \League\Csv\CannotInsertRecord
511 * @throws \CRM_Core_Exception
513 public static function outputCSV(): void
{
514 $userJobID = CRM_Utils_Request
::retrieveValue('user_job_id', 'Integer', NULL, TRUE);
515 $status = CRM_Utils_Request
::retrieveValue('status', 'String', NULL, TRUE);
516 $saveFileName = CRM_Import_Parser
::saveFileName($status);
518 $form = new CRM_Import_Forms();
519 $form->controller
= new CRM_Core_Controller();
520 $form->set('user_job_id', $userJobID);
523 $writer = Writer
::createFromFileObject(new SplTempFileObject());
524 $headers = $form->getOutputColumnsHeaders();
525 $writer->insertOne($headers);
526 // Note this might be more inefficient that iterating the result
527 // set & doing insertOne - possibly something to explore later.
528 $writer->insertAll($form->getOutputRows($status));
530 CRM_Utils_System
::setHttpHeader('Cache-Control', 'must-revalidate, post-check=0, pre-check=0');
531 CRM_Utils_System
::setHttpHeader('Content-Description', 'File Transfer');
532 CRM_Utils_System
::setHttpHeader('Content-Type', 'text/csv; charset=UTF-8');
533 $writer->output($saveFileName);
534 CRM_Utils_System
::civiExit();
538 * Get the url to download the relevant csv file.
539 * @param string $status
543 protected function getDownloadURL(string $status): string {
544 return CRM_Utils_System
::url('civicrm/import/outcome', [
545 'user_job_id' => $this->get('user_job_id'),
552 * Get the fields available for import selection.
555 * e.g ['first_name' => 'First Name', 'last_name' => 'Last Name'....
557 * @throws \API_Exception
559 protected function getAvailableFields(): array {
560 return $this->getParser()->getAvailableFields();
564 * Get an instance of the parser class.
566 * @return \CRM_Contact_Import_Parser_Contact|\CRM_Contribute_Import_Parser_Contribution
568 protected function getParser() {
573 * Get the mapped fields as an array of labels.
576 * ['First Name', 'Employee Of - First Name', 'Home - Street Address']
579 * @throws \API_Exception
581 protected function getMappedFieldLabels(): array {
583 $parser = $this->getParser();
584 foreach ($this->getSubmittedValue('mapper') as $columnNumber => $mappedField) {
585 $mapper[$columnNumber] = $parser->getMappedFieldLabel($parser->getMappingFieldFromMapperInput($mappedField, 0, $columnNumber));
591 * Assign variables required for the MapField form.
593 * @throws \API_Exception
594 * @throws \CRM_Core_Exception
596 protected function assignMapFieldVariables(): void
{
597 $this->addExpectedSmartyVariable('highlightedRelFields');
598 $this->_columnCount
= $this->getNumberOfColumns();
599 $this->_columnNames
= $this->getColumnHeaders();
600 $this->_dataValues
= array_values($this->getDataRows([], 2));
601 $this->assign('columnNames', $this->getColumnHeaders());
602 $this->assign('showColumnNames', $this->getSubmittedValue('skipColumnHeader') ||
$this->getSubmittedValue('dataSource') !== 'CRM_Import_DataSource');
603 $this->assign('highlightedFields', $this->getHighlightedFields());
604 $this->assign('columnCount', $this->_columnCount
);
605 $this->assign('dataValues', $this->_dataValues
);
609 * Get the fields to be highlighted in the UI.
611 * The highlighted fields are those used to match
612 * to an existing entity.
616 * @throws \CRM_Core_Exception
618 protected function getHighlightedFields(): array {
623 * Get the data patterns to pattern match the incoming data.
627 public function getDataPatterns(): array {
628 return $this->getParser()->getDataPatterns();
632 * Get the data patterns to pattern match the incoming data.
636 public function getHeaderPatterns(): array {
637 return $this->getParser()->getHeaderPatterns();
641 * Has the user chosen to update existing records.
644 protected function isUpdateExisting(): bool {
645 return ((int) $this->getSubmittedValue('onDuplicate')) === CRM_Import_Parser
::DUPLICATE_UPDATE
;
649 * Has the user chosen to update existing records.
652 protected function isSkipExisting(): bool {
653 return ((int) $this->getSubmittedValue('onDuplicate')) === CRM_Import_Parser
::DUPLICATE_SKIP
;