diff --git a/appinfo/routes.php b/appinfo/routes.php index 3620e691e..0c323c029 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -58,6 +58,7 @@ // Submissions ['name' => 'api#getSubmissions', 'url' => '/api/v1/submissions/{hash}', 'verb' => 'GET'], ['name' => 'api#exportSubmissions', 'url' => '/api/v1/submissions/export/{hash}', 'verb' => 'GET'], + ['name' => 'api#exportSubmissionsToCloud', 'url' => '/api/v1/submissions/export', 'verb' => 'POST'], ['name' => 'api#deleteAllSubmissions', 'url' => '/api/v1/submissions/{formId}', 'verb' => 'DELETE'], ['name' => 'api#insertSubmission', 'url' => '/api/v1/submission/insert', 'verb' => 'POST'], diff --git a/docs/API.md b/docs/API.md index 2f53614b9..84d1e0285 100644 --- a/docs/API.md +++ b/docs/API.md @@ -387,7 +387,7 @@ Get all Submissions to a Form } ``` -### Export Submissions to csv +### Get Submissions as csv (Download) Returns all submissions to the form in form of a csv-file. - Endpoint: `/api/v1/submissions/export/{hash}` - Url-Parameter: @@ -397,9 +397,23 @@ Returns all submissions to the form in form of a csv-file. - Method: `GET` - Response: A Data Download Response containg the headers `Content-Disposition: attachment; filename="Form 1 (responses).csv"` and `Content-Type: text/csv;charset=UTF-8`. The actual data contains all submissions to the referred form, formatted as comma separated and escaped csv. ``` -"User display name",Timestamp,"Question 1","Question 2" -jonas,"Friday, January 22, 2021 at 12:47:29 AM GMT+0:00","Option 2",Answer -jonas,"Friday, January 22, 2021 at 12:45:57 AM GMT+0:00","Option 3",NextAnswer +"User display name","Timestamp","Question 1","Question 2" +"jonas","Friday, January 22, 2021 at 12:47:29 AM GMT+0:00","Option 2","Answer" +"jonas","Friday, January 22, 2021 at 12:45:57 AM GMT+0:00","Option 3","NextAnswer" +``` + +### Export Submissions to Cloud (Files-App) +Creates a csv file and stores it to the cloud, resp. Files-App. +- Endpoint: `/api/v1/submissions/export` +- Method: `POST` +- Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _hash_ | String | Hash of the form to get the submissions for | + | _path_ | String | Path within User-Dir, to store the file to | +- Response: Stores the file to the given path and returns the fileName. +``` +"data": "Form 2 (responses).csv" ``` ### Delete Submissions @@ -487,4 +501,4 @@ This Error is not produed by the Forms-API, but comes from Nextclouds OCS API. T { "message": "CSRF check failed" } -``` \ No newline at end of file +``` diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 30c081a29..55ba50a8f 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -27,7 +27,6 @@ namespace OCA\Forms\Controller; -use DateTimeZone; use Exception; use OCA\Forms\Activity\ActivityManager; @@ -43,6 +42,7 @@ use OCA\Forms\Db\Submission; use OCA\Forms\Db\SubmissionMapper; use OCA\Forms\Service\FormsService; +use OCA\Forms\Service\SubmissionService; use OCP\AppFramework\OCSController; use OCP\AppFramework\Db\DoesNotExistException; @@ -50,9 +50,9 @@ use OCP\AppFramework\Http\DataDownloadResponse; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCS\OCSBadRequestException; +use OCP\AppFramework\OCS\OCSException; use OCP\AppFramework\OCS\OCSForbiddenException; -use OCP\IConfig; -use OCP\IDateTimeFormatter; +use OCP\Files\NotPermittedException; use OCP\IL10N; use OCP\ILogger; use OCP\IRequest; @@ -61,10 +61,6 @@ use OCP\IUserSession; use OCP\Security\ISecureRandom; -use League\Csv\EscapeFormula; -use League\Csv\Reader; -use League\Csv\Writer; - class ApiController extends OCSController { protected $appName; @@ -89,11 +85,8 @@ class ApiController extends OCSController { /** @var FormsService */ private $formsService; - /** @var IConfig */ - private $config; - - /** @var IDateTimeFormatter */ - private $dateTimeFormatter; + /** @var SubmissionService */ + private $submissionService; /** @var IL10N */ private $l10n; @@ -118,8 +111,7 @@ public function __construct(string $appName, QuestionMapper $questionMapper, SubmissionMapper $submissionMapper, FormsService $formsService, - IConfig $config, - IDateTimeFormatter $dateTimeFormatter, + SubmissionService $submissionService, IL10N $l10n, ILogger $logger, IRequest $request, @@ -135,9 +127,8 @@ public function __construct(string $appName, $this->questionMapper = $questionMapper; $this->submissionMapper = $submissionMapper; $this->formsService = $formsService; + $this->submissionService = $submissionService; - $this->config = $config; - $this->dateTimeFormatter = $dateTimeFormatter; $this->l10n = $l10n; $this->logger = $logger; $this->userManager = $userManager; @@ -1077,82 +1068,44 @@ public function exportSubmissions(string $hash): DataDownloadResponse { throw new OCSForbiddenException(); } - try { - $submissionEntities = $this->submissionMapper->findByForm($form->getId()); - } catch (DoesNotExistException $e) { - // Just ignore, if no Data. Returns empty Submissions-Array - } - - $questions = $this->questionMapper->findByForm($form->getId()); - $defaultTimeZone = date_default_timezone_get(); - $userTimezone = $this->config->getUserValue('core', 'timezone', $this->currentUser->getUID(), $defaultTimeZone); - - // Process initial header - $header = []; - $header[] = $this->l10n->t('User display name'); - $header[] = $this->l10n->t('Timestamp'); - foreach ($questions as $question) { - $header[] = $question->getText(); - } - - // Init dataset - $data = []; - - // Process each answers - foreach ($submissionEntities as $submission) { - $row = []; - - // User - $user = $this->userManager->get($submission->getUserId()); - if ($user === null) { - $row[] = $this->l10n->t('Anonymous user'); - } else { - $row[] = $user->getDisplayName(); - } - - // Date - $row[] = $this->dateTimeFormatter->formatDateTime($submission->getTimestamp(), 'full', 'full', new DateTimeZone($userTimezone), $this->l10n); - - // Answers, make sure we keep the question order - $answers = array_reduce($this->answerMapper->findBySubmission($submission->getId()), function (array $carry, Answer $answer) { - $carry[$answer->getQuestionId()] = $answer->getText(); - return $carry; - }, []); - - foreach ($questions as $question) { - $row[] = key_exists($question->getId(), $answers) - ? $answers[$question->getId()] - : null; - } - - $data[] = $row; - } - - $fileName = $form->getTitle() . ' (' . $this->l10n->t('responses') . ').csv'; - return new DataDownloadResponse($this->array2csv($header, $data), $fileName, 'text/csv'); + $csv = $this->submissionService->getSubmissionsCsv($hash); + return new DataDownloadResponse($csv['data'], $csv['fileName'], 'text/csv'); } /** - * Convert an array to a csv string - * @param array $array - * @return string + * Export Submissions to the Cloud + * @param string $hash of the form + * @param string $path The Cloud-Path to export to + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException */ - private function array2csv(array $header, array $records): string { - if (empty($header) && empty($records)) { - return ''; - } + public function exportSubmissionsToCloud(string $hash, string $path) { + $this->logger->debug('Export submissions for form: {hash} to Cloud at: /{path}', [ + 'hash' => $hash, + 'path' => $path, + ]); - // load the CSV document from a string - $csv = Writer::createFromString(''); - $csv->setOutputBOM(Reader::BOM_UTF8); - $csv->addFormatter(new EscapeFormula()); + try { + $form = $this->formMapper->findByHash($hash); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form'); + throw new OCSBadRequestException(); + } - // insert the header - $csv->insertOne($header); + if ($form->getOwnerId() !== $this->currentUser->getUID()) { + $this->logger->debug('This form is not owned by the current user'); + throw new OCSForbiddenException(); + } - // insert all the records - $csv->insertAll($records); + // Write file to cloud + try { + $fileName = $this->submissionService->writeCsvToCloud($hash, $path); + } catch (NotPermittedException $e) { + $this->logger->debug('Failed to export Submissions: Not allowed to write to file'); + throw new OCSException('Not allowed to write to file.'); + } - return $csv->getContent(); + return new DataResponse($fileName); } } diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php new file mode 100644 index 000000000..19191547e --- /dev/null +++ b/lib/Service/SubmissionService.php @@ -0,0 +1,233 @@ + + * + * @author John Molakvoæ (skjnldsv) + * @author Jonas Rittershofer + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Forms\Service; + +use DateTimeZone; + +use OCA\Forms\Db\FormMapper; +use OCA\Forms\Db\QuestionMapper; +use OCA\Forms\Db\SubmissionMapper; +use OCA\Forms\Db\Answer; +use OCA\Forms\Db\AnswerMapper; + +use OCP\Files\File; +use OCP\Files\IRootFolder; +use OCP\Files\NotPermittedException; +use OCP\IConfig; +use OCP\IDateTimeFormatter; +use OCP\IL10N; +use OCP\ILogger; +use OCP\IUserManager; +use OCP\IUserSession; + +use League\Csv\EncloseField; +use League\Csv\EscapeFormula; +use League\Csv\Reader; +use League\Csv\Writer; + +class SubmissionService { + + /** @var FormMapper */ + private $formMapper; + + /** @var QuestionMapper */ + private $questionMapper; + + /** @var SubmissionMapper */ + private $submissionMapper; + + /** @var AnswerMapper */ + private $answerMapper; + + /** @var IRootFolder */ + private $storage; + + /** @var IConfig */ + private $config; + + /** @var IDateTimeFormatter */ + private $dateTimeFormatter; + + /** @var IL10N */ + private $l10n; + + /** @var ILogger */ + private $logger; + + /** @var IUserManager */ + private $userManager; + + public function __construct(FormMapper $formMapper, + QuestionMapper $questionMapper, + SubmissionMapper $submissionMapper, + AnswerMapper $answerMapper, + IRootFolder $storage, + IConfig $config, + IDateTimeFormatter $dateTimeFormatter, + IL10N $l10n, + ILogger $logger, + IUserManager $userManager, + IUserSession $userSession) { + $this->formMapper = $formMapper; + $this->questionMapper = $questionMapper; + $this->submissionMapper = $submissionMapper; + $this->answerMapper = $answerMapper; + $this->storage = $storage; + $this->config = $config; + $this->dateTimeFormatter = $dateTimeFormatter; + $this->l10n = $l10n; + $this->logger = $logger; + $this->userManager = $userManager; + + $this->currentUser = $userSession->getUser(); + } + + /** + * Export Submissions to Cloud-Filesystem + * @param string $hash of the form + * @param string $path The Cloud-Path to export to + * @return string The written fileName + * @throws NotPermittedException + */ + public function writeCsvToCloud(string $hash, string $path): string { + $node = $this->storage->getUserFolder($this->currentUser->getUID())->get($path); + + // Get Data + $csvData = $this->getSubmissionsCsv($hash); + + // If chosen path is a file, get folder, if file is csv, use filename. + if ($node instanceof File) { + if ($node->getExtension() === 'csv') { + $csvData['fileName'] = $node->getName(); + } + $node = $node->getParent(); + } + + // check if file exists, create otherwise. + try { + $file = $node->get($csvData['fileName']); + } catch (\OCP\Files\NotFoundException $e) { + $node->newFile($csvData['fileName']); + $file = $node->get($csvData['fileName']); + } + + // Write the data to file + $file->putContent($csvData['data']); + + return $csvData['fileName']; + } + + /** + * Create CSV from Submissions to form + * @param string $hash Hash of the form + * @return array Array with 'fileName' and 'data' + */ + public function getSubmissionsCsv(string $hash): array { + $form = $this->formMapper->findByHash($hash); + + try { + $submissionEntities = $this->submissionMapper->findByForm($form->getId()); + } catch (DoesNotExistException $e) { + // Just ignore, if no Data. Returns empty Submissions-Array + } + + $questions = $this->questionMapper->findByForm($form->getId()); + $defaultTimeZone = date_default_timezone_get(); + $userTimezone = $this->config->getUserValue('core', 'timezone', $this->currentUser->getUID(), $defaultTimeZone); + + // Process initial header + $header = []; + $header[] = $this->l10n->t('User display name'); + $header[] = $this->l10n->t('Timestamp'); + foreach ($questions as $question) { + $header[] = $question->getText(); + } + + // Init dataset + $data = []; + + // Process each answers + foreach ($submissionEntities as $submission) { + $row = []; + + // User + $user = $this->userManager->get($submission->getUserId()); + if ($user === null) { + $row[] = $this->l10n->t('Anonymous user'); + } else { + $row[] = $user->getDisplayName(); + } + + // Date + $row[] = $this->dateTimeFormatter->formatDateTime($submission->getTimestamp(), 'full', 'full', new DateTimeZone($userTimezone), $this->l10n); + + // Answers, make sure we keep the question order + $answers = array_reduce($this->answerMapper->findBySubmission($submission->getId()), function (array $carry, Answer $answer) { + $carry[$answer->getQuestionId()] = $answer->getText(); + return $carry; + }, []); + + foreach ($questions as $question) { + $row[] = key_exists($question->getId(), $answers) + ? $answers[$question->getId()] + : null; + } + + $data[] = $row; + } + + $fileName = $form->getTitle() . ' (' . $this->l10n->t('responses') . ').csv'; + + return [ + 'fileName' => $fileName, + 'data' => $this->array2csv($header, $data), + ]; + } + + /** + * Convert an array to a csv string + * @param array $array + * @return string + */ + private function array2csv(array $header, array $records): string { + if (empty($header) && empty($records)) { + return ''; + } + + // load the CSV document from a string + $csv = Writer::createFromString(''); + $csv->setOutputBOM(Reader::BOM_UTF8); + $csv->addFormatter(new EscapeFormula()); + EncloseField::addTo($csv, "\t\x1f"); + + // insert the header + $csv->insertOne($header); + + // insert all the records + $csv->insertAll($records); + + return $csv->getContent(); + } +} diff --git a/src/views/Results.vue b/src/views/Results.vue index 0082aed5d..2f2cbaae9 100644 --- a/src/views/Results.vue +++ b/src/views/Results.vue @@ -74,8 +74,11 @@ + + {{ t('forms', 'Save CSV to Files') }} + - {{ t('forms', 'Export to CSV') }} + {{ t('forms', 'Download CSV') }} {{ t('forms', 'Delete all responses') }} @@ -123,7 +126,7 @@