Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Poc classe Import #4529

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
106 changes: 106 additions & 0 deletions admin/src/scenes/classe/Import.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React, { ChangeEvent, useRef, useState } from "react";
import ReactLoading from "react-loading";
import { HiOutlineDocumentAdd } from "react-icons/hi";

import { MIME_TYPES, translate } from "snu-lib";

import api from "@/services/api";
import { capture } from "@/sentry";

import { Button, Container, Header, Page } from "@snu/ds/admin";

import { toastr } from "react-redux-toastr";

const FILE_SIZE_LIMIT = 5 * 1024 * 1024;

export default function Import() {
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);

function importFile(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
setUploadError(null);
if (fileInput && fileInput.current) {
fileInput.current.click();
}
}

async function handleUpload(e: ChangeEvent<HTMLInputElement>) {
if (!e?.target?.files?.length) return;
const file = e.target.files[0];
setUploadError(null);
if (file.type !== MIME_TYPES.EXCEL) {
setUploadError("Le fichier doit être au format Excel.");
return;
}
if (file.size > FILE_SIZE_LIMIT) {
setUploadError("Votre fichier dépasse la limite de 5Mo.");
return;
}

setIsUploading(true);
try {
const res = await api.uploadFiles(`/cle/classe/importAuto/classe-importAuto`, [file]);
if (res.code === "FILE_CORRUPTED") {
setUploadError("Le fichier semble corrompu. Pouvez-vous changer le format ou regénérer votre fichier ? Si vous rencontrez toujours le problème, contactez le support.");
} else if (!res.ok) {
toastr.error("Une erreur est survenue lors de l'import du fichier", translate(res.code));
capture(res.code);
setUploadError("Une erreur s'est produite lors du téléversement de votre fichier.");
} else {
toastr.success("Succès", "Fichier importé avec succès");
const { data: base64Data, mimeType, fileName } = res;
const binaryData = Uint8Array.from(atob(base64Data), (c) => c.charCodeAt(0));

const blob = new Blob([binaryData], { type: mimeType });
const url = window.URL.createObjectURL(blob);

const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();

a.remove();
window.URL.revokeObjectURL(url);
}
} catch (err) {
setUploadError("Une erreur est survenue. Nous n'avons pu enregistrer le fichier. Veuillez réessayer dans quelques instants.");
}
setIsUploading(false);
}

return (
<Page>
<Header title="Import des classes" breadcrumb={[{ title: "Séjours" }, { title: "Classes" }, { title: "Import" }]} />
<Container>
<div className="mt-8 flex w-full flex-col items-center justify-center bg-white rounded-xl px-8 pt-12 pb-24">
<div className="bg-gray-50 w-full flex-col pb-5">
<h1 className="text-lg leading-6 font-medium text-gray-900 text-center mt-12 mb-2">Mettre à jour les classes</h1>
<p className="text-sm leading-5 font-normal text-gray-500 text-center mb-12">Importez votre fichier (au format .xlsx jusqu’à 5Mo)</p>

{!isUploading ? (
<>
<Button
onClick={importFile}
className="cursor-pointer text-center mx-auto text-blue-600"
leftIcon={<HiOutlineDocumentAdd className="mt-0.5 mr-2" size={20} />}
title="Téléversez votre fichier"></Button>
<input type="file" accept={MIME_TYPES.EXCEL} ref={fileInput} onChange={handleUpload} className="hidden" />
{uploadError && <div className="mt-8 text-center text-sm font-bold text-red-900">{uploadError}</div>}
</>
) : (
<>
<div className="mx-auto flex justify-center mb-2">
<p>Temps Estimé : 1 minute</p>
</div>
<ReactLoading className="mx-auto" type="spin" color="#2563EB" width={"40px"} height={"40px"} />
</>
)}
</div>
</div>
</Container>
</Page>
);
}
2 changes: 2 additions & 0 deletions admin/src/scenes/classe/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { SentryRoute } from "../../sentry";
import Create from "./create";
import List from "./list";
import View from "./view";
import Import from "./Import";
import { toastr } from "react-redux-toastr";
import NotFound from "@/components/layout/NotFound";

