Merge pull request #23701 from eileenmcnaughton/unused
[civicrm-core.git] / CRM / Import / Forms.php
CommitLineData
9d7974eb
EM
1<?php
2/*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
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 +--------------------------------------------------------------------+
10 */
11
12/**
13 *
14 * @package CRM
15 * @copyright CiviCRM LLC https://civicrm.org/licensing
16 */
17
7b057b66 18use Civi\Api4\UserJob;
99e3c5f7 19use League\Csv\Writer;
7b057b66 20
9d7974eb
EM
21/**
22 * This class helps the forms within the import flow access submitted & parsed values.
23 */
24class CRM_Import_Forms extends CRM_Core_Form {
25
26 /**
7b057b66
EM
27 * User job id.
28 *
29 * This is the primary key of the civicrm_user_job table which is used to
30 * track the import.
31 *
32 * @var int
33 */
34 protected $userJobID;
35
36 /**
37 * @return int|null
38 */
39 public function getUserJobID(): ?int {
40 if (!$this->userJobID && $this->get('user_job_id')) {
41 $this->userJobID = $this->get('user_job_id');
42 }
43 return $this->userJobID;
44 }
45
46 /**
47 * Set user job ID.
48 *
49 * @param int $userJobID
50 */
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);
55 }
56
57 /**
58 * User job details.
59 *
60 * This is the relevant row from civicrm_user_job.
61 *
62 * @var array
63 */
64 protected $userJob;
65
83a1c234
EM
66 /**
67 * @var \CRM_Import_Parser
68 */
69 protected $parser;
70
7b057b66
EM
71 /**
72 * Get User Job.
73 *
74 * API call to retrieve the userJob row.
75 *
76 * @return array
77 *
78 * @throws \API_Exception
79 */
80 protected function getUserJob(): array {
81 if (!$this->userJob) {
82 $this->userJob = UserJob::get()
83 ->addWhere('id', '=', $this->getUserJobID())
84 ->execute()
85 ->first();
86 }
87 return $this->userJob;
88 }
89
90 /**
91 * Get submitted values stored in the user job.
92 *
93 * @return array
94 * @throws \API_Exception
95 */
96 protected function getUserJobSubmittedValues(): array {
97 return $this->getUserJob()['metadata']['submitted_values'];
98 }
99
100 /**
101 * Fields that may be submitted on any form in the flow.
102 *
103 * @var string[]
104 */
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',
dfa2f16c 113 'contactSubType' => 'DataSource',
7b057b66
EM
114 'dateFormats' => 'DataSource',
115 'savedMapping' => 'DataSource',
116 'dataSource' => 'DataSource',
dfa2f16c
EM
117 'dedupe_rule_id' => 'DataSource',
118 'onDuplicate' => 'DataSource',
119 'disableUSPS' => 'DataSource',
9c16701f
EM
120 'doGeocodeAddress' => 'DataSource',
121 // Note we don't add the save mapping instructions for MapField here
122 // (eg 'updateMapping') - as they really are an action for that form
123 // rather than part of the mapping config.
124 'mapper' => 'MapField',
7b057b66
EM
125 ];
126
127 /**
128 * Get the submitted value, accessing it from whatever form in the flow it is
129 * submitted on.
130 *
9d7974eb
EM
131 * @param string $fieldName
132 *
133 * @return mixed|null
7b057b66 134 * @throws \CRM_Core_Exception
9d7974eb
EM
135 */
136 public function getSubmittedValue(string $fieldName) {
7b057b66
EM
137 if ($fieldName === 'dataSource') {
138 // Hard-coded handling for DataSource as it affects the contents of
139 // getSubmittableFields and can cause a loop.
82cf0710
EM
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');
7b057b66
EM
144 }
145 $mappedValues = $this->getSubmittableFields();
9d7974eb
EM
146 if (array_key_exists($fieldName, $mappedValues)) {
147 return $this->controller->exportValue($mappedValues[$fieldName], $fieldName);
148 }
149 return parent::getSubmittedValue($fieldName);
150
151 }
152
7b057b66
EM
153 /**
154 * Get values submitted on any form in the multi-page import flow.
155 *
156 * @return array
157 */
158 public function getSubmittedValues(): array {
159 $values = [];
160 foreach (array_keys($this->getSubmittableFields()) as $key) {
161 $values[$key] = $this->getSubmittedValue($key);
162 }
163 return $values;
164 }
165
39dc35d4
EM
166 /**
167 * Get the available datasource.
168 *
169 * Permission dependent, this will look like
170 * [
171 * 'CRM_Import_DataSource_CSV' => 'Comma-Separated Values (CSV)',
172 * 'CRM_Import_DataSource_SQL' => 'SQL Query',
173 * ]
174 *
175 * The label is translated.
176 *
177 * @return array
178 */
179 protected function getDataSources(): array {
180 $dataSources = [];
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'];
185 }
186 }
187 return $dataSources;
188 }
189
d452dfe6
EM
190 /**
191 * Get the name of the datasource class.
192 *
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.
196 *
197 * This is problematic in the forward->back flow & option changing flow. As in....
198 *
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
203 * required.
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.
209 *
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.
214 *
215 * @return string|null
216 *
217 * @throws \CRM_Core_Exception
218 */
c2562331 219 protected function getDataSourceClassName(): string {
d452dfe6
EM
220 $className = CRM_Utils_Request::retrieveValue(
221 'dataSource',
222 'String'
223 );
224 if (!$className) {
225 $className = $this->getSubmittedValue('dataSource');
226 }
227 if (!$className) {
228 $className = $this->getDefaultDataSource();
229 }
230 if ($this->getDataSources()[$className]) {
231 return $className;
232 }
233 throw new CRM_Core_Exception('Invalid data source');
234 }
235
236 /**
237 * Allow the datasource class to add fields.
238 *
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.
242 *
243 * @throws \CRM_Core_Exception
244 */
245 protected function buildDataSourceFields(): void {
7b057b66
EM
246 $dataSourceClass = $this->getDataSourceObject();
247 if ($dataSourceClass) {
248 $dataSourceClass->buildQuickForm($this);
249 }
250 }
251
1163561b
EM
252 /**
253 * Flush datasource on re-submission of the form.
254 *
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.
258 *
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.
262 *
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.
266 *
267 * @throws \API_Exception
268 * @throws \CRM_Core_Exception
269 */
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
274 // an sql query
275 $oldDataSource = $this->getUserJobSubmittedValues()['dataSource'];
276 $oldDataSourceObject = new $oldDataSource($this->getUserJobID());
277 $newParams = $this->getSubmittedValue('dataSource') === $oldDataSource ? $this->getSubmittedValues() : [];
278 $oldDataSourceObject->purge($newParams);
279 }
280
7b057b66
EM
281 /**
282 * Get the relevant datasource object.
283 *
284 * @return \CRM_Import_DataSource|null
285 *
286 * @throws \CRM_Core_Exception
287 */
288 protected function getDataSourceObject(): ?CRM_Import_DataSource {
289 $className = $this->getDataSourceClassName();
290 if ($className) {
291 /* @var CRM_Import_DataSource $dataSource */
292 return new $className($this->getUserJobID());
293 }
294 return NULL;
295 }
296
297 /**
298 * Allow the datasource class to add fields.
299 *
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.
303 *
304 * @throws \CRM_Core_Exception
305 */
306 protected function getDataSourceFields(): array {
d452dfe6
EM
307 $className = $this->getDataSourceClassName();
308 if ($className) {
7b057b66 309 /* @var CRM_Import_DataSource $dataSourceClass */
d452dfe6 310 $dataSourceClass = new $className();
7b057b66 311 return $dataSourceClass->getSubmittableFields();
d452dfe6 312 }
7b057b66 313 return [];
d452dfe6
EM
314 }
315
316 /**
317 * Get the default datasource.
318 *
319 * @return string
320 */
321 protected function getDefaultDataSource(): string {
322 return 'CRM_Import_DataSource_CSV';
323 }
324
7b057b66
EM
325 /**
326 * Get the fields that can be submitted in the Import form flow.
327 *
328 * These could be on any form in the flow & are accessed the same way from
329 * all forms.
330 *
331 * @return string[]
332 * @throws \CRM_Core_Exception
333 */
334 protected function getSubmittableFields(): array {
335 $dataSourceFields = array_fill_keys($this->getDataSourceFields(), 'DataSource');
336 return array_merge($this->submittableFields, $dataSourceFields);
337 }
338
6e78138c
EM
339 /**
340 * Get the contact type selected for the import (on the datasource form).
341 *
342 * @return string
343 * e.g Individual, Organization, Household.
344 *
345 * @throws \CRM_Core_Exception
346 */
347 protected function getContactType(): string {
348 $contactTypeMapping = [
349 CRM_Import_Parser::CONTACT_INDIVIDUAL => 'Individual',
350 CRM_Import_Parser::CONTACT_HOUSEHOLD => 'Household',
351 CRM_Import_Parser::CONTACT_ORGANIZATION => 'Organization',
352 ];
353 return $contactTypeMapping[$this->getSubmittedValue('contactType')];
354 }
355
80cb71bb
EM
356 /**
357 * Get the contact sub type selected for the import (on the datasource form).
358 *
359 * @return string|null
360 * e.g Staff.
361 *
362 * @throws \CRM_Core_Exception
363 */
364 protected function getContactSubType(): ?string {
365 return $this->getSubmittedValue('contactSubType');
366 }
367
7b057b66
EM
368 /**
369 * Create a user job to track the import.
370 *
371 * @return int
372 *
373 * @throws \API_Exception
374 */
375 protected function createUserJob(): int {
376 $id = UserJob::create(FALSE)
377 ->setValues([
378 'created_id' => CRM_Core_Session::getLoggedInContactID(),
08a76614 379 'type_id:name' => $this->getUserJobType(),
7b057b66
EM
380 'status_id:name' => 'draft',
381 // This suggests the data could be cleaned up after this.
382 'expires_date' => '+ 1 week',
383 'metadata' => [
384 'submitted_values' => $this->getSubmittedValues(),
385 ],
386 ])
387 ->execute()
388 ->first()['id'];
389 $this->setUserJobID($id);
390 return $id;
391 }
392
393 /**
394 * @param string $key
395 * @param array $data
396 *
397 * @throws \API_Exception
398 * @throws \Civi\API\Exception\UnauthorizedException
399 */
400 protected function updateUserJobMetadata(string $key, array $data): void {
401 $metaData = array_merge(
402 $this->getUserJob()['metadata'],
403 [$key => $data]
404 );
405 UserJob::update(FALSE)
406 ->addWhere('id', '=', $this->getUserJobID())
407 ->setValues(['metadata' => $metaData])
408 ->execute();
409 $this->userJob['metadata'] = $metaData;
410 }
411
4a01628c
EM
412 /**
413 * Get column headers for the datasource or empty array if none apply.
414 *
415 * This would be the first row of a csv or the fields in an sql query.
416 *
417 * If the csv does not have a header row it will be empty.
418 *
419 * @return array
420 *
421 * @throws \API_Exception
422 * @throws \CRM_Core_Exception
423 */
424 protected function getColumnHeaders(): array {
425 return $this->getDataSourceObject()->getColumnHeaders();
426 }
427
428 /**
429 * Get the number of importable columns in the data source.
430 *
431 * @return int
432 *
433 * @throws \API_Exception
434 * @throws \CRM_Core_Exception
435 */
436 protected function getNumberOfColumns(): int {
437 return $this->getDataSourceObject()->getNumberOfColumns();
438 }
439
440 /**
441 * Get x data rows from the datasource.
442 *
443 * At this stage we are fetching from what has been stored in the form
444 * during `postProcess` on the DataSource form.
445 *
446 * In the future we will use the dataSource object, likely
447 * supporting offset as well.
448 *
99e3c5f7
EM
449 * @return array|int
450 * One or more of the statues available - e.g
451 * CRM_Import_Parser::VALID
da8d3d49 452 * or [CRM_Import_Parser::ERROR, CRM_Import_Parser::VALID]
4a01628c 453 *
99e3c5f7
EM
454 * @throws \CRM_Core_Exception
455 * @throws \API_Exception
456 */
457 protected function getDataRows($statuses = [], int $limit = 0): array {
458 $statuses = (array) $statuses;
459 return $this->getDataSourceObject()->setLimit($limit)->setStatuses($statuses)->getRows();
460 }
461
462 /**
463 * Get the number of rows with the specified status.
4a01628c 464 *
99e3c5f7
EM
465 * @param array|int $statuses
466 *
467 * @return int
468 *
469 * @throws \API_Exception
4a01628c 470 * @throws \CRM_Core_Exception
99e3c5f7
EM
471 */
472 protected function getRowCount($statuses = []) {
473 $statuses = (array) $statuses;
474 return $this->getDataSourceObject()->getRowCount($statuses);
475 }
476
477 /**
478 * Outputs and downloads the csv of outcomes from an import job.
479 *
480 * This gets the rows from the temp table that match the relevant status
481 * and output them as a csv.
482 *
4a01628c 483 * @throws \API_Exception
99e3c5f7
EM
484 * @throws \League\Csv\CannotInsertRecord
485 * @throws \CRM_Core_Exception
486 */
487 public static function outputCSV(): void {
488 $userJobID = CRM_Utils_Request::retrieveValue('user_job_id', 'Integer', NULL, TRUE);
489 $status = CRM_Utils_Request::retrieveValue('status', 'String', NULL, TRUE);
490 $saveFileName = CRM_Import_Parser::saveFileName($status);
491
492 $form = new CRM_Import_Forms();
493 $form->controller = new CRM_Core_Controller();
494 $form->set('user_job_id', $userJobID);
495
496 $form->getUserJob();
497 $writer = Writer::createFromFileObject(new SplTempFileObject());
498 $headers = $form->getColumnHeaders();
499 if ($headers) {
500 array_unshift($headers, ts('Reason'));
501 array_unshift($headers, ts('Line Number'));
502 $writer->insertOne($headers);
503 }
504 $writer->addFormatter(['CRM_Import_Forms', 'reorderOutput']);
505 // Note this might be more inefficient that iterating the result
506 // set & doing insertOne - possibly something to explore later.
507 $writer->insertAll($form->getDataRows($status));
508
509 CRM_Utils_System::setHttpHeader('Cache-Control', 'must-revalidate, post-check=0, pre-check=0');
510 CRM_Utils_System::setHttpHeader('Content-Description', 'File Transfer');
511 CRM_Utils_System::setHttpHeader('Content-Type', 'text/csv; charset=UTF-8');
512 $writer->output($saveFileName);
513 CRM_Utils_System::civiExit();
514 }
515
516 /**
517 * When outputting the row as a csv, more the last 2 rows to the start.
518 *
519 * This is because the id and status message fields are at the end. It may make sense
520 * to move them to the start later, when order code cleanup has happened...
521 *
522 * @param array $record
523 */
524 public static function reorderOutput(array $record): array {
525 $rowNumber = array_pop($record);
526 $message = array_pop($record);
527 // Also pop off the status - but we are not going to use this at this stage.
528 array_pop($record);
5e21b588
EM
529 // Related entities
530 array_pop($record);
531 // Entity_id
532 array_pop($record);
99e3c5f7
EM
533 array_unshift($record, $message);
534 array_unshift($record, $rowNumber);
535 return $record;
536 }
537
538 /**
539 * Get the url to download the relevant csv file.
540 * @param string $status
541 *
542 * @return string
4a01628c 543 */
99e3c5f7
EM
544 protected function getDownloadURL(string $status): string {
545 return CRM_Utils_System::url('civicrm/import/outcome', [
546 'user_job_id' => $this->get('user_job_id'),
547 'status' => $status,
548 'reset' => 1,
549 ]);
4a01628c
EM
550 }
551
52bd01f5
EM
552 /**
553 * Get the fields available for import selection.
554 *
555 * @return array
556 * e.g ['first_name' => 'First Name', 'last_name' => 'Last Name'....
557 *
558 * @throws \API_Exception
559 */
560 protected function getAvailableFields(): array {
73edfc10
EM
561 return $this->getParser()->getAvailableFields();
562 }
563
564 /**
565 * Get an instance of the parser class.
566 *
567 * @return \CRM_Contact_Import_Parser_Contact|\CRM_Contribute_Import_Parser_Contribution
568 */
569 protected function getParser() {
570 return NULL;
52bd01f5
EM
571 }
572
2a879010
EM
573 /**
574 * Get the mapped fields as an array of labels.
575 *
576 * e.g
577 * ['First Name', 'Employee Of - First Name', 'Home - Street Address']
578 *
579 * @return array
580 * @throws \API_Exception
581 * @throws \CRM_Core_Exception
582 */
583 protected function getMappedFieldLabels(): array {
584 $mapper = [];
d00da884 585 $parser = $this->getParser();
2a879010
EM
586 foreach ($this->getSubmittedValue('mapper') as $columnNumber => $mappedField) {
587 $mapper[$columnNumber] = $parser->getMappedFieldLabel($parser->getMappingFieldFromMapperInput($mappedField, 0, $columnNumber));
588 }
589 return $mapper;
590 }
591
4d9f4d69
EM
592 /**
593 * Assign variables required for the MapField form.
594 *
595 * @throws \API_Exception
596 * @throws \CRM_Core_Exception
597 */
598 protected function assignMapFieldVariables(): void {
599 $this->addExpectedSmartyVariable('highlightedRelFields');
600 $this->_columnCount = $this->getNumberOfColumns();
601 $this->_columnNames = $this->getColumnHeaders();
602 $this->_dataValues = array_values($this->getDataRows([], 2));
603 $this->assign('columnNames', $this->getColumnHeaders());
604 $this->assign('highlightedFields', $this->getHighlightedFields());
578e4db3 605 $this->assign('columnCount', $this->_columnCount);
4d9f4d69
EM
606 $this->assign('dataValues', $this->_dataValues);
607 }
608
609 /**
610 * Get the fields to be highlighted in the UI.
611 *
612 * The highlighted fields are those used to match
613 * to an existing entity.
614 *
615 * @return array
616 *
617 * @throws \CRM_Core_Exception
618 */
619 protected function getHighlightedFields(): array {
620 return [];
621 }
622
623 /**
624 * Get the data patterns to pattern match the incoming data.
625 *
626 * @return array
627 */
628 public function getDataPatterns(): array {
629 return $this->getParser()->getDataPatterns();
630 }
631
632 /**
633 * Get the data patterns to pattern match the incoming data.
634 *
635 * @return array
636 */
637 public function getHeaderPatterns(): array {
638 return $this->getParser()->getHeaderPatterns();
639 }
640
9d7974eb 641}