Add output form for csv-on-the-fly
[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
66 /**
67 * Get User Job.
68 *
69 * API call to retrieve the userJob row.
70 *
71 * @return array
72 *
73 * @throws \API_Exception
74 */
75 protected function getUserJob(): array {
76 if (!$this->userJob) {
77 $this->userJob = UserJob::get()
78 ->addWhere('id', '=', $this->getUserJobID())
79 ->execute()
80 ->first();
81 }
82 return $this->userJob;
83 }
84
85 /**
86 * Get submitted values stored in the user job.
87 *
88 * @return array
89 * @throws \API_Exception
90 */
91 protected function getUserJobSubmittedValues(): array {
92 return $this->getUserJob()['metadata']['submitted_values'];
93 }
94
95 /**
96 * Fields that may be submitted on any form in the flow.
97 *
98 * @var string[]
99 */
100 protected $submittableFields = [
101 // Skip column header is actually a field that would be added from the
102 // datasource - but currently only in contact, it is always there for
103 // other imports, ditto uploadFile.
104 'skipColumnHeader' => 'DataSource',
105 'fieldSeparator' => 'DataSource',
106 'uploadFile' => 'DataSource',
107 'contactType' => 'DataSource',
dfa2f16c 108 'contactSubType' => 'DataSource',
7b057b66
EM
109 'dateFormats' => 'DataSource',
110 'savedMapping' => 'DataSource',
111 'dataSource' => 'DataSource',
dfa2f16c
EM
112 'dedupe_rule_id' => 'DataSource',
113 'onDuplicate' => 'DataSource',
114 'disableUSPS' => 'DataSource',
9c16701f
EM
115 'doGeocodeAddress' => 'DataSource',
116 // Note we don't add the save mapping instructions for MapField here
117 // (eg 'updateMapping') - as they really are an action for that form
118 // rather than part of the mapping config.
119 'mapper' => 'MapField',
7b057b66
EM
120 ];
121
122 /**
123 * Get the submitted value, accessing it from whatever form in the flow it is
124 * submitted on.
125 *
9d7974eb
EM
126 * @param string $fieldName
127 *
128 * @return mixed|null
7b057b66 129 * @throws \CRM_Core_Exception
9d7974eb
EM
130 */
131 public function getSubmittedValue(string $fieldName) {
7b057b66
EM
132 if ($fieldName === 'dataSource') {
133 // Hard-coded handling for DataSource as it affects the contents of
134 // getSubmittableFields and can cause a loop.
135 return $this->controller->exportValue('DataSource', 'dataSource');
136 }
137 $mappedValues = $this->getSubmittableFields();
9d7974eb
EM
138 if (array_key_exists($fieldName, $mappedValues)) {
139 return $this->controller->exportValue($mappedValues[$fieldName], $fieldName);
140 }
141 return parent::getSubmittedValue($fieldName);
142
143 }
144
7b057b66
EM
145 /**
146 * Get values submitted on any form in the multi-page import flow.
147 *
148 * @return array
149 */
150 public function getSubmittedValues(): array {
151 $values = [];
152 foreach (array_keys($this->getSubmittableFields()) as $key) {
153 $values[$key] = $this->getSubmittedValue($key);
154 }
155 return $values;
156 }
157
39dc35d4
EM
158 /**
159 * Get the available datasource.
160 *
161 * Permission dependent, this will look like
162 * [
163 * 'CRM_Import_DataSource_CSV' => 'Comma-Separated Values (CSV)',
164 * 'CRM_Import_DataSource_SQL' => 'SQL Query',
165 * ]
166 *
167 * The label is translated.
168 *
169 * @return array
170 */
171 protected function getDataSources(): array {
172 $dataSources = [];
173 foreach (['CRM_Import_DataSource_SQL', 'CRM_Import_DataSource_CSV'] as $dataSourceClass) {
174 $object = new $dataSourceClass();
175 if ($object->checkPermission()) {
176 $dataSources[$dataSourceClass] = $object->getInfo()['title'];
177 }
178 }
179 return $dataSources;
180 }
181
d452dfe6
EM
182 /**
183 * Get the name of the datasource class.
184 *
185 * This function prioritises retrieving from GET and POST over 'submitted'.
186 * The reason for this is the submitted array will hold the previous submissions
187 * data until after buildForm is called.
188 *
189 * This is problematic in the forward->back flow & option changing flow. As in....
190 *
191 * 1) Load DataSource form - initial default datasource is set to CSV and the
192 * form is via ajax (this calls DataSourceConfig to get the data).
193 * 2) User changes the source to SQL - the ajax updates the html but the
194 * form was built with the expectation that the csv-specific fields would be
195 * required.
196 * 3) When the user submits Quickform calls preProcess and buildForm and THEN
197 * retrieves the submitted values based on what has been added in buildForm.
198 * Only the submitted values for fields added in buildForm are available - but
199 * these have to be added BEFORE the submitted values are determined. Hence
200 * we look in the POST or GET to get the updated value.
201 *
202 * Note that an imminent refactor will involve storing the values in the
203 * civicrm_user_job table - this will hopefully help with a known (not new)
204 * issue whereby the previously submitted values (eg. skipColumnHeader has
205 * been checked or sql has been filled in) are not loaded via the ajax request.
206 *
207 * @return string|null
208 *
209 * @throws \CRM_Core_Exception
210 */
c2562331 211 protected function getDataSourceClassName(): string {
d452dfe6
EM
212 $className = CRM_Utils_Request::retrieveValue(
213 'dataSource',
214 'String'
215 );
216 if (!$className) {
217 $className = $this->getSubmittedValue('dataSource');
218 }
219 if (!$className) {
220 $className = $this->getDefaultDataSource();
221 }
222 if ($this->getDataSources()[$className]) {
223 return $className;
224 }
225 throw new CRM_Core_Exception('Invalid data source');
226 }
227
228 /**
229 * Allow the datasource class to add fields.
230 *
231 * This is called as a snippet in DataSourceConfig and
232 * also from DataSource::buildForm to add the fields such
233 * that quick form picks them up.
234 *
235 * @throws \CRM_Core_Exception
236 */
237 protected function buildDataSourceFields(): void {
7b057b66
EM
238 $dataSourceClass = $this->getDataSourceObject();
239 if ($dataSourceClass) {
240 $dataSourceClass->buildQuickForm($this);
241 }
242 }
243
1163561b
EM
244 /**
245 * Flush datasource on re-submission of the form.
246 *
247 * If the form has been re-submitted the datasource might have changed.
248 * We tell the dataSource class to remove any tables (and potentially files)
249 * created last form submission.
250 *
251 * If the DataSource in use is unchanged (ie still CSV or still SQL)
252 * we also pass in the new variables. In theory it could decide that they
253 * have not actually changed and it doesn't need to do any cleanup.
254 *
255 * In practice the datasource classes blast away as they always have for now
256 * - however, the sql class, for example, might realise the fields it cares
257 * about are unchanged and not flush the table.
258 *
259 * @throws \API_Exception
260 * @throws \CRM_Core_Exception
261 */
262 protected function flushDataSource(): void {
263 // If the form has been resubmitted the datasource might have changed.
264 // We give the datasource a chance to clean up any tables it might have
265 // created. If we are still using the same type of datasource (e.g still
266 // an sql query
267 $oldDataSource = $this->getUserJobSubmittedValues()['dataSource'];
268 $oldDataSourceObject = new $oldDataSource($this->getUserJobID());
269 $newParams = $this->getSubmittedValue('dataSource') === $oldDataSource ? $this->getSubmittedValues() : [];
270 $oldDataSourceObject->purge($newParams);
271 }
272
7b057b66
EM
273 /**
274 * Get the relevant datasource object.
275 *
276 * @return \CRM_Import_DataSource|null
277 *
278 * @throws \CRM_Core_Exception
279 */
280 protected function getDataSourceObject(): ?CRM_Import_DataSource {
281 $className = $this->getDataSourceClassName();
282 if ($className) {
283 /* @var CRM_Import_DataSource $dataSource */
284 return new $className($this->getUserJobID());
285 }
286 return NULL;
287 }
288
289 /**
290 * Allow the datasource class to add fields.
291 *
292 * This is called as a snippet in DataSourceConfig and
293 * also from DataSource::buildForm to add the fields such
294 * that quick form picks them up.
295 *
296 * @throws \CRM_Core_Exception
297 */
298 protected function getDataSourceFields(): array {
d452dfe6
EM
299 $className = $this->getDataSourceClassName();
300 if ($className) {
7b057b66 301 /* @var CRM_Import_DataSource $dataSourceClass */
d452dfe6 302 $dataSourceClass = new $className();
7b057b66 303 return $dataSourceClass->getSubmittableFields();
d452dfe6 304 }
7b057b66 305 return [];
d452dfe6
EM
306 }
307
308 /**
309 * Get the default datasource.
310 *
311 * @return string
312 */
313 protected function getDefaultDataSource(): string {
314 return 'CRM_Import_DataSource_CSV';
315 }
316
7b057b66
EM
317 /**
318 * Get the fields that can be submitted in the Import form flow.
319 *
320 * These could be on any form in the flow & are accessed the same way from
321 * all forms.
322 *
323 * @return string[]
324 * @throws \CRM_Core_Exception
325 */
326 protected function getSubmittableFields(): array {
327 $dataSourceFields = array_fill_keys($this->getDataSourceFields(), 'DataSource');
328 return array_merge($this->submittableFields, $dataSourceFields);
329 }
330
6e78138c
EM
331 /**
332 * Get the contact type selected for the import (on the datasource form).
333 *
334 * @return string
335 * e.g Individual, Organization, Household.
336 *
337 * @throws \CRM_Core_Exception
338 */
339 protected function getContactType(): string {
340 $contactTypeMapping = [
341 CRM_Import_Parser::CONTACT_INDIVIDUAL => 'Individual',
342 CRM_Import_Parser::CONTACT_HOUSEHOLD => 'Household',
343 CRM_Import_Parser::CONTACT_ORGANIZATION => 'Organization',
344 ];
345 return $contactTypeMapping[$this->getSubmittedValue('contactType')];
346 }
347
80cb71bb
EM
348 /**
349 * Get the contact sub type selected for the import (on the datasource form).
350 *
351 * @return string|null
352 * e.g Staff.
353 *
354 * @throws \CRM_Core_Exception
355 */
356 protected function getContactSubType(): ?string {
357 return $this->getSubmittedValue('contactSubType');
358 }
359
7b057b66
EM
360 /**
361 * Create a user job to track the import.
362 *
363 * @return int
364 *
365 * @throws \API_Exception
366 */
367 protected function createUserJob(): int {
368 $id = UserJob::create(FALSE)
369 ->setValues([
370 'created_id' => CRM_Core_Session::getLoggedInContactID(),
371 'type_id:name' => 'contact_import',
372 'status_id:name' => 'draft',
373 // This suggests the data could be cleaned up after this.
374 'expires_date' => '+ 1 week',
375 'metadata' => [
376 'submitted_values' => $this->getSubmittedValues(),
377 ],
378 ])
379 ->execute()
380 ->first()['id'];
381 $this->setUserJobID($id);
382 return $id;
383 }
384
385 /**
386 * @param string $key
387 * @param array $data
388 *
389 * @throws \API_Exception
390 * @throws \Civi\API\Exception\UnauthorizedException
391 */
392 protected function updateUserJobMetadata(string $key, array $data): void {
393 $metaData = array_merge(
394 $this->getUserJob()['metadata'],
395 [$key => $data]
396 );
397 UserJob::update(FALSE)
398 ->addWhere('id', '=', $this->getUserJobID())
399 ->setValues(['metadata' => $metaData])
400 ->execute();
401 $this->userJob['metadata'] = $metaData;
402 }
403
4a01628c
EM
404 /**
405 * Get column headers for the datasource or empty array if none apply.
406 *
407 * This would be the first row of a csv or the fields in an sql query.
408 *
409 * If the csv does not have a header row it will be empty.
410 *
411 * @return array
412 *
413 * @throws \API_Exception
414 * @throws \CRM_Core_Exception
415 */
416 protected function getColumnHeaders(): array {
417 return $this->getDataSourceObject()->getColumnHeaders();
418 }
419
420 /**
421 * Get the number of importable columns in the data source.
422 *
423 * @return int
424 *
425 * @throws \API_Exception
426 * @throws \CRM_Core_Exception
427 */
428 protected function getNumberOfColumns(): int {
429 return $this->getDataSourceObject()->getNumberOfColumns();
430 }
431
432 /**
433 * Get x data rows from the datasource.
434 *
435 * At this stage we are fetching from what has been stored in the form
436 * during `postProcess` on the DataSource form.
437 *
438 * In the future we will use the dataSource object, likely
439 * supporting offset as well.
440 *
99e3c5f7
EM
441 * @return array|int
442 * One or more of the statues available - e.g
443 * CRM_Import_Parser::VALID
444 * or [CRM_Import_Parser::ERROR, CRM_Import_Parser::CONFLICT]
4a01628c 445 *
99e3c5f7
EM
446 * @throws \CRM_Core_Exception
447 * @throws \API_Exception
448 */
449 protected function getDataRows($statuses = [], int $limit = 0): array {
450 $statuses = (array) $statuses;
451 return $this->getDataSourceObject()->setLimit($limit)->setStatuses($statuses)->getRows();
452 }
453
454 /**
455 * Get the number of rows with the specified status.
4a01628c 456 *
99e3c5f7
EM
457 * @param array|int $statuses
458 *
459 * @return int
460 *
461 * @throws \API_Exception
4a01628c 462 * @throws \CRM_Core_Exception
99e3c5f7
EM
463 */
464 protected function getRowCount($statuses = []) {
465 $statuses = (array) $statuses;
466 return $this->getDataSourceObject()->getRowCount($statuses);
467 }
468
469 /**
470 * Outputs and downloads the csv of outcomes from an import job.
471 *
472 * This gets the rows from the temp table that match the relevant status
473 * and output them as a csv.
474 *
4a01628c 475 * @throws \API_Exception
99e3c5f7
EM
476 * @throws \League\Csv\CannotInsertRecord
477 * @throws \CRM_Core_Exception
478 */
479 public static function outputCSV(): void {
480 $userJobID = CRM_Utils_Request::retrieveValue('user_job_id', 'Integer', NULL, TRUE);
481 $status = CRM_Utils_Request::retrieveValue('status', 'String', NULL, TRUE);
482 $saveFileName = CRM_Import_Parser::saveFileName($status);
483
484 $form = new CRM_Import_Forms();
485 $form->controller = new CRM_Core_Controller();
486 $form->set('user_job_id', $userJobID);
487
488 $form->getUserJob();
489 $writer = Writer::createFromFileObject(new SplTempFileObject());
490 $headers = $form->getColumnHeaders();
491 if ($headers) {
492 array_unshift($headers, ts('Reason'));
493 array_unshift($headers, ts('Line Number'));
494 $writer->insertOne($headers);
495 }
496 $writer->addFormatter(['CRM_Import_Forms', 'reorderOutput']);
497 // Note this might be more inefficient that iterating the result
498 // set & doing insertOne - possibly something to explore later.
499 $writer->insertAll($form->getDataRows($status));
500
501 CRM_Utils_System::setHttpHeader('Cache-Control', 'must-revalidate, post-check=0, pre-check=0');
502 CRM_Utils_System::setHttpHeader('Content-Description', 'File Transfer');
503 CRM_Utils_System::setHttpHeader('Content-Type', 'text/csv; charset=UTF-8');
504 $writer->output($saveFileName);
505 CRM_Utils_System::civiExit();
506 }
507
508 /**
509 * When outputting the row as a csv, more the last 2 rows to the start.
510 *
511 * This is because the id and status message fields are at the end. It may make sense
512 * to move them to the start later, when order code cleanup has happened...
513 *
514 * @param array $record
515 */
516 public static function reorderOutput(array $record): array {
517 $rowNumber = array_pop($record);
518 $message = array_pop($record);
519 // Also pop off the status - but we are not going to use this at this stage.
520 array_pop($record);
521 array_unshift($record, $message);
522 array_unshift($record, $rowNumber);
523 return $record;
524 }
525
526 /**
527 * Get the url to download the relevant csv file.
528 * @param string $status
529 *
530 * @return string
4a01628c 531 */
99e3c5f7
EM
532 protected function getDownloadURL(string $status): string {
533 return CRM_Utils_System::url('civicrm/import/outcome', [
534 'user_job_id' => $this->get('user_job_id'),
535 'status' => $status,
536 'reset' => 1,
537 ]);
4a01628c
EM
538 }
539
52bd01f5
EM
540 /**
541 * Get the fields available for import selection.
542 *
543 * @return array
544 * e.g ['first_name' => 'First Name', 'last_name' => 'Last Name'....
545 *
546 * @throws \API_Exception
547 */
548 protected function getAvailableFields(): array {
549 $parser = new CRM_Contact_Import_Parser_Contact();
550 $parser->setUserJobID($this->getUserJobID());
551 return $parser->getAvailableFields();
552 }
553
9d7974eb 554}