feat: Gestion de la sauvegarde du fichier d'inscription / affichage du

fichier avec le bon nom / possibilité de refuser un DI
This commit is contained in:
N3WT DE COMPET
2025-03-31 20:13:10 +02:00
parent e0bfd3e115
commit d6edf250bb
11 changed files with 281 additions and 102 deletions

View File

@ -44,6 +44,11 @@
"from": "ENVOYE",
"to": "ARCHIVE"
},
{
"name": "refuseDI",
"from": "EN_VALIDATION",
"to": "ENVOYE"
},
{
"name": "valideDI",
"from": "EN_VALIDATION",

View File

@ -16,7 +16,9 @@ from enum import Enum
import random
import string
from rest_framework.parsers import JSONParser
import pymupdf
from PyPDF2 import PdfMerger
import shutil
def recupereListeFichesInscription():
"""
@ -96,23 +98,29 @@ def merge_files_pdf(filenames, output_filename):
Fusionne plusieurs fichiers PDF en un seul document.
Vérifie l'existence des fichiers sources avant la fusion.
"""
merger = pymupdf.open()
merger = PdfMerger()
valid_files = []
# Vérifier l'existence des fichiers et ne garder que ceux qui existent
print(f'filenames : {filenames}')
for filename in filenames:
print(f'check exists filename : {filename}')
if os.path.exists(filename):
print(f'append filename : {filename}')
valid_files.append(filename)
# Fusionner les fichiers valides
if not valid_files:
raise FileNotFoundError("Aucun fichier valide à fusionner.")
# Ajouter les fichiers valides au merger
for filename in valid_files:
merger.insert_file(filename)
merger.append(filename)
# S'assurer que le dossier de destination existe
os.makedirs(os.path.dirname(output_filename), exist_ok=True)
# Sauvegarder le fichier fusionné
merger.save(output_filename)
merger.write(output_filename)
merger.close()
return output_filename
@ -134,6 +142,11 @@ def rfToPDF(registerForm, filename):
# Générer le PDF
pdf = renderers.render_to_pdf('pdfs/dossier_inscription.html', data)
# Vérifier si un fichier avec le même nom existe déjà et le supprimer
if registerForm.registration_file and os.path.exists(registerForm.registration_file.path):
os.remove(registerForm.registration_file.path)
registerForm.registration_file.delete(save=False)
# Écrire le fichier directement
with open(filename, 'wb') as f:
f.write(pdf.content)
@ -146,4 +159,16 @@ def rfToPDF(registerForm, filename):
save=True
)
return registerForm.registration_file
return filename
def delete_registration_files(registerForm):
"""
Supprime le fichier et le dossier associés à un RegistrationForm.
"""
base_dir = f"registration_files/dossier_rf_{registerForm.pk}"
if registerForm.registration_file and os.path.exists(registerForm.registration_file.path):
os.remove(registerForm.registration_file.path)
registerForm.registration_file.delete(save=False)
if os.path.exists(base_dir):
shutil.rmtree(base_dir)

View File

