From bde8b86dc6a5199dacda4686a41945bd2a05f58d Mon Sep 17 00:00:00 2001 From: hamza221 Date: Sun, 13 Nov 2022 20:23:07 +0100 Subject: [PATCH] Save a Single Question as csv Signed-off-by: hamza221 --- appinfo/routes.php | 32 ++++ docs/API.md | 53 +++++++ lib/Controller/ApiController.php | 155 +++++++++++++++++- lib/Db/AnswerMapper.php | 23 +++ lib/Service/SubmissionService.php | 182 +++++++++++++++++++++- src/components/Results/ResultsSummary.vue | 71 ++++++++- src/components/Results/Submission.vue | 49 ++++++ 7 files changed, 553 insertions(+), 12 deletions(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index 269682834..0a6dd8ae1 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -291,6 +291,22 @@ 'apiVersion' => 'v2(\.[1-2])?' ] ], + [ + 'name' => 'api#exportQuestion', + 'url' => '/api/{apiVersion}/submissions/exportQuestion/{questionId}', + 'verb' => 'GET', + 'requirements' => [ + 'apiVersion' => 'v2.2' + ] + ], + [ + 'name' => 'api#exportSubmission', + 'url' => '/api/{apiVersion}/submissions/exportSubmission/{submissionId}', + 'verb' => 'GET', + 'requirements' => [ + 'apiVersion' => 'v2.2' + ] + ], [ 'name' => 'api#exportSubmissionsToCloud', 'url' => '/api/{apiVersion}/submissions/export', @@ -299,6 +315,22 @@ 'apiVersion' => 'v2(\.[1-2])?' ] ], + [ + 'name' => 'api#exportQuestionToCLoud', + 'url' => '/api/{apiVersion}/submissions/exportQuestion', + 'verb' => 'POST', + 'requirements' => [ + 'apiVersion' => 'v2.2' + ] + ], + [ + 'name' => 'api#exportSubmissionToCloud', + 'url' => '/api/{apiVersion}/submissions/exportSubmission', + 'verb' => 'POST', + 'requirements' => [ + 'apiVersion' => 'v2.2' + ] + ], [ 'name' => 'api#deleteAllSubmissions', 'url' => '/api/{apiVersion}/submissions/{formId}', diff --git a/docs/API.md b/docs/API.md index 5871dd534..47a215dca 100644 --- a/docs/API.md +++ b/docs/API.md @@ -525,6 +525,33 @@ Returns all submissions to the form in form of a csv-file. "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" ``` +### Get a single Submission as csv (Download) +Returns one submission in form of a csv-file. +- Endpoint: `/api/v2.2/submissions//exportSubmission/{submissionId}` +- Url-Parameter: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _submissionId_ | Integer | Id of the submission | +- 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" +``` +### Get a single Question as csv (Download) +Returns one question in form of a csv-file. +- Endpoint: `/api/v2.2/submissions//exportQuestion/{questionId}` +- Url-Parameter: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _questionId_ | Integer | Id of the question | +- 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" +"jonas","Friday, January 22, 2021 at 12:47:29 AM GMT+0:00","Option 2" +"jonas","Friday, January 22, 2021 at 12:45:57 AM GMT+0:00","Option 3" +``` ### Export Submissions to Cloud (Files-App) Creates a csv file and stores it to the cloud, resp. Files-App. @@ -539,6 +566,32 @@ Creates a csv file and stores it to the cloud, resp. Files-App. ``` "data": "Form 2 (responses).csv" ``` +### Export Submission to Cloud (Files-App) +Creates a csv file of one submission and stores it to the cloud, resp. Files-App. +- Endpoint: `/api/v2.2/submissions/exportSubmission` +- Method: `POST` +- Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _submissionId_ | Integer | Id of the submission | + | _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" +``` +### Export Question to Cloud (Files-App) +Creates a csv file of one question answers and stores it to the cloud, resp. Files-App. +- Endpoint: `/api/v2.2/submissions/exportQuestion` +- Method: `POST` +- Parameters: + | Parameter | Type | Description | + |-----------|---------|-------------| + | _questionId_ | Integer | Id of the question | + | _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 Delete all Submissions to a form diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 7e4c6cfc0..857945972 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -1096,7 +1096,72 @@ public function exportSubmissions(string $hash): DataDownloadResponse { $csv = $this->submissionService->getSubmissionsCsv($hash); return new DataDownloadResponse($csv['data'], $csv['fileName'], 'text/csv'); } + /** + * @NoAdminRequired + * @NoCSRFRequired + * + * Export submissions of a specified Question + * + * @param int $questionId of the question + * @return DataDownloadResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function exportQuestion(int $questionId): DataDownloadResponse { + $this->logger->debug('Export submissions for Question: {questionId}', [ + 'questionId' => $questionId, + ]); + try { + $question = $this->questionMapper->findById($questionId); + $formId = $question->getFormId(); + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find question'); + throw new OCSBadRequestException(); + } + + if (!$this->formsService->canSeeResults($form)) { + $this->logger->debug('The current user has no permission to get the results for this form'); + throw new OCSForbiddenException(); + } + + $csv = $this->submissionService->getQuestionCsv($formId, $questionId); + return new DataDownloadResponse($csv['data'], $csv['fileName'], 'text/csv'); + } + /** + * @NoAdminRequired + * @NoCSRFRequired + * + * Export a single submission + * + * @param int $submissionId of the submission + * @return DataDownloadResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function exportSubmission(int $submissionId): DataDownloadResponse { + $this->logger->debug('Export submission: {submissionId}', [ + 'submissionId' => $submissionId, + ]); + + try { + $submission = $this->submissionMapper->findById($submissionId); + $formId = $submission->getFormId(); + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find submission'); + throw new OCSBadRequestException(); + } + + if (!$this->formsService->canSeeResults($form)) { + $this->logger->debug('The current user has no permission to get the results for this form'); + throw new OCSForbiddenException(); + } + + $csv = $this->submissionService->getSubmissionCsv($formId, $submissionId); + return new DataDownloadResponse($csv['data'], $csv['fileName'], 'text/csv'); + } /** * @CORS * @NoAdminRequired @@ -1129,12 +1194,100 @@ public function exportSubmissionsToCloud(string $hash, string $path) { // Write file to cloud try { - $fileName = $this->submissionService->writeCsvToCloud($hash, $path); + $csvData = $this->submissionService->getSubmissionsCsv($hash); + $fileName = $this->submissionService->writeCsvToCloud($csvData, $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 new DataResponse($fileName); + } + /** + * @CORS + * @NoAdminRequired + * + * Export Submission to the Cloud + * + * @param int $submissionId of the submission + * @param string $path The Cloud-Path to export to + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function exportSubmissionToCloud(int $submissionId, string $path) { + $this->logger->debug('Export submission: {submissionId} to Cloud at: /{path}', [ + '$submissionId' => $submissionId, + 'path' => $path, + ]); + + try { + $submission = $this->submissionMapper->findById($submissionId); + $formId = $submission->getFormId(); + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find submission'); + throw new OCSBadRequestException(); + } + + if (!$this->formsService->canSeeResults($form)) { + $this->logger->debug('The current user has no permission to get the results for this form'); + throw new OCSForbiddenException(); + } + + // Write file to cloud + try { + $csvData = $this->submissionService->getSubmissionCsv($formId, $submissionId); + $fileName = $this->submissionService->writeCsvToCloud($csvData, $path); + } catch (NotPermittedException $e) { + $this->logger->debug('Failed to export Submission: Not allowed to write to file'); + throw new OCSException('Not allowed to write to file.'); + } + + return new DataResponse($fileName); + } + + /** + * @CORS + * @NoAdminRequired + * + * Export Question to the Cloud + * + * @param int $questionId of the question + * @param string $path The Cloud-Path to export to + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function exportQuestionToCLoud(int $questionId, string $path) { + $this->logger->debug('Export question : {questionId} to Cloud at: /{path}', [ + 'questionId' => $questionId, + 'path' => $path, + ]); + + try { + $question = $this->questionMapper->findById($questionId); + $formId = $question->getFormId(); + $form = $this->formMapper->findById($formId); + } catch (IMapperException $e) { + $this->logger->debug('Could not find submission'); + throw new OCSBadRequestException(); + } + + if (!$this->formsService->canSeeResults($form)) { + $this->logger->debug('The current user has no permission to get the results for this form'); + throw new OCSForbiddenException(); + } + + // Write file to cloud + try { + $csvData = $this->submissionService->getQuestionCsv($formId, $questionId); + $fileName = $this->submissionService->writeCsvToCloud($csvData, $path); + } catch (NotPermittedException $e) { + $this->logger->debug('Failed to export Question: Not allowed to write to file'); + throw new OCSException('Not allowed to write to file.'); + } + return new DataResponse($fileName); } } diff --git a/lib/Db/AnswerMapper.php b/lib/Db/AnswerMapper.php index 7ab374eae..20ef504d2 100644 --- a/lib/Db/AnswerMapper.php +++ b/lib/Db/AnswerMapper.php @@ -60,6 +60,29 @@ public function findBySubmission(int $submissionId): array { return $this->findEntities($qb); } + /** + * @param int $submissionId + * @param int $questionId + * @throws \OCP\AppFramework\Db\DoesNotExistException if not found + * @return Answer[] + */ + + public function findBySubmissionAndQuestionId(int $submissionId, int $questionId): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('submission_id', $qb->createNamedParameter($submissionId, IQueryBuilder::PARAM_INT)) + ) + ->andWhere( + $qb->expr()->eq('question_id', $qb->createNamedParameter($questionId, IQueryBuilder::PARAM_INT)) + + ); + + return $this->findEntities($qb); + } + /** * @param int $submissionId */ diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index 2f61184f9..205553990 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -43,6 +43,7 @@ use OCP\Files\IRootFolder; use OCP\Files\NotPermittedException; use OCP\IConfig; +use OCP\IDateTimeFormatter; use OCP\IL10N; use OCP\IUser; @@ -92,6 +93,7 @@ public function __construct(FormMapper $formMapper, AnswerMapper $answerMapper, IRootFolder $storage, IConfig $config, + IDateTimeFormatter $dateTimeFormatter, IL10N $l10n, LoggerInterface $logger, IUserManager $userManager, @@ -103,6 +105,7 @@ public function __construct(FormMapper $formMapper, $this->answerMapper = $answerMapper; $this->storage = $storage; $this->config = $config; + $this->dateTimeFormatter = $dateTimeFormatter; $this->l10n = $l10n; $this->logger = $logger; $this->userManager = $userManager; @@ -155,18 +158,15 @@ public function getSubmissions(int $formId): array { /** * Export Submissions to Cloud-Filesystem - * @param string $hash of the form + * @param array $csvData 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 { + public function writeCsvToCloud(array $csvData, string $path): string { /** @var \OCP\Files\Folder|\OCP\Files\File $node */ $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') { @@ -191,7 +191,6 @@ public function writeCsvToCloud(string $hash, string $path): string { return $csvData['fileName']; } - /** * Create CSV from Submissions to form * @param string $hash Hash of the form @@ -239,7 +238,7 @@ public function getSubmissionsCsv(string $hash): array { } // Date - $row[] = date_format(date_timestamp_set(new DateTime(), $submission->getTimestamp())->setTimezone(new DateTimeZone($userTimezone)), 'c'); + $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) { @@ -274,7 +273,174 @@ public function getSubmissionsCsv(string $hash): array { 'data' => $this->array2csv($header, $data), ]; } - + /** + * Create Question specific CSV from Submissions to form + * @param string $formId id of the form + * @param string $questionId id of the question + * @return array{fileName:string,data:string} Array with 'fileName' and 'data' + */ + + public function getQuestionCsv(int $formId, int $questionId): array { + $form = $this->formMapper->findByid($formId); + + try { + $submissionEntities = $this->submissionMapper->findByForm($form->getId()); + } catch (DoesNotExistException $e) { + // Just ignore, if no Data. Returns empty Submissions-Array + } + + $question = $this->questionMapper->findById($questionId); + $defaultTimeZone = date_default_timezone_get(); + $userTimezone = $this->config->getUserValue($this->currentUser->getUID(), 'core', 'timezone', $defaultTimeZone); + + // Process initial header + $header = []; + $header[] = $this->l10n->t('User ID'); + $header[] = $this->l10n->t('User display name'); + $header[] = $this->l10n->t('Timestamp'); + $header[] = $question->getText(); + + + // Init dataset + $data = []; + + + // Process each answers + foreach ($submissionEntities as $submission) { + $currentSubmissionAnswers = $this->answerMapper->findBySubmissionAndQuestionId($submission->getId(), $questionId); + $row = []; + + // User + $user = $this->userManager->get($submission->getUserId()); + if ($user === null) { + // Give empty userId + $row[] = ''; + // TRANSLATORS Shown on export if no Display-Name is available. + $row[] = $this->l10n->t('Anonymous user'); + } else { + $row[] = $user->getUID(); + $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($currentSubmissionAnswers, function (array $carry, Answer $answer) { + $questionId = $answer->getQuestionId(); + + // If key exists, insert separator + if (key_exists($questionId, $carry)) { + $carry[$questionId] .= '; ' . $answer->getText(); + } else { + $carry[$questionId] = $answer->getText(); + } + + return $carry; + }, []); + + + $row[] = key_exists($question->getId(), $answers) + ? $answers[$question->getId()] + : null; + + + $data[] = $row; + } + + // TRANSLATORS Appendix for CSV-Export: 'Form Title (responses).csv' + $fileName = $form->getTitle() . ' (' . $this->l10n->t('responses') . ').csv'; + + return [ + 'fileName' => $fileName, + 'data' => $this->array2csv($header, $data), + ]; + } + + /** + * Create a submission Csv + * @param string $formId id of the form + * @param string $questionId id of the question + * @return array{fileName:string,data:string} Array with 'fileName' and 'data' + */ + + public function getSubmissionCsv(int $formId, int $submissionId): array { + + try { + $submission = $this->submissionMapper->findById($submissionId); + $form = $this->formMapper->findById($formId); + + } 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($this->currentUser->getUID(), 'core', 'timezone', $defaultTimeZone); + + // Process initial header + $header = []; + $header[] = $this->l10n->t('User ID'); + $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 + + $row = []; + + // User + $user = $this->userManager->get($submission->getUserId()); + if ($user === null) { + // Give empty userId + $row[] = ''; + // TRANSLATORS Shown on export if no Display-Name is available. + $row[] = $this->l10n->t('Anonymous user'); + } else { + $row[] = $user->getUID(); + $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) { + $questionId = $answer->getQuestionId(); + + // If key exists, insert separator + if (key_exists($questionId, $carry)) { + $carry[$questionId] .= '; ' . $answer->getText(); + } else { + $carry[$questionId] = $answer->getText(); + } + + return $carry; + }, []); + + foreach ($questions as $question) { + $row[] = key_exists($question->getId(), $answers) + ? $answers[$question->getId()] + : null; + } + + $data[] = $row; + + + // TRANSLATORS Appendix for CSV-Export: 'Form Title (responses).csv' + $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 diff --git a/src/components/Results/ResultsSummary.vue b/src/components/Results/ResultsSummary.vue index 7889fe3ca..404bb8a8f 100644 --- a/src/components/Results/ResultsSummary.vue +++ b/src/components/Results/ResultsSummary.vue @@ -22,9 +22,26 @@