Expand All @@ -15,6 +16,7 @@ export default function Index() {
return (
<Switch>
<SentryRoute path="/classes/create" component={Create} />
<SentryRoute path="/classes/import" component={Import} />
<SentryRoute
path="/classes/:id"
render={({ match }) => {
Expand Down
8 changes: 7 additions & 1 deletion admin/src/scenes/classe/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Filters, ResultTable, Save, SelectedFilters, SortOption } from "@/compo
import { capture } from "@/sentry";
import api from "@/services/api";
import { Button, Container, Header, Page } from "@snu/ds/admin";
import { ROLES, translateStatusClasse, translate, EtablissementType, ClasseType } from "snu-lib";
import { ROLES, translateStatusClasse, translate, EtablissementType, ClasseType, isSuperAdmin } from "snu-lib";
import { orderCohort } from "../../components/filters-system-v2/components/filters/utils";

import { getCohortGroups } from "@/services/cohort.service";
Expand Down Expand Up @@ -142,12 +142,18 @@ export default function List() {
[ROLES.ADMIN, ROLES.REFERENT_REGION].includes(user.role) && (
<Button
title="Exporter le SR"
className="mr-2"
onClick={() => exportData({ type: "schema-de-repartition" })}
loading={exportLoading}
disabled={!isCohortSelected}
tooltip="Vous devez selectionner une cohort pour pouvoir exporter le SR"
/>
),
isSuperAdmin(user) && (
<Link to="/classes/import">
<Button title="Mettre à jour" className="mr-2" loading={exportLoading} />
</Link>
),
].filter(Boolean)}
/>
{!isClasses && (
Expand Down
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,6 @@
"supertest": "6.3.4",
"ts-jest": "^29.2.5",
"typescript": "5.4.5",
"xlsx": "~0.18.5"
"xlsx": "^0.18.5"
}
}
80 changes: 80 additions & 0 deletions api/src/cle/classe/importAuto/classeImportAutoController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import express from "express";
import fileUpload from "express-fileupload";
import { UploadedFile } from "express-fileupload";
import fs from "fs";

import { isSuperAdmin, ROLES } from "snu-lib";
import { ERRORS, uploadFile } from "../../../utils";
import { UserRequest } from "../../../controllers/request";
import { readCSVBuffer } from "../../../services/fileService";
import { capture } from "../../../sentry";
import { generateCSVStream, getHeaders, streamToBuffer, XLSXToCSVBuffer } from "../../../services/fileService";
import { accessControlMiddleware } from "../../../middlewares/accessControlMiddleware";
import { authMiddleware } from "../../../middlewares/authMiddleware";

import { updateClasseFromExport } from "./classeImportAutoService";
import { ClasseFromCSV } from "./classeImportAutoType";

const router = express.Router();
router.use(authMiddleware("referent"));
const xlsxMimetype = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";

router.post(
"/classe-importAuto",
[accessControlMiddleware([ROLES.ADMIN])],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pour un superAdmin tu peux faire : accessControlMiddleware([])

fileUpload({ limits: { fileSize: 5 * 1024 * 1024 }, useTempFiles: true, tempFileDir: "/tmp/" }),
async (req: UserRequest, res) => {
if (!isSuperAdmin(req.user)) {
return res.status(403).send({ ok: false, code: ERRORS.OPERATION_UNAUTHORIZED });
}

try {
const files = Object.values(req.files || {});
if (files.length === 0) {
return res.status(400).send({ ok: false, code: ERRORS.INVALID_BODY });
}

const file: UploadedFile = files[0];
if (file.mimetype !== xlsxMimetype) {
return res.status(400).send({ ok: false, code: ERRORS.INVALID_BODY });
}

const filePath = file.tempFilePath;
const timestamp = `${new Date().toISOString()?.replaceAll(":", "-")?.replace(".", "-")}`;

const data = fs.readFileSync(filePath);

uploadFile(`file/appelAProjet/import/import-classe/import-${timestamp}/${timestamp}-exported-classes.xlsx`, {
data: data,
encoding: "",
mimetype: xlsxMimetype,
});

const csvBuffer = XLSXToCSVBuffer(filePath);
const parsedContent: ClasseFromCSV[] = await readCSVBuffer(csvBuffer);
const importedClasseCohort = await updateClasseFromExport(parsedContent);

const headers = getHeaders(importedClasseCohort);
const csvDataReponse = generateCSVStream(importedClasseCohort, headers);
uploadFile(`file/appelAProjet/import/import-classe/import-${timestamp}/${timestamp}-updated-classes.csv`, {
data: csvDataReponse,
encoding: "",
mimetype: "text/csv",
});

const csvBufferResponse = Buffer.from(await streamToBuffer(csvDataReponse));

return res.status(200).send({
data: csvBufferResponse.toString("base64"),
mimeType: "text/csv",
fileName: `${timestamp}-updated-classes.csv`,
ok: true,
});
} catch (error) {
capture(error);
return res.status(422).send({ ok: false, code: error.message });
}
},
);

export default router;
15 changes: 15 additions & 0 deletions api/src/cle/classe/importAuto/classeImportAutoMapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ClasseFromCSV, ClasseMapped } from "./classeImportAutoType";

export const mapClassesForUpdate = (classesChortes: ClasseFromCSV[]): ClasseMapped[] => {
return classesChortes.map((classeCohorte) => {
const classeCohorteMapped: ClasseMapped = {
classeId: classeCohorte["Identifiant de la classe engagée"],
cohortCode: classeCohorte["Session formule"],
classeTotalSeats: classeCohorte["Effectif de jeunes concernés"],
centerCode: classeCohorte["Désignation du centre"],
pdrCode: classeCohorte["Code point de rassemblement initial"],
sessionCode: `${classeCohorte["Session : Code de la session"]}_${classeCohorte["Désignation du centre"]}`,
};
return classeCohorteMapped;
});
};
Loading
Loading