@ -4,10 +4,12 @@ from rest_framework.views import APIView
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from Subscriptions.models import Guardian, Student
from Subscriptions.models import Guardian, Student, RegistrationForm
from Auth.models import ProfileRole
from N3wtSchool import bdd
import Subscriptions.util as util
class GuardianView(APIView):
"""
Gestion des responsables légaux.
@ -74,6 +76,16 @@ class DissociateGuardianView(APIView):
# Supprimer le guardian
guardian.delete()
# Récupérer le RegistrationForm associé au Student
registerForm = bdd.getObject(RegistrationForm, "student__id", student_id)
if registerForm:
# Réinitialiser le statut en "Créé"
registerForm.status = RegistrationForm.RegistrationFormStatus.RF_CREATED
registerForm.save()
# Supprimer le fichier et le dossier associés
util.delete_registration_files(registerForm)
return JsonResponse(
{
"message": f"Le guardian {guardian.last_name} {guardian.first_name} a été dissocié de l'étudiant {student.last_name} {student.first_name}.",

View File

@ -238,7 +238,7 @@ class RegisterFormWithIdView(APIView):
if _status == RegistrationForm.RegistrationFormStatus.RF_UNDER_REVIEW:
try:
# Génération de la fiche d'inscription au format PDF
base_dir = f"data/registration_files/dossier_rf_{registerForm.pk}"
base_dir = f"registration_files/dossier_rf_{registerForm.pk}"
os.makedirs(base_dir, exist_ok=True)
# Fichier PDF initial
@ -248,11 +248,13 @@ class RegisterFormWithIdView(APIView):
# Récupération des fichiers d'inscription
fileNames = RegistrationTemplate.get_files_from_rf(registerForm.pk)
if registerForm.registration_file:
fileNames.insert(0, registerForm.registration_file.path)
# Création du fichier PDF Fusionné
merged_pdf = f"{base_dir}/dossier_complet_{registerForm.pk}.pdf"
util.merge_files_pdf(fileNames, merged_pdf)
# Mise à jour du champ registration_file avec le fichier fusionné
@ -271,6 +273,14 @@ class RegisterFormWithIdView(APIView):
# L'école a validé le dossier d'inscription
# Mise à jour de l'automate
updateStateMachine(registerForm, 'valideDI')
elif _status == RegistrationForm.RegistrationFormStatus.RF_SENT:
# Vérifier si l'étape précédente était RF_UNDER_REVIEW
if registerForm.status == RegistrationForm.RegistrationFormStatus.RF_UNDER_REVIEW:
# Mise à jour de l'automate
updateStateMachine(registerForm, 'refuseDI')
# Supprimer le fichier et le dossier associés
util.delete_registration_files(registerForm)
studentForm_serializer = RegistrationFormSerializer(registerForm, data=studentForm_data)
if studentForm_serializer.is_valid():

Binary file not shown.

View File

@ -1,7 +1,6 @@
'use client'
import React, { useState, useEffect } from 'react';
import Table from '@/components/Table';
import {mockFicheInscription} from '@/data/mockFicheInscription';
import Tab from '@/components/Tab';
import { useTranslations } from 'next-intl';
import StatusLabel from '@/components/StatusLabel';
@ -11,7 +10,7 @@ import Loader from '@/components/Loader';
import AlertWithModal from '@/components/AlertWithModal';
import DropdownMenu from "@/components/DropdownMenu";
import { formatPhoneNumber } from '@/utils/Telephone';
import { MoreVertical, Send, Edit, Trash2, FileText, CheckCircle, Plus } from 'lucide-react';
import { MoreVertical, Send, Edit, Trash2, FileText, CheckCircle, Plus, TicketX } from 'lucide-react';
import Modal from '@/components/Modal';
import InscriptionForm from '@/components/Inscription/InscriptionForm'
import AffectationClasseForm from '@/components/AffectationClasseForm'
@ -364,8 +363,27 @@ useEffect(()=>{
});
}
const refuseRegistrationForm = (id, lastname, firstname, guardianEmail) => {
const data = { status: 2, establishment: selectedEstablishmentId };
setPopup({
visible: true,
message: `Avertissement ! \nVous êtes sur le point de refuser le dossier d'inscription de ${lastname} ${firstname}\nUne notification va être envoyée à l'adresse ${guardianEmail}\nÊtes-vous sûr(e) de vouloir poursuivre l'opération ?`,
onConfirm: () => {
editRegisterForm(id, data, csrfToken)
.then(data => {
logger.debug('Success:', data);
setReloadFetch(true);
})
.catch(error => {
logger.error('Error refusing RF:', error);
});
}
});
};
const updateStatusAction = (id, newStatus) => {
logger.debug('Edit fiche inscription with id:', id);
logger.debug(`Mise à jour du statut du dossier d'inscription avec l'ID : ${id} vers le statut : ${newStatus}`);
};
const handleSearchChange = (event) => {
@ -447,7 +465,7 @@ useEffect(()=>{
.then(clonedDocument => {
// Sauvegarde des templates clonés dans la base de données
const cloneData = {
name: `clone_${clonedDocument.id}`,
name: `${templateMaster.name}_${updatedData.guardianFirstName}_${updatedData.guardianLastName}`,
slug: clonedDocument.slug,
id: clonedDocument.id,
master: templateMaster.id,
@ -555,6 +573,80 @@ useEffect(()=>{
});
}
const getActionsByStatus = (row) => {
const actions = {
1: [
{
label: (
<>
<Send size={16} className="mr-2" /> Envoyer
</>
),
onClick: () => sendConfirmRegisterForm(row.student.id, row.student.last_name, row.student.first_name),
},
{
label: (
<>
<Edit size={16} className="mr-2" /> Modifier
</>
),
onClick: () => window.location.href = `${FE_ADMIN_SUBSCRIPTIONS_EDIT_URL}?studentId=${row.student.id}&id=1`,
},
],
2: [
{
label: (
<>
<Edit size={16} className="mr-2" /> Modifier
</>
),
onClick: () => window.location.href = `${FE_ADMIN_SUBSCRIPTIONS_EDIT_URL}?studentId=${row.student.id}&id=1`,
},
],
3: [
{
label: (
<>
<CheckCircle size={16} className="mr-2" /> Valider
</>
),
onClick: () => openModalAssociationEleve(row.student),
},
{
label: (
<>
<TicketX size={16} className="mr-2 text-red-700" /> Refuser
</>
),
onClick: () => refuseRegistrationForm(row.student.id, row.student.last_name, row.student.first_name, row.student.guardians[0].associated_profile_email),
},
],
5: [
{
label: (
<>
<CheckCircle size={16} className="mr-2" /> Rattacher
</>
),
onClick: () => openModalAssociationEleve(row.student),
},
],
default: [
{
label: (
<>
<Trash2 size={16} className="mr-2 text-red-700" /> Archiver
</>
),
onClick: () => archiveFicheInscription(row.student.id, row.student.last_name, row.student.first_name),
},
],
};
// Combine actions for the specific status and default actions
return [...(actions[row.status] || []), ...(row.status !== 6 ? actions.default : [])];
};
const columns = [
{ name: t('studentName'), transform: (row) => row.student.last_name },
{ name: t('studentFistName'), transform: (row) => row.student.first_name },
@ -588,69 +680,21 @@ const columns = [
{ name: t('files'), transform: (row) =>
(row.registration_file != null) &&(
<ul>
<li className="flex items-center gap-2">
<li className="flex justify-center items-center gap-2">
<FileText size={16} />
<a href={ `${BASE_URL}${row.registration_file}`} target='_blank'>{row.registration_file?.split('/').pop()}</a>
</li>
</ul>
) },
{ name: 'Actions', transform: (row) => (
<DropdownMenu
buttonContent={<MoreVertical size={20} className="text-gray-400 hover:text-gray-600" />}
items={[
...(row.status === 1 ? [{
label: (
<>
<Send size={16} className="mr-2" /> Envoyer
</>
),
onClick: () => sendConfirmRegisterForm(row.student.id, row.student.last_name, row.student.first_name),
}] : []),
...(row.status === 1 ? [{
label: (
<>
<Edit size={16} className="mr-2" /> Modifier
</>
),
onClick: () => window.location.href = `${FE_ADMIN_SUBSCRIPTIONS_EDIT_URL}?studentId=${row.student.id}&id=1`,
}] : []),
...(row.status === 2 ? [{
label: (
<>
<Edit size={16} className="mr-2" /> Modifier
</>
),
onClick: () => window.location.href = `${FE_ADMIN_SUBSCRIPTIONS_EDIT_URL}?studentId=${row.student.id}&id=1`,
}] : []),
...(row.status === 3 ? [{
label: (
<>
<CheckCircle size={16} className="mr-2" /> Valider
</>
),
onClick: () => openModalAssociationEleve(row.student),
}] : []),
...(row.status === 5 ? [{
label: (
<>
<CheckCircle size={16} className="mr-2" /> Rattacher
</>
),
onClick: () => openModalAssociationEleve(row.student),
}] : []),
...(row.status !== 6 ? [{
label: (
<>
<Trash2 size={16} className="mr-2 text-red-700" /> Archiver
</>
),
onClick: () => archiveFicheInscription(row.student.id, row.student.last_name, row.student.first_name),
}] : []),
]}
buttonClassName="text-gray-400 hover:text-gray-600"
menuClassName="absolute right-0 mt-2 w-48 bg-white border border-gray-200 rounded-md shadow-lg z-10 flex flex-col items-center"
/>
) },
{ name: 'Actions',
transform: (row) => (
<DropdownMenu
buttonContent={<MoreVertical size={20} className="text-gray-400 hover:text-gray-600" />}
items={getActionsByStatus(row)}
buttonClassName="text-gray-400 hover:text-gray-600"
menuClassName="absolute right-0 mt-2 w-48 bg-white border border-gray-200 rounded-md shadow-lg z-10 flex flex-col items-center"
/>
), },
];

View File

@ -83,7 +83,8 @@ export default function ParentHomePage() {
// Définir les colonnes du tableau
const childrenColumns = [
{ name: 'Nom', transform: (row) => `${row.student.last_name} ${row.student.first_name}` },
{ name: 'Nom', transform: (row) => `${row.student.last_name}` },
{ name: 'Prénom', transform: (row) => `${row.student.first_name}` },
{
name: 'Statut',
transform: (row) => (

View File

@ -21,7 +21,7 @@ import DraggableFileUpload from '@/components/DraggableFileUpload';
import Modal from '@/components/Modal';
import FileStatusLabel from '@/components/FileStatusLabel';
import logger from '@/utils/logger';
import StudentInfoForm from '@/components/Inscription/StudentInfoForm';
import StudentInfoForm, { validateStudentInfo } from '@/components/Inscription/StudentInfoForm';
import FilesToSign from '@/components/Inscription/FilesToSign';
import FilesToUpload from '@/components/Inscription/FilesToUpload';
import { DocusealForm } from '@docuseal/react';
@ -70,6 +70,14 @@ export default function InscriptionFormShared({
const [currentPage, setCurrentPage] = useState(1);
const isCurrentPageValid = () => {
if (currentPage === 1) {
const isValid = validateStudentInfo(formData);
return isValid;
}
return true;
};
// Chargement initial des données
// Mettre à jour les données quand initialData change
useEffect(() => {
@ -178,12 +186,25 @@ export default function InscriptionFormShared({
...formData,
guardians
},
establishment: ESTABLISHMENT_ID,
establishment: 1,
status:3
}
onSubmit(data);
};
// Soumission du formulaire
const handleSave = (e) => {
e.preventDefault();
const data ={
student: {
...formData,
guardians
},
establishment: 1
}
onSubmit(data);
};
// Récupération des messages d'erreur
const getError = (field) => {
return errors?.student?.[field]?.[0];
@ -276,31 +297,52 @@ export default function InscriptionFormShared({
{/* Pages suivantes : Section Fichiers d'inscription */}
{currentPage > 1 && currentPage <= requiredFileTemplates.length + 1 && (
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h2 className="text-xl font-bold mb-4 text-gray-800">{requiredFileTemplates[currentPage - 2].name}</h2>
<DocusealForm
id="docusealForm"
src={"https://docuseal.com/s/"+requiredFileTemplates[currentPage - 2].slug}
withDownloadButton={false}
onComplete={() => {
downloadTemplate(requiredFileTemplates[currentPage - 2].slug)
.then((data) => fetch(data))
.then((response) => response.blob())
.then((blob) => {
const file = new File([blob], `${requiredFileTemplates[currentPage - 2].name}.pdf`, { type: blob.type });
const updateData = new FormData();
updateData.append('file', file);
{/* Titre du document */}
<div className="mb-4">
<h2 className="text-lg font-semibold text-gray-800">
{requiredFileTemplates[currentPage - 2].name || "Document sans nom"}
</h2>
<p className="text-sm text-gray-500">
{requiredFileTemplates[currentPage - 2].description || "Aucune description disponible pour ce document."}
</p>
</div>
return editRegistrationTemplates(requiredFileTemplates[currentPage - 2].id, updateData, csrfToken);
})
.then((data) => {
logger.debug("EDIT TEMPLATE : ", data);
})
.catch((error) => {
logger.error("error editing template : ", error);
});
}}
>
</DocusealForm>
{/* Affichage du formulaire ou du document */}
{requiredFileTemplates[currentPage - 2].file === "" ? (
<DocusealForm
id="docusealForm"
src={"https://docuseal.com/s/" + requiredFileTemplates[currentPage - 2].slug}
withDownloadButton={false}
onComplete={() => {
downloadTemplate(requiredFileTemplates[currentPage - 2].slug)
.then((data) => fetch(data))
.then((response) => response.blob())
.then((blob) => {
const file = new File([blob], `${requiredFileTemplates[currentPage - 2].name}.pdf`, { type: blob.type });
const updateData = new FormData();
updateData.append('file', file);
return editRegistrationTemplates(requiredFileTemplates[currentPage - 2].id, updateData, csrfToken);
})
.then((data) => {
logger.debug("EDIT TEMPLATE : ", data);
})
.catch((error) => {
logger.error("error editing template : ", error);
});
}}
/>
) : (
<iframe
src={`${BASE_URL}/${requiredFileTemplates[currentPage - 2].file}`}
title="Document Viewer"
className="w-full"
style={{
height: '75vh', // Ajuster la hauteur à 75% de la fenêtre
border: 'none',
}}
/>
)}
</div>
)}
@ -316,11 +358,29 @@ export default function InscriptionFormShared({
{/* Boutons de contrôle */}
<div className="flex justify-end space-x-4">
<Button
text="Sauvegarder"
onClick={handleSave}
className="px-4 py-2 rounded-md shadow-sm focus:outline-none bg-orange-500 text-white hover:bg-orange-600"
primary
name="Save"
/>
{currentPage > 1 && (
<Button text="Précédent" onClick={(e) => { e.preventDefault(); handlePreviousPage(); }} />
)}
{currentPage < requiredFileTemplates.length + 2 && (
<Button text="Suivant" onClick={(e) => { e.preventDefault(); handleNextPage(); }} />
<Button
text="Suivant"
onClick={(e) => { e.preventDefault(); handleNextPage(); }}
className={`px-4 py-2 rounded-md shadow-sm focus:outline-none ${
!isCurrentPageValid()
? "bg-gray-300 text-gray-700 cursor-not-allowed"
: "bg-emerald-500 text-white hover:bg-emerald-600"
}`}
disabled={!isCurrentPageValid()}
primary
name="Next"
/>
)}
{currentPage === requiredFileTemplates.length + 2 && (
<Button type="submit" text="Valider" primary />

View File

@ -56,8 +56,8 @@ export default function ResponsableInputFields({guardians, onGuardiansChange, ad
name="mailResponsable"
type="email"
label={t('email')}
value={item.email}
onChange={(event) => {onGuardiansChange(item.id, "email", event.target.value)}}
value={item.associated_profile_email}
onChange={(event) => {onGuardiansChange(item.id, "associated_profile_email", event.target.value)}}
required
errorMsg={getError(index, 'email')}
/>

View File

@ -10,6 +10,28 @@ const levels = [
{ value:'4', label: 'GS - Grande Section'},
];
// Fonction de validation pour vérifier les champs requis
export function validateStudentInfo(formData) {
const requiredFields = [
'last_name',
'first_name',
'nationality',
'birth_date',
'birth_place',
'birth_postal_code',
'address',
'attending_physician',
'level',
];
const isValid = requiredFields.every((field) => {
const value = formData[field];
return typeof value === 'string' ? value.trim() !== '' : Boolean(value);
});
return isValid;
}
export default function StudentInfoForm({ formData, updateFormField, guardians, setGuardians, errors }) {
const getError = (field) => {
return errors?.student?.[field]?.[0];

View File

@ -66,7 +66,7 @@ export default function FileUpload({ handleCreateTemplateMaster, handleEditTempl
const details = selectedGroups.flatMap(group =>
group.registration_forms.flatMap(form =>
form.guardians.map(guardian => ({
email: guardian.email,
email: guardian.associated_profile_email,
last_name: form.last_name,
first_name: form.first_name,
registration_form: form.student_id