diff --git a/locales/en/admin.json b/locales/en/admin.json index f24024b7..a3c9a145 100644 --- a/locales/en/admin.json +++ b/locales/en/admin.json @@ -124,5 +124,14 @@ }, "InterviewId": "interview ID", "HouseholdSize": "Household size", - "Select": "Select" + "Select": "Select", + "export": { + "PrepareCsvExportFiles": "Prepare CSV files for export by object", + "RefreshExportFiles": "Refresh export file list", + "ServerErrorForExport": "Server error while preparing export files", + "ServerExportForbidden": "You are not authorized to export data", + "PrepareDataError": "Unexpected error while preparing data for export", + "UpdateOrWaitError": "Error while updating export files. Please wait a few seconds and try again", + "WaitForFileError": "Error getting the files to export from server" + } } \ No newline at end of file diff --git a/locales/fr/admin.json b/locales/fr/admin.json index f2c8d53f..04149800 100644 --- a/locales/fr/admin.json +++ b/locales/fr/admin.json @@ -124,5 +124,14 @@ }, "InterviewId": "ID d'entrevue", "HouseholdSize": "Taille du ménage", - "Select": "Sélectionner" + "Select": "Sélectionner", + "export": { + "PrepareCsvExportFiles": "Préparer les fichiers CSV pour exportation par objet", + "RefreshExportFiles": "Rafraîchir la liste des fichiers d'export", + "ServerErrorForExport": "Erreur de serveur pendant la préparation des fichiers d'export", + "ServerExportForbidden": "Vous n'êtes pas autorisé à exporter les données d'enquête", + "PrepareDataError": "Erreur inattendue en préparant les fichiers d'export", + "UpdateOrWaitError": "Erreur lors de la mise à jour des fichiers d'export. Veuillez ré-essayer plus tard.", + "WaitForFileError": "Erreur en attendant la fin de la préparation des fichiers d'export" + } } \ No newline at end of file diff --git a/packages/evolution-backend/src/api/admin.routes.ts b/packages/evolution-backend/src/api/admin.routes.ts index 0fe68946..3f24e92e 100644 --- a/packages/evolution-backend/src/api/admin.routes.ts +++ b/packages/evolution-backend/src/api/admin.routes.ts @@ -8,6 +8,10 @@ import moment from 'moment'; import knex from 'chaire-lib-backend/lib/config/shared/db.config'; import router from 'chaire-lib-backend/lib/api/admin.routes'; +// Add export routes from admin/exports.routes +import { addExportRoutes } from './admin/exports.routes'; + +addExportRoutes(); router.all('/data/widgets/:widget/', (req, res, next) => { const widgetName = req.params.widget; diff --git a/packages/evolution-backend/src/api/admin/exports.routes.ts b/packages/evolution-backend/src/api/admin/exports.routes.ts new file mode 100644 index 00000000..d44459d0 --- /dev/null +++ b/packages/evolution-backend/src/api/admin/exports.routes.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2024, Polytechnique Montreal and contributors + * + * This file is licensed under the MIT License. + * License text available at https://opensource.org/licenses/MIT + */ +import path from 'path'; +import { + exportAllToCsvByObject, + filePathOnServer, + getExportFiles, + isExportRunning +} from '../../services/adminExport/exportAllToCsvByObject'; +import { fileManager } from 'chaire-lib-backend/lib/utils/filesystem/fileManager'; +import * as Status from 'chaire-lib-common/lib/utils/Status'; + +// TODO Do not use the main admin router. Fine-tune the permissions for the export routes. +import router from 'chaire-lib-backend/lib/api/admin.routes'; + +export const addExportRoutes = () => { + console.log('addin export routes'); + // Get a specific export file per object + router.get('/data/exportcsv/exports/:filePath', (req, res, next) => { + console.log('requesting csv file from path', req.params.filePath); + const projectRelativeFilePath = `${filePathOnServer}/${req.params.filePath}`; + const fileExists = fileManager.fileExists(projectRelativeFilePath); + if (fileExists) { + const fileName = path.basename(req.params.filePath); + res.setHeader('Content-disposition', `attachment; filename=${fileName}`); + res.set('Content-Type', 'text/csv'); + return res.status(200).sendFile(fileManager.getAbsolutePath(projectRelativeFilePath)); + } else { + return res.status(500).json({ + status: 'error', + error: 'file does not exist' + }); + } + }); + + // Route to get the status of the export task and the list of files + router.get('/data/getExportTaskResults', (req, res, next) => { + console.log('getting csv export files...'); + try { + if (isExportRunning()) { + return res.status(200).json(Status.createOk({ taskRunning: true, files: [] })); + } + const files = getExportFiles(); + return res.status(200).json(Status.createOk({ taskRunning: false, files })); + } catch (error) { + console.log('error getting csv export files', error); + return res.status(500).json(Status.createError('Error getting csv files')); + } + }); + + // Route to prepare the csv files to export + router.get('/data/prepareCsvFileForExportByObject', (req, res, next) => { + console.log('preparing csv export files...'); + try { + const taskRunning = exportAllToCsvByObject(); + return res.status(200).json({ + status: taskRunning + }); + } catch (error) { + return res.status(500).json({ + status: 'error', + csvExportFilePaths: [], + error: 'could not prepare csv files for export: ' + error + }); + } + }); +}; diff --git a/packages/evolution-frontend/src/components/admin/ExportInterviewData.tsx b/packages/evolution-frontend/src/components/admin/ExportInterviewData.tsx new file mode 100644 index 00000000..b930b746 --- /dev/null +++ b/packages/evolution-frontend/src/components/admin/ExportInterviewData.tsx @@ -0,0 +1,131 @@ +/* + * Copyright 2024, Polytechnique Montreal and contributors + * + * This file is licensed under the MIT License. + * License text available at https://opensource.org/licenses/MIT + */ +import React, { useEffect, useState } from 'react'; +import { WithTranslation, withTranslation } from 'react-i18next'; +import Button from 'chaire-lib-frontend/lib/components/input/Button'; +import FormErrors from 'chaire-lib-frontend/lib/components/pageParts/FormErrors'; +import LoadingPage from 'chaire-lib-frontend/lib/components/pages/LoadingPage'; +import * as Status from 'chaire-lib-common/lib/utils/Status'; +import TrError from 'chaire-lib-common/lib/utils/TrError'; + +const ExportInterviewData = ({ t }: WithTranslation) => { + const [error, setError] = useState(undefined); + const [isPreparingCsvExportFiles, setIsPreparingCsvExportFiles] = useState(false); + const [csvExportFilesReady, setCsvExportFilesReady] = useState(false); + const [csvExportFilePaths, setCsvExportFilePaths] = useState([]); + + useEffect(() => { + updateOrWaitForFiles(false); + }, []); + + const onPrepareCsvExportFiles = async () => { + setIsPreparingCsvExportFiles(true); + try { + const response = await fetch('/api/admin/data/prepareCsvFileForExportByObject', { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json' + } + }); + if (response.status === 200) { + updateOrWaitForFiles(); + } else { + throw new TrError( + 'Wrong response status ' + response.status, + 'prepareNoResponse', + response.status >= 500 + ? 'admin:export:ServerErrorForExport' + : response.status === 401 + ? 'admin:export:ServerExportForbidden' + : 'admin:export:PrepareDataError' + ); + } + } catch (err) { + console.log('Error preparing export files.', err); + setError(TrError.isTrError(err) ? err.message : 'admin:export:PrepareDataError'); + setCsvExportFilesReady(false); + setCsvExportFilePaths([]); + setIsPreparingCsvExportFiles(false); + } + }; + + const updateOrWaitForFiles = async (listenForFiles = true) => { + try { + const response = await fetch('/api/admin/data/getExportTaskResults', { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json' + } + }); + if (response.status === 200) { + const status: Status.Status<{ taskRunning: boolean; files: string[] }> = await response.json(); + if (Status.isStatusOk(status)) { + const { taskRunning, files } = Status.unwrap(status); + setCsvExportFilesReady(!taskRunning); + setCsvExportFilePaths(files); + setError(undefined); + setIsPreparingCsvExportFiles(taskRunning); + if (taskRunning && listenForFiles) { + setTimeout(updateOrWaitForFiles, 10000); + } + } else { + throw status.error; + } + } else { + throw new TrError( + 'Wrong response status ' + response.status, + 'updateOrWaitResponse', + response.status >= 500 + ? 'admin:export:ServerErrorForExport' + : response.status === 401 + ? 'admin:export:ServerExportForbidden' + : 'admin:export:UpdateOrWaitError' + ); + } + } catch (err) { + console.log('Error fetching export files.', err); + setError(TrError.isTrError(err) ? err.message : 'admin:export:WaitForFileError'); + setCsvExportFilesReady(false); + setCsvExportFilePaths([]); + setIsPreparingCsvExportFiles(false); + } + }; + + const onRefreshExportFiles = () => updateOrWaitForFiles(false); + + const csvFileExportLinks = csvExportFilePaths.map((csvFilePath) => ( +
  • + + {csvFilePath} + +
  • + )); + + return ( +
    + {isPreparingCsvExportFiles && } +
    + ); +}; + +export default withTranslation()(ExportInterviewData); diff --git a/packages/evolution-legacy/src/components/admin/Monitoring.js b/packages/evolution-legacy/src/components/admin/Monitoring.js index 425a3f3c..720182fc 100644 --- a/packages/evolution-legacy/src/components/admin/Monitoring.js +++ b/packages/evolution-legacy/src/components/admin/Monitoring.js @@ -10,6 +10,7 @@ import moment from 'moment'; import appConfig from 'evolution-frontend/lib/config/application.config'; import StartedAndCompletedInterviewsByDay from './monitoring/StartedAndCompletedInterviewByDay'; +import ExportInterviewData from 'evolution-frontend/lib/components/admin/ExportInterviewData'; // FIXME Commented 2023-11-07 because of od_mtl_2023, it takes too long. Should it be a default widget? Or rather a widget implemented in evolution that surveys can optionally add? //import InterviewsByHouseholdSize from './monitoring/InterviewsByHouseholdSize'; //import config from 'chaire-lib-common/lib/config/shared/project.config'; @@ -43,6 +44,7 @@ class Monitoring extends React.Component {
    + {customMonitoringComponentsArray}