7 Commits

20 changed files with 1026 additions and 582 deletions

View File

@ -60,7 +60,6 @@ class TeacherSerializer(serializers.ModelSerializer):
profile_role = serializers.PrimaryKeyRelatedField(queryset=ProfileRole.objects.all(), required=False)
profile_role_data = ProfileRoleSerializer(write_only=True, required=False)
associated_profile_email = serializers.SerializerMethodField()
profile = serializers.SerializerMethodField()
class Meta:
model = Teacher
@ -156,12 +155,6 @@ class TeacherSerializer(serializers.ModelSerializer):
return obj.profile_role.role_type
return None
def get_profile(self, obj):
# Retourne l'id du profile associé via profile_role
if obj.profile_role and obj.profile_role.profile:
return obj.profile_role.profile.id
return None
class PlanningSerializer(serializers.ModelSerializer):
class Meta:
model = Planning

View File

@ -118,43 +118,17 @@ class TeacherDetailView(APIView):
return JsonResponse(teacher_serializer.data, safe=False)
def put(self, request, id):
teacher_data = JSONParser().parse(request)
teacher_data=JSONParser().parse(request)
teacher = getObject(_objectName=Teacher, _columnName='id', _value=id)
# Récupérer l'ancien profile avant modification
old_profile_role = getattr(teacher, 'profile_role', None)
old_profile = getattr(old_profile_role, 'profile', None) if old_profile_role else None
teacher_serializer = TeacherSerializer(teacher, data=teacher_data)
if teacher_serializer.is_valid():
teacher_serializer.save()
# Après modification, vérifier si l'ancien profile n'a plus de ProfileRole
if old_profile:
from Auth.models import ProfileRole # import local pour éviter les imports circulaires
if not ProfileRole.objects.filter(profile=old_profile).exists():
old_profile.delete()
return JsonResponse(teacher_serializer.data, safe=False)
return JsonResponse(teacher_serializer.errors, safe=False)
def delete(self, request, id):
# Suppression du Teacher et du ProfileRole associé
teacher = getObject(_objectName=Teacher, _columnName='id', _value=id)
profile_role = getattr(teacher, 'profile_role', None)
profile = getattr(profile_role, 'profile', None) if profile_role else None
# Supprime le Teacher (ce qui supprime le ProfileRole via on_delete=models.CASCADE)
response = delete_object(Teacher, id, related_field='profile_role')
# Si un profile était associé, vérifier s'il reste des ProfileRole
if profile:
from Auth.models import ProfileRole # import local pour éviter les imports circulaires
if not ProfileRole.objects.filter(profile=profile).exists():
profile.delete()
return response
return delete_object(Teacher, id, related_field='profile_role')
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')

View File

@ -487,12 +487,7 @@ def generate_form_json_pdf(register_form, form_json):
for field in fields:
label = field.get("label", field.get("id", ""))
ftype = field.get("type", "")
value = field.get("value", "")
# Afficher la valeur si elle existe
if value not in (None, ""):
c.drawString(100, y, f"{label} [{ftype}] : {value}")
else:
c.drawString(100, y, f"{label} [{ftype}]")
c.drawString(100, y, f"{label} [{ftype}]")
y -= 25
if y < 100:
c.showPage()

View File

@ -6,10 +6,11 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework import status
from Subscriptions.serializers import RegistrationParentFileMasterSerializer
from Subscriptions.serializers import RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer
from Subscriptions.models import (
RegistrationForm,
RegistrationParentFileMaster
RegistrationParentFileMaster,
RegistrationParentFileTemplate
)
from N3wtSchool import bdd
import logging
@ -175,3 +176,97 @@ class RegistrationParentFileMasterSimpleView(APIView):
return JsonResponse({'message': 'La suppression du fichier parent a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
else:
return JsonResponse({'erreur': 'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
class RegistrationParentFileTemplateView(APIView):
@swagger_auto_schema(
operation_description="Récupère tous les templates parents pour un établissement donné",
manual_parameters=[
openapi.Parameter(
'establishment_id',
openapi.IN_QUERY,
description="ID de l'établissement",
type=openapi.TYPE_INTEGER,
required=True
)
],
responses={200: RegistrationParentFileTemplateSerializer(many=True)}
)
def get(self, request):
establishment_id = request.GET.get('establishment_id')
if not establishment_id:
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
# Filtrer les templates parents liés à l'établissement via master.groups.establishment
templates = RegistrationParentFileTemplate.objects.filter(
master__groups__establishment__id=establishment_id
).distinct()
serializer = RegistrationParentFileTemplateSerializer(templates, many=True)
return Response(serializer.data)
@swagger_auto_schema(
operation_description="Crée un nouveau template d'inscription",
request_body=RegistrationParentFileTemplateSerializer,
responses={
201: RegistrationParentFileTemplateSerializer,
400: "Données invalides"
}
)
def post(self, request):
serializer = RegistrationParentFileTemplateSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RegistrationParentFileTemplateSimpleView(APIView):
@swagger_auto_schema(
operation_description="Récupère un template d'inscription spécifique",
responses={
200: RegistrationParentFileTemplateSerializer,
404: "Template non trouvé"
}
)
def get(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
if template is None:
return JsonResponse({"errorMessage":'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationParentFileTemplateSerializer(template)
return JsonResponse(serializer.data, safe=False)
@swagger_auto_schema(
operation_description="Met à jour un template d'inscription existant",
request_body=RegistrationParentFileTemplateSerializer,
responses={
200: RegistrationParentFileTemplateSerializer,
400: "Données invalides",
404: "Template non trouvé"
}
)
def put(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
if template is None:
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationParentFileTemplateSerializer(template, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
operation_description="Supprime un template d'inscription",
responses={
204: "Suppression réussie",
404: "Template non trouvé"
}
)
def delete(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
if template is not None:
# Suppression du fichier PDF associé avant suppression de l'objet
if template.file and template.file.name:
template.file.delete(save=False)
template.delete()
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
else:
return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)

View File

@ -1,21 +1,128 @@
from django.http.response import JsonResponse
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework import status
import os
import glob
from Subscriptions.serializers import RegistrationSchoolFileTemplateSerializer
from Subscriptions.models import RegistrationSchoolFileTemplate
from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer
from Subscriptions.models import RegistrationSchoolFileMaster, RegistrationSchoolFileTemplate, RegistrationParentFileMaster, RegistrationParentFileTemplate
from N3wtSchool import bdd
import logging
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
import Subscriptions.util as util
logger = logging.getLogger(__name__)
class RegistrationSchoolFileMasterView(APIView):
parser_classes = [MultiPartParser, FormParser]
@swagger_auto_schema(
operation_description="Récupère tous les masters de templates d'inscription pour un établissement donné",
manual_parameters=[
openapi.Parameter(
'establishment_id',
openapi.IN_QUERY,
description="ID de l'établissement",
type=openapi.TYPE_INTEGER,
required=True
)
],
responses={200: RegistrationSchoolFileMasterSerializer(many=True)}
)
def get(self, request):
establishment_id = request.GET.get('establishment_id')
if not establishment_id:
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
# Filtrer les masters liés à l'établissement via groups.establishment
masters = RegistrationSchoolFileMaster.objects.filter(
groups__establishment__id=establishment_id
).distinct()
serializer = RegistrationSchoolFileMasterSerializer(masters, many=True)
return Response(serializer.data)
@swagger_auto_schema(
operation_description="Crée un nouveau master de template d'inscription",
request_body=RegistrationSchoolFileMasterSerializer,
responses={
201: RegistrationSchoolFileMasterSerializer,
400: "Données invalides"
}
)
def post(self, request):
logger.info(f"raw request.data: {request.data}")
payload, resp = util.build_payload_from_request(request)
if resp:
return resp
logger.info(f"payload for serializer: {payload}")
serializer = RegistrationSchoolFileMasterSerializer(data=payload, partial=True)
if serializer.is_valid():
obj = serializer.save()
return Response(RegistrationSchoolFileMasterSerializer(obj).data, status=status.HTTP_201_CREATED)
logger.error(f"serializer errors: {serializer.errors}")
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RegistrationSchoolFileMasterSimpleView(APIView):
@swagger_auto_schema(
operation_description="Récupère un master de template d'inscription spécifique",
responses={
200: RegistrationSchoolFileMasterSerializer,
404: "Master non trouvé"
}
)
def get(self, request, id):
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
if master is None:
return JsonResponse({"errorMessage":'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationSchoolFileMasterSerializer(master)
return JsonResponse(serializer.data, safe=False)
@swagger_auto_schema(
operation_description="Met à jour un master de template d'inscription existant",
request_body=RegistrationSchoolFileMasterSerializer,
responses={
200: RegistrationSchoolFileMasterSerializer,
400: "Données invalides",
404: "Master non trouvé"
}
)
def put(self, request, id):
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
if master is None:
return JsonResponse({'erreur': "Le master de template n'a pas été trouvé"}, safe=False, status=status.HTTP_404_NOT_FOUND)
# Normaliser payload (supporte form-data avec champ 'data' JSON ou fichier JSON)
payload, resp = util.build_payload_from_request(request)
if resp:
return resp
logger.info(f"payload for update serializer: {payload}")
serializer = RegistrationSchoolFileMasterSerializer(master, data=payload, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
logger.error(f"serializer errors on put: {serializer.errors}")
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
operation_description="Supprime un master de template d'inscription",
responses={
204: "Suppression réussie",
404: "Master non trouvé"
}
)
def delete(self, request, id):
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
if master is not None:
master.delete()
return JsonResponse({'message': 'La suppression du master de template a été effectuée avec succès'}, safe=False, status=status.HTTP_200_OK)
else:
return JsonResponse({'erreur': 'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
class RegistrationSchoolFileTemplateView(APIView):
@swagger_auto_schema(
operation_description="Récupère tous les templates d'inscription pour un établissement donné",
@ -58,8 +165,6 @@ class RegistrationSchoolFileTemplateView(APIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RegistrationSchoolFileTemplateSimpleView(APIView):
parser_classes = [MultiPartParser, FormParser, JSONParser]
@swagger_auto_schema(
operation_description="Récupère un template d'inscription spécifique",
responses={
@ -84,83 +189,12 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
}
)
def put(self, request, id):
# Normaliser la payload (support form-data avec champ 'data' JSON ou fichier JSON)
payload, resp = util.build_payload_from_request(request)
if resp is not None:
return resp
# Synchroniser fields[].value dans le payload AVANT le serializer (pour les formulaires dynamiques)
formTemplateData = payload.get('formTemplateData')
if formTemplateData and isinstance(formTemplateData, dict):
responses = None
if "responses" in formTemplateData:
resp = formTemplateData["responses"]
if isinstance(resp, dict) and "responses" in resp:
responses = resp["responses"]
elif isinstance(resp, dict):
responses = resp
if responses and "fields" in formTemplateData:
for field in formTemplateData["fields"]:
field_id = field.get("id")
if field_id and field_id in responses:
field["value"] = responses[field_id]
payload['formTemplateData'] = formTemplateData
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
if template is None:
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
# Cas 1 : Upload d'un fichier existant (PDF/image)
if 'file' in request.FILES:
upload = request.FILES['file']
file_field = template.file
upload_name = upload.name
upload_dir = os.path.dirname(file_field.path) if file_field and file_field.name else None
if upload_dir:
base_name, _ = os.path.splitext(upload_name)
pattern = os.path.join(upload_dir, f"{base_name}.*")
for f in glob.glob(pattern):
try:
if os.path.exists(f):
os.remove(f)
logger.info(f"Suppression du fichier existant (pattern): {f}")
except Exception as e:
logger.error(f"Erreur suppression fichier existant (pattern): {e}")
target_path = os.path.join(upload_dir, upload_name)
if os.path.exists(target_path):
try:
os.remove(target_path)
except Exception as e:
logger.error(f"Erreur suppression fichier cible: {e}")
# On écrase le fichier existant sans passer par le serializer
template.file.save(upload_name, upload, save=True)
return Response({'message': 'Template mis à jour avec succès', 'data': RegistrationSchoolFileTemplateSerializer(template).data}, status=status.HTTP_200_OK)
# Cas 2 : Formulaire dynamique (JSON)
serializer = RegistrationSchoolFileTemplateSerializer(template, data=payload)
serializer = RegistrationSchoolFileTemplateSerializer(template, data=request.data)
if serializer.is_valid():
serializer.save()
# Régénérer le PDF si besoin
formTemplateData = serializer.validated_data.get('formTemplateData')
if (
formTemplateData
and isinstance(formTemplateData, dict)
and formTemplateData.get("fields")
and hasattr(template, "file")
):
old_pdf_name = None
if template.file and template.file.name:
old_pdf_name = os.path.basename(template.file.name)
try:
template.file.delete(save=False)
if os.path.exists(template.file.path):
os.remove(template.file.path)
except Exception as e:
logger.error(f"Erreur lors de la suppression du fichier existant: {e}")
from Subscriptions.util import generate_form_json_pdf
pdf_file = generate_form_json_pdf(template.registration_form, formTemplateData)
pdf_filename = old_pdf_name or f"{template.name}_{template.id}.pdf"
template.file.save(pdf_filename, pdf_file, save=True)
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -189,3 +223,195 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
else:
return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
class RegistrationParentFileMasterView(APIView):
@swagger_auto_schema(
operation_description="Récupère tous les fichiers parents pour un établissement donné",
manual_parameters=[
openapi.Parameter(
'establishment_id',
openapi.IN_QUERY,
description="ID de l'établissement",
type=openapi.TYPE_INTEGER,
required=True
)
],
responses={200: RegistrationParentFileMasterSerializer(many=True)}
)
def get(self, request):
establishment_id = request.GET.get('establishment_id')
if not establishment_id:
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
# Filtrer les fichiers parents liés à l'établissement
templates = RegistrationParentFileMaster.objects.filter(
groups__establishment__id=establishment_id
).distinct()
serializer = RegistrationParentFileMasterSerializer(templates, many=True)
return Response(serializer.data)
@swagger_auto_schema(
operation_description="Crée un nouveau fichier parent",
request_body=RegistrationParentFileMasterSerializer,
responses={
201: RegistrationParentFileMasterSerializer,
400: "Données invalides"
}
)
def post(self, request):
serializer = RegistrationParentFileMasterSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RegistrationParentFileMasterSimpleView(APIView):
@swagger_auto_schema(
operation_description="Récupère un fichier parent spécifique",
responses={
200: RegistrationParentFileMasterSerializer,
404: "Fichier parent non trouvé"
}
)
def get(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
if template is None:
return JsonResponse({"errorMessage":'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationParentFileMasterSerializer(template)
return JsonResponse(serializer.data, safe=False)
@swagger_auto_schema(
operation_description="Met à jour un fichier parent existant",
request_body=RegistrationParentFileMasterSerializer,
responses={
200: RegistrationParentFileMasterSerializer,
400: "Données invalides",
404: "Fichier parent non trouvé"
}
)
def put(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
if template is None:
return JsonResponse({'erreur': 'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationParentFileMasterSerializer(template, data=request.data)
if serializer.is_valid():
serializer.save()
return Response({'message': 'Fichier parent mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
operation_description="Supprime un fichier parent",
responses={
204: "Suppression réussie",
404: "Fichier parent non trouvé"
}
)
def delete(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
if template is not None:
template.delete()
return JsonResponse({'message': 'La suppression du fichier parent a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
else:
return JsonResponse({'erreur': 'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
class RegistrationParentFileTemplateView(APIView):
@swagger_auto_schema(
operation_description="Récupère tous les templates parents pour un établissement donné",
manual_parameters=[
openapi.Parameter(
'establishment_id',
openapi.IN_QUERY,
description="ID de l'établissement",
type=openapi.TYPE_INTEGER,
required=True
)
],
responses={200: RegistrationParentFileTemplateSerializer(many=True)}
)
def get(self, request):
establishment_id = request.GET.get('establishment_id')
if not establishment_id:
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
# Filtrer les templates parents liés à l'établissement via master.groups.establishment
templates = RegistrationParentFileTemplate.objects.filter(
master__groups__establishment__id=establishment_id
).distinct()
serializer = RegistrationParentFileTemplateSerializer(templates, many=True)
return Response(serializer.data)
@swagger_auto_schema(
operation_description="Crée un nouveau template d'inscription",
request_body=RegistrationParentFileTemplateSerializer,
responses={
201: RegistrationParentFileTemplateSerializer,
400: "Données invalides"
}
)
def post(self, request):
serializer = RegistrationParentFileTemplateSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RegistrationParentFileTemplateSimpleView(APIView):
@swagger_auto_schema(
operation_description="Récupère un template d'inscription spécifique",
responses={
200: RegistrationParentFileTemplateSerializer,
404: "Template non trouvé"
}
)
def get(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
if template is None:
return JsonResponse({"errorMessage":'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationParentFileTemplateSerializer(template)
return JsonResponse(serializer.data, safe=False)
@swagger_auto_schema(
operation_description="Met à jour un template d'inscription existant",
request_body=RegistrationParentFileTemplateSerializer,
responses={
200: RegistrationParentFileTemplateSerializer,
400: "Données invalides",
404: "Template non trouvé"
}
)
def put(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
if template is None:
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
serializer = RegistrationParentFileTemplateSerializer(template, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@swagger_auto_schema(
operation_description="Supprime un template d'inscription",
responses={
204: "Suppression réussie",
404: "Template non trouvé"
}
)
def delete(self, request, id):
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
if template is not None:
# Suppression du fichier PDF associé
if template.file and template.file.name:
file_path = template.file.path
template.file.delete(save=False)
# Vérification post-suppression
if os.path.exists(file_path):
try:
os.remove(file_path)
logger.info(f"Fichier supprimé manuellement: {file_path}")
except Exception as e:
logger.error(f"Erreur lors de la suppression manuelle du fichier: {e}")
template.delete()
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
else:
return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)

View File

@ -1 +1 @@
__version__ = "0.0.3"
__version__ = "0.0.4"

View File

@ -1,6 +1,6 @@
{
"name": "n3wt-school-front-end",
"version": "0.0.3",
"version": "0.0.4",
"private": true,
"scripts": {
"dev": "next dev",
@ -51,4 +51,4 @@
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14"
}
}
}

View File

@ -1,10 +1,8 @@
'use client';
import React, { useState, useEffect } from 'react';
import FormRenderer from '@/components/Form/FormRenderer';
import FileUpload from '@/components/Form/FileUpload';
import { CheckCircle, Hourglass, FileText, Download, Upload } from 'lucide-react';
import { CheckCircle, Hourglass, FileText } from 'lucide-react';
import logger from '@/utils/logger';
import { BASE_URL } from '@/utils/Url';
/**
* Composant pour afficher et gérer les formulaires dynamiques d'inscription
@ -12,7 +10,6 @@ import { BASE_URL } from '@/utils/Url';
* @param {Object} existingResponses - Réponses déjà sauvegardées
* @param {Function} onFormSubmit - Callback appelé quand un formulaire est soumis
* @param {Boolean} enable - Si les formulaires sont modifiables
* @param {Function} onFileUpload - Callback appelé quand un fichier est sélectionné
*/
export default function DynamicFormsList({
schoolFileMasters,
@ -20,12 +17,10 @@ export default function DynamicFormsList({
onFormSubmit,
enable = true,
onValidationChange,
onFileUpload, // nouvelle prop pour gérer l'upload (à passer depuis le parent)
}) {
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
const [formsData, setFormsData] = useState({});
const [formsValidation, setFormsValidation] = useState({});
const fileInputRefs = React.useRef({});
// Initialiser les données avec les réponses existantes
useEffect(() => {
@ -143,27 +138,6 @@ export default function DynamicFormsList({
return schoolFileMasters[currentTemplateIndex];
};
// Handler d'upload pour formulaire existant
const handleUpload = async (file, selectedFile) => {
if (!file || !selectedFile) return;
try {
if (onFileUpload) {
await onFileUpload(file, selectedFile);
setFormsValidation((prev) => ({
...prev,
[selectedFile.id]: true,
}));
}
} catch (error) {
logger.error('Erreur lors de l\'upload du fichier :', error);
}
};
const isDynamicForm = (template) =>
template.formTemplateData &&
Array.isArray(template.formTemplateData.fields) &&
template.formTemplateData.fields.length > 0;
if (!schoolFileMasters || schoolFileMasters.length === 0) {
return (
<div className="text-center py-8">
@ -249,13 +223,13 @@ export default function DynamicFormsList({
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<div className="mb-6">
<h3 className="text-xl font-semibold text-gray-800 mb-2">
{currentTemplate.formTemplateData?.title ||
{currentTemplate.formMasterData?.title ||
currentTemplate.title ||
currentTemplate.name ||
'Formulaire sans nom'}
</h3>
<p className="text-sm text-gray-600">
{currentTemplate.formTemplateData?.description ||
{currentTemplate.formMasterData?.description ||
currentTemplate.description ||
'Veuillez compléter ce formulaire pour continuer votre inscription.'}
</p>
@ -265,57 +239,39 @@ export default function DynamicFormsList({
</div>
</div>
{/* Affichage dynamique ou existant */}
{isDynamicForm(currentTemplate) ? (
{/* Vérifier si le formulaire maître a des données de configuration */}
{(currentTemplate.formMasterData?.fields &&
currentTemplate.formMasterData.fields.length > 0) ||
(currentTemplate.fields && currentTemplate.fields.length > 0) ? (
<FormRenderer
key={currentTemplate.id}
formConfig={{
id: currentTemplate.id,
title:
currentTemplate.formTemplateData?.title ||
currentTemplate.formMasterData?.title ||
currentTemplate.title ||
currentTemplate.name ||
'Formulaire',
fields:
currentTemplate.formTemplateData?.fields ||
currentTemplate.formMasterData?.fields ||
currentTemplate.fields ||
[],
submitLabel:
currentTemplate.formTemplateData?.submitLabel || 'Valider',
currentTemplate.formMasterData?.submitLabel || 'Valider',
}}
onFormSubmit={(formData) =>
handleFormSubmit(formData, currentTemplate.id)
}
/>
) : (
// Formulaire existant (PDF, image, etc.)
<div className="flex flex-col items-center gap-6">
<div className="flex flex-col items-center gap-2">
<FileText className="w-16 h-16 text-gray-400" />
<div className="text-lg font-semibold text-gray-700">
{currentTemplate.name}
</div>
{currentTemplate.file && (
<a
href={`${BASE_URL}${currentTemplate.file}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
download
>
<Download className="w-5 h-5" />
Télécharger le document
</a>
)}
</div>
{enable && (
<FileUpload
selectionMessage="Sélectionnez le fichier du document"
onFileSelect={(file) => handleUpload(file, currentTemplate)}
required
enable
/>
)}
<div className="text-center py-8">
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<p className="text-gray-600">
Ce formulaire n&apos;est pas encore configuré.
</p>
<p className="text-sm text-gray-500 mt-2">
Contactez l&apos;administration pour plus d&apos;informations.
</p>
</div>
)}
</div>

View File

@ -5,12 +5,15 @@ import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import {
fetchSchoolFileTemplatesFromRegistrationFiles,
fetchParentFileTemplatesFromRegistrationFiles,
fetchRegistrationSchoolFileMasters,
saveFormResponses,
fetchFormResponses,
autoSaveRegisterForm,
} from '@/app/actions/subscriptionAction';
import {
downloadTemplate,
editRegistrationSchoolFileTemplates,
editRegistrationParentFileTemplates,
} from '@/app/actions/registerFileGroupAction';
import {
fetchRegistrationPaymentModes,
@ -19,7 +22,7 @@ import {
fetchTuitionPaymentPlans,
} from '@/app/actions/schoolAction';
import { fetchProfiles } from '@/app/actions/authAction';
import { FE_PARENTS_HOME_URL } from '@/utils/Url';
import { BASE_URL, FE_PARENTS_HOME_URL } from '@/utils/Url';
import logger from '@/utils/logger';
import FilesToUpload from '@/components/Inscription/FilesToUpload';
import DynamicFormsList from '@/components/Inscription/DynamicFormsList';
@ -29,6 +32,7 @@ import ResponsableInputFields from '@/components/Inscription/ResponsableInputFie
import SiblingInputFields from '@/components/Inscription/SiblingInputFields';
import PaymentMethodSelector from '@/components/Inscription/PaymentMethodSelector';
import ProgressStep from '@/components/ProgressStep';
import { CheckCircle, Hourglass } from 'lucide-react';
import { useRouter } from 'next/navigation';
/**
@ -43,6 +47,7 @@ export default function InscriptionFormShared({
studentId,
csrfToken,
selectedEstablishmentId,
apiDocuseal,
onSubmit,
errors = {}, // Nouvelle prop pour les erreurs
enable = true,
@ -77,7 +82,7 @@ export default function InscriptionFormShared({
const [parentFileTemplates, setParentFileTemplates] = useState([]);
const [schoolFileMasters, setSchoolFileMasters] = useState([]);
const [formResponses, setFormResponses] = useState({});
const [currentPage, setCurrentPage] = useState(5);
const [currentPage, setCurrentPage] = useState(1);
const [isPage1Valid, setIsPage1Valid] = useState(false);
const [isPage2Valid, setIsPage2Valid] = useState(false);
@ -278,8 +283,8 @@ export default function InscriptionFormShared({
});
// Trouver le template correspondant pour récupérer sa configuration
const currentTemplate = schoolFileTemplates.find(
(template) => template.id === templateId
const currentTemplate = schoolFileMasters.find(
(master) => master.id === templateId
);
if (!currentTemplate) {
throw new Error(`Template avec l'ID ${templateId} non trouvé`);
@ -289,16 +294,17 @@ export default function InscriptionFormShared({
const formTemplateData = {
id: currentTemplate.id,
title:
currentTemplate.formTemplateData?.title ||
currentTemplate.formMasterData?.title ||
currentTemplate.title ||
currentTemplate.name ||
'Formulaire',
fields: (
currentTemplate.formTemplateData?.fields ||
currentTemplate.formMasterData?.fields ||
currentTemplate.fields ||
[]
).map((field) => ({
...field,
// Ajouter la réponse de l'utilisateur selon le type de champ
...(field.type === 'checkbox'
? { checked: formData[field.id] || false }
: {}),
@ -309,8 +315,8 @@ export default function InscriptionFormShared({
? { value: formData[field.id] || '' }
: {}),
})),
submitLabel: currentTemplate.formTemplateData?.submitLabel || 'Valider',
responses: formData,
submitLabel: currentTemplate.formMasterData?.submitLabel || 'Valider',
responses: formData, // Garder aussi les réponses brutes pour facilité d'accès
};
// Sauvegarder les réponses du formulaire via l'API RegistrationSchoolFileTemplate
@ -325,37 +331,18 @@ export default function InscriptionFormShared({
);
logger.debug("Réponse de l'API:", result);
// Prendre en compte la réponse du back pour mettre à jour les réponses locales
let newResponses = formData;
if (
result &&
result.data &&
result.data.formTemplateData &&
result.data.formTemplateData.responses &&
result.data.formTemplateData.responses.responses
) {
// Si la structure responses.responses existe, on la prend
newResponses = result.data.formTemplateData.responses.responses;
} else if (
result &&
result.data &&
result.data.formTemplateData &&
result.data.formTemplateData.responses
) {
// Sinon, on prend responses directement
newResponses = result.data.formTemplateData.responses;
}
// Mettre à jour l'état local des réponses
setFormResponses((prev) => ({
...prev,
[templateId]: newResponses,
[templateId]: formData,
}));
setSchoolFileTemplates((prevTemplates) => {
return prevTemplates.map((template) =>
template.id === templateId
? { ...template, completed: true, responses: newResponses }
: template
// Mettre à jour l'état local pour indiquer que le formulaire est complété
setSchoolFileMasters((prevMasters) => {
return prevMasters.map((master) =>
master.id === templateId
? { ...master, completed: true, responses: formData }
: master
);
});
@ -367,6 +354,7 @@ export default function InscriptionFormShared({
error: error.message,
stack: error.stack,
});
// Afficher l'erreur à l'utilisateur
alert(`Erreur lors de la sauvegarde du formulaire: ${error.message}`);
return Promise.reject(error);
}
@ -382,56 +370,6 @@ export default function InscriptionFormShared({
useEffect(() => {
fetchSchoolFileTemplatesFromRegistrationFiles(studentId).then((data) => {
setSchoolFileTemplates(data);
// Récupérer les réponses existantes pour chaque template
const fetchAllResponses = async () => {
const responsesMap = {};
for (const template of data) {
if (template.id) {
try {
const templateData = await fetchFormResponses(template.id);
if (templateData && templateData.formTemplateData) {
if (templateData.formTemplateData.responses) {
responsesMap[template.id] = templateData.formTemplateData.responses;
} else {
// Extraire les réponses depuis les champs
const responses = {};
if (templateData.formTemplateData.fields) {
templateData.formTemplateData.fields.forEach((field) => {
if (
field.type === 'checkbox' &&
field.checked !== undefined
) {
responses[field.id] = field.checked;
} else if (
field.type === 'radio' &&
field.selected !== undefined
) {
responses[field.id] = field.selected;
} else if (
(field.type === 'text' ||
field.type === 'textarea' ||
field.type === 'email') &&
field.value !== undefined
) {
responses[field.id] = field.value;
}
});
}
responsesMap[template.id] = responses;
}
}
} catch (error) {
logger.debug(
`Pas de données existantes pour le template ${template.id}:`,
error
);
}
}
}
setFormResponses(responsesMap);
};
fetchAllResponses();
});
fetchParentFileTemplatesFromRegistrationFiles(studentId).then((data) => {
@ -454,6 +392,66 @@ export default function InscriptionFormShared({
.catch((error) => logger.error('Error fetching profiles : ', error));
if (selectedEstablishmentId) {
// Fetch data for school file masters
fetchRegistrationSchoolFileMasters(selectedEstablishmentId)
.then(async (data) => {
logger.debug('School file masters fetched:', data);
setSchoolFileMasters(data);
// Récupérer les données existantes de chaque template
const responsesMap = {};
for (const master of data) {
if (master.id) {
try {
const templateData = await fetchFormResponses(master.id);
if (templateData && templateData.formTemplateData) {
// Si on a les réponses brutes sauvegardées, les utiliser
if (templateData.formTemplateData.responses) {
responsesMap[master.id] =
templateData.formTemplateData.responses;
} else {
// Sinon, extraire les réponses depuis les champs
const responses = {};
if (templateData.formTemplateData.fields) {
templateData.formTemplateData.fields.forEach((field) => {
if (
field.type === 'checkbox' &&
field.checked !== undefined
) {
responses[field.id] = field.checked;
} else if (
field.type === 'radio' &&
field.selected !== undefined
) {
responses[field.id] = field.selected;
} else if (
(field.type === 'text' ||
field.type === 'textarea' ||
field.type === 'email') &&
field.value !== undefined
) {
responses[field.id] = field.value;
}
});
}
responsesMap[master.id] = responses;
}
}
} catch (error) {
logger.debug(
`Pas de données existantes pour le template ${master.id}:`,
error
);
// Ce n'est pas critique si un template n'a pas de données
}
}
}
setFormResponses(responsesMap);
})
.catch((error) =>
logger.error('Error fetching school file masters:', error)
);
// Fetch data for registration payment modes
handleRegistrationPaymentModes();
@ -466,7 +464,7 @@ export default function InscriptionFormShared({
// Fetch data for tuition payment plans
handleTuitionnPaymentPlans();
}
}, [studentId, selectedEstablishmentId]);
}, [selectedEstablishmentId]);
const handleRegistrationPaymentModes = () => {
fetchRegistrationPaymentModes(selectedEstablishmentId)
@ -516,22 +514,10 @@ export default function InscriptionFormShared({
);
}
// Générer le nom du fichier : <nom_template>.<extension d'origine>
let extension = '';
if (file.name && file.name.lastIndexOf('.') !== -1) {
extension = file.name.substring(file.name.lastIndexOf('.'));
}
// Nettoyer le nom du template pour éviter les caractères spéciaux
const cleanName = (selectedFile.name || 'document')
.replace(/[^a-zA-Z0-9_\-]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
const finalFileName = `${cleanName}${extension}`;
const updateData = new FormData();
updateData.append('file', file, finalFileName);
updateData.append('file', file);
return editRegistrationSchoolFileTemplates(
return editRegistrationParentFileTemplates(
selectedFile.id,
updateData,
csrfToken
@ -542,10 +528,11 @@ export default function InscriptionFormShared({
setUploadedFiles((prev) => {
const updatedFiles = prev.map((uploadedFile) =>
uploadedFile.id === selectedFile.id
? { ...uploadedFile, fileName: response.data.file }
? { ...uploadedFile, fileName: response.data.file } // Met à jour le fichier téléversé
: uploadedFile
);
// Si le fichier n'existe pas encore, l'ajouter
if (!updatedFiles.find((file) => file.id === selectedFile.id)) {
updatedFiles.push({
id: selectedFile.id,
@ -565,11 +552,11 @@ export default function InscriptionFormShared({
)
);
return response;
return response; // Retourner la réponse pour signaler le succès
})
.catch((error) => {
logger.error('Erreur lors de la mise à jour du fichier :', error);
throw error;
throw error; // Relancer l'erreur pour que l'appelant puisse la capturer
});
};
@ -600,7 +587,7 @@ export default function InscriptionFormShared({
setUploadedFiles((prev) =>
prev.map((uploadedFile) =>
uploadedFile.id === templateId
? { ...uploadedFile, fileName: null, fileUrl: null }
? { ...uploadedFile, fileName: null, fileUrl: null } // Réinitialiser les champs liés au fichier
: uploadedFile
)
);
@ -799,12 +786,11 @@ export default function InscriptionFormShared({
{/* Page 5 : Formulaires dynamiques d'inscription */}
{currentPage === 5 && (
<DynamicFormsList
schoolFileMasters={schoolFileTemplates}
schoolFileMasters={schoolFileMasters}
existingResponses={formResponses}
onFormSubmit={handleDynamicFormSubmit}
onValidationChange={handleDynamicFormsValidationChange}
enable={enable}
onFileUpload={handleFileUpload}
/>
)}

View File

@ -146,59 +146,34 @@ const TeachersSection = ({
const { selectedEstablishmentId } = useEstablishment();
// --- UTILS ---
// Retourne le profil existant pour un email, utilisé par un teacher actif ou le teacher en cours d'édition
const getUsedProfileForEmail = (email, teacherId = null) => {
const usedProfileIds = new Set(
teachers.map(t => t.profile_role && t.profile).filter(Boolean)
);
// Ajoute le profil du teacher en cours d'édition si besoin
if (teacherId) {
const currentTeacher = teachers.find(t => t.id === teacherId);
if (currentTeacher && currentTeacher.profile_role && currentTeacher.profile) {
const profileObj = profiles.find(p => p.id === currentTeacher.profile);
if (profileObj && profileObj.email === email) {
usedProfileIds.add(profileObj.id);
}
} else {
// Cas création immédiate : on cherche le profil par email dans profiles
const profileObj = profiles.find(p => p.email === email);
if (profileObj) {
usedProfileIds.add(profileObj.id);
}
}
}
return profiles.find(
(profile) => profile.email === email && usedProfileIds.has(profile.id)
);
};
// Met à jour le formData et newTeacher si besoin
const updateFormData = (data) => {
setFormData(prev => ({ ...prev, ...data }));
if (newTeacher) setNewTeacher(prev => ({ ...prev, ...data }));
};
// Récupération des messages d'erreur pour un champ donné
const getError = (field) => {
return localErrors?.[field]?.[0];
};
// --- HANDLERS ---
const handleEmailChange = (e) => {
const email = e.target.value;
const existingProfile = getUsedProfileForEmail(email, editingTeacher);
if (existingProfile) {
logger.info(`Adresse email déjà utilisée pour le profil ${existingProfile.id}`);
}
// Vérifier si l'email correspond à un profil existant
const existingProfile = profiles.find((profile) => profile.email === email);
updateFormData({
setFormData((prevData) => ({
...prevData,
associated_profile_email: email,
existingProfileId: existingProfile ? existingProfile.id : null,
});
}));
if (newTeacher) {
setNewTeacher((prevData) => ({
...prevData,
associated_profile_email: email,
existingProfileId: existingProfile ? existingProfile.id : null,
}));
}
};
const handleCancelConfirmation = () => {
setConfirmPopupVisible(false);
};
// Récupération des messages d'erreur
const getError = (field) => {
return localErrors?.[field]?.[0];
};
const handleAddTeacher = () => {
@ -220,15 +195,15 @@ const TeachersSection = ({
};
const handleRemoveTeacher = (id) => {
logger.debug('[DELETE] Suppression teacher id:', id);
return handleDelete(id)
.then(() => {
setTeachers(prevTeachers =>
prevTeachers.filter(teacher => teacher.id !== id)
setTeachers((prevTeachers) =>
prevTeachers.filter((teacher) => teacher.id !== id)
);
logger.debug('[DELETE] Teacher supprimé:', id);
})
.catch(logger.error);
.catch((error) => {
logger.error(error);
});
};
const handleSaveNewTeacher = () => {
@ -259,29 +234,16 @@ const TeachersSection = ({
handleCreate(data)
.then((createdTeacher) => {
// Recherche du profile associé dans profiles
let newProfileId = undefined;
let foundProfile = undefined;
if (
createdTeacher &&
createdTeacher.profile_role &&
createdTeacher.profile
) {
newProfileId = createdTeacher.profile;
foundProfile = profiles.find(p => p.id === newProfileId);
}
setTeachers([createdTeacher, ...teachers]);
setNewTeacher(null);
setLocalErrors({});
setFormData(prev => ({
...prev,
existingProfileId: newProfileId,
}));
})
.catch((error) => {
logger.error('Error:', error.message);
if (error.details) setLocalErrors(error.details);
if (error.details) {
logger.error('Form errors:', error.details);
setLocalErrors(error.details);
}
});
} else {
setPopupMessage('Tous les champs doivent être remplis et valides');
@ -290,28 +252,51 @@ const TeachersSection = ({
};
const handleUpdateTeacher = (id, updatedData) => {
// Simplification : le profil est forcément existant, on utilise directement existingProfileId du formData
// Récupérer l'enseignant actuel à partir de la liste des enseignants
const currentTeacher = teachers.find((teacher) => teacher.id === id);
// Vérifier si l'email correspond à un profil existant
const existingProfile = profiles.find(
(profile) => profile.email === currentTeacher.associated_profile_email
);
// Vérifier si l'email a été modifié
const isEmailModified = currentTeacher
? currentTeacher.associated_profile_email !==
updatedData.associated_profile_email
: true;
// Mettre à jour existingProfileId en fonction de l'email
updatedData.existingProfileId = existingProfile ? existingProfile.id : null;
if (
updatedData.last_name &&
updatedData.first_name &&
updatedData.associated_profile_email
) {
const profileRoleData = {
id: updatedData.profile_role,
establishment: selectedEstablishmentId,
role_type: updatedData.role_type || 0,
is_active: true,
profile: updatedData.existingProfileId,
};
handleEdit(id, {
const data = {
last_name: updatedData.last_name,
first_name: updatedData.first_name,
profile_role_data: profileRoleData,
profile_role_data: {
id: updatedData.profile_role,
establishment: selectedEstablishmentId,
role_type: updatedData.role_type || 0,
is_active: true,
...(isEmailModified
? {
profile_data: {
id: updatedData.existingProfileId,
email: updatedData.associated_profile_email,
username: updatedData.associated_profile_email,
password: 'Provisoire01!',
},
}
: { profile: updatedData.existingProfileId }),
},
specialities: updatedData.specialities || [],
})
};
handleEdit(id, data)
.then((updatedTeacher) => {
setTeachers((prevTeachers) =>
prevTeachers.map((teacher) =>
@ -323,7 +308,10 @@ const TeachersSection = ({
})
.catch((error) => {
logger.error('Error:', error.message);
if (error.details) setLocalErrors(error.details);
if (error.details) {
logger.error('Form errors:', error.details);
setLocalErrors(error.details);
}
});
} else {
setPopupMessage('Tous les champs doivent être remplis et valides');
@ -333,12 +321,45 @@ const TeachersSection = ({
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
let parsedValue = type === 'checkbox' ? (checked ? 1 : 0) : value;
updateFormData({ [name]: parsedValue });
let parsedValue = value;
if (type === 'checkbox') {
parsedValue = checked ? 1 : 0;
}
if (editingTeacher) {
setFormData((prevData) => ({
...prevData,
[name]: parsedValue,
}));
} else if (newTeacher) {
setNewTeacher((prevData) => ({
...prevData,
[name]: parsedValue,
}));
setFormData((prevData) => ({
...prevData,
[name]: parsedValue,
}));
}
};
const handleSpecialitiesChange = (selectedSpecialities) => {
updateFormData({ specialities: selectedSpecialities });
if (editingTeacher) {
setFormData((prevData) => ({
...prevData,
specialities: selectedSpecialities,
}));
} else if (newTeacher) {
setNewTeacher((prevData) => ({
...prevData,
specialities: selectedSpecialities,
}));
setFormData((prevData) => ({
...prevData,
specialities: selectedSpecialities,
}));
}
};
const handleEditTeacher = (teacher) => {
@ -385,7 +406,6 @@ const TeachersSection = ({
onChange={handleEmailChange}
placeholder="Adresse email de l'enseignant"
errorMsg={getError('email')}
enable={!isEditing}
/>
);
case 'SPECIALITES':

View File

@ -0,0 +1,209 @@
import React, { useState } from 'react';
import Modal from '@/components/Modal';
import { FolderPlus, FileText, FilePlus2, ArrowLeft, Settings2, Upload as UploadIcon } from 'lucide-react';
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
import FileUpload from '@/components/Form/FileUpload';
export default function CreateDocumentModal({
isOpen,
onClose,
onCreateGroup,
onCreateParentFile,
onCreateSchoolFileMaster,
groups = [],
}) {
const [step, setStep] = useState('main'); // main | choose_form | form_builder | file_upload
const [fileName, setFileName] = useState('');
const [selectedGroupsFileUpload, setSelectedGroupsFileUpload] = useState([]);
const [uploadedFile, setUploadedFile] = useState(null);
React.useEffect(() => {
if (!isOpen) {
setStep('main');
setFileName('');
setSelectedGroupsFileUpload([]);
setUploadedFile(null);
}
}, [isOpen]);
// Handler pour chaque type
const handleSelect = (type) => {
if (type === 'groupe') {
setStep('main');
onCreateGroup();
onClose();
}
if (type === 'formulaire') {
setStep('choose_form');
}
if (type === 'parent') {
setStep('main');
onCreateParentFile();
onClose();
}
};
// Retour au menu principal
const handleBack = () => setStep('main');
// Submit pour formulaire existant
const handleFileUploadSubmit = (e) => {
e.preventDefault();
if (!fileName || selectedGroupsFileUpload.length === 0 || !uploadedFile) return;
onCreateSchoolFileMaster({
name: fileName,
group_ids: selectedGroupsFileUpload,
file: uploadedFile,
});
onClose();
};
return (
<Modal
isOpen={isOpen}
setIsOpen={onClose}
title="Créer un document"
modalClassName="w-full max-w-md"
>
{step === 'main' && (
<div className="flex flex-col gap-6 py-4">
<button
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-blue-50 hover:bg-blue-100 border border-blue-200 transition"
onClick={() => handleSelect('groupe')}
>
<FolderPlus className="w-6 h-6 text-blue-600" />
<span className="font-semibold text-blue-800">Dossier d&aposinscription</span>
</button>
<button
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 transition"
onClick={() => handleSelect('formulaire')}
>
<FileText className="w-6 h-6 text-emerald-600" />
<span className="font-semibold text-emerald-800">Formulaire scolaire</span>
</button>
<button
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-orange-50 hover:bg-orange-100 border border-orange-200 transition"
onClick={() => handleSelect('parent')}
>
<FilePlus2 className="w-6 h-6 text-orange-500" />
<span className="font-semibold text-orange-700">Pièce à fournir</span>
</button>
</div>
)}
{step === 'choose_form' && (
<div className="flex flex-col gap-4 py-4">
<button
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-emerald-100 hover:bg-emerald-200 border border-emerald-300 transition"
onClick={() => setStep('form_builder')}
>
<Settings2 className="w-6 h-6 text-emerald-700" />
<span className="font-semibold text-emerald-900">Formulaire personnalisé</span>
</button>
<button
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-300 transition"
onClick={() => setStep('file_upload')}
>
<UploadIcon className="w-6 h-6 text-gray-700" />
<span className="font-semibold text-gray-900">Importer un formulaire existant</span>
</button>
<button
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mt-2"
onClick={handleBack}
>
<ArrowLeft className="w-5 h-5" />
<span>Retour</span>
</button>
</div>
)}
{step === 'form_builder' && (
<div>
<button
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-2"
onClick={handleBack}
>
<ArrowLeft className="w-5 h-5" />
<span>Retour</span>
</button>
<FormTemplateBuilder
onSave={(data) => {
onCreateSchoolFileMaster(data);
onClose();
}}
groups={groups}
isEditing={false}
/>
</div>
)}
{step === 'file_upload' && (
<div>
<button
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-2"
onClick={handleBack}
>
<ArrowLeft className="w-5 h-5" />
<span>Retour</span>
</button>
<form className="flex flex-col gap-4" onSubmit={handleFileUploadSubmit}>
<input
type="text"
className="border rounded px-3 py-2"
placeholder="Nom du formulaire"
value={fileName}
onChange={e => setFileName(e.target.value)}
required
/>
{/* Sélecteur de groupes à cocher */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Groupes d&apos;inscription <span className="text-red-500">*</span>
</label>
<div className="space-y-2 max-h-32 overflow-y-auto border rounded-md p-2">
{groups && groups.length > 0 ? (
groups.map((group) => (
<label key={group.id} className="flex items-center">
<input
type="checkbox"
checked={selectedGroupsFileUpload.includes(group.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedGroupsFileUpload([
...selectedGroupsFileUpload,
group.id,
]);
} else {
setSelectedGroupsFileUpload(
selectedGroupsFileUpload.filter((id) => id !== group.id)
);
}
}}
className="mr-2 text-blue-600"
/>
<span className="text-sm">{group.name}</span>
</label>
))
) : (
<p className="text-gray-500 text-sm">
Aucun groupe disponible
</p>
)}
</div>
</div>
<FileUpload
selectionMessage="Sélectionnez le fichier du formulaire"
onFileSelect={setUploadedFile}
required
enable
/>
<button
type="submit"
className="bg-emerald-600 text-white px-4 py-2 rounded font-bold mt-2"
disabled={!fileName || selectedGroupsFileUpload.length === 0 || !uploadedFile}
>
Créer le formulaire
</button>
</form>
</div>
)}
</Modal>
);
}

View File

@ -6,6 +6,8 @@ import {
Star,
ChevronDown,
Plus,
Archive,
Eye
} from 'lucide-react';
import Modal from '@/components/Modal';
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
@ -29,9 +31,11 @@ import {
} from '@/app/actions/registerFileGroupAction';
import RegistrationFileGroupForm from '@/components/Structure/Files/RegistrationFileGroupForm';
import logger from '@/utils/logger';
import ParentFiles from './ParentFiles';
import Popup from '@/components/Popup';
import Loader from '@/components/Loader';
import { useNotification } from '@/context/NotificationContext';
import CreateDocumentModal from '@/components/Structure/Files/CreateDocumentModal';
import FileUpload from '@/components/Form/FileUpload';
import SectionTitle from '@/components/SectionTitle';
import DropdownMenu from '@/components/DropdownMenu';

View File

@ -1,85 +0,0 @@
pipeline {
agent any
environment {
DOCKER_REGISTRY = 'git.v0id.ovh'
ORGANIZATION = "n3wt-innov"
APP_NAME = 'n3wt-school'
}
// Déclencher uniquement sur les tags
triggers {
issueCommentTrigger('.*deploy.*')
}
stages {
stage('Vérification du Tag') {
when {
expression { env.TAG_NAME != null }
}
steps {
script {
// Extraire la version du tag
env.VERSION = env.TAG_NAME
echo "Version détectée: ${env.VERSION}"
}
}
}
stage('Build Docker Images') {
when {
expression { env.TAG_NAME != null }
}
steps {
script {
// Donner les permissions d'exécution au script
sh 'chmod +x ./ci-scripts/makeDocker.sh'
// Exécuter le script avec la version
sh """
./ci-scripts/makeDocker.sh ${env.VERSION}
"""
}
}
}
stage('Push sur Registry') {
when {
expression { env.TAG_NAME != null }
}
steps {
script {
withCredentials([usernamePassword(
credentialsId: 'docker-registry-credentials',
usernameVariable: 'REGISTRY_USER',
passwordVariable: 'REGISTRY_PASS'
)]) {
// Login au registry
sh "docker login ${DOCKER_REGISTRY} -u ${REGISTRY_USER} -p ${REGISTRY_PASS}"
// Push des images
sh """
docker push ${DOCKER_REGISTRY}/${ORGANIZATION}/${APP_NAME}/frontend:${env.VERSION}
docker push ${DOCKER_REGISTRY}/${ORGANIZATION}/${APP_NAME}/backend:${env.VERSION}
docker push ${DOCKER_REGISTRY}/${ORGANIZATION}/${APP_NAME}/frontend:latest
docker push ${DOCKER_REGISTRY}/${ORGANIZATION}/${APP_NAME}/backend:latest
"""
}
}
}
}
}
post {
success {
echo "Build et push des images Docker réussis pour la version ${env.VERSION}"
}
failure {
echo "Échec du build ou du push des images Docker"
}
always {
// Nettoyage
sh 'docker system prune -f'
}
}
}

View File

@ -24,7 +24,7 @@ Maquette figma : https://www.figma.com/design/1BtWHIQlJDTeue2oYblefV/Maquette-Lo
Lien de téléchargement : https://www.docker.com/get-started/
# Lancement de monteschool
# Lancement du projet
```sh
docker compose up -d
@ -36,7 +36,7 @@ Lancement du front end
npm run dev
```
se connecter à localhost:8080
- se connecter à localhost:8080 pour le backend localhost:3000 pour le front
# Installation et développement en local
@ -57,25 +57,6 @@ npm i
npm run format
```
# Faire une livraison Mise en Production
# Mise en Production, Préparation de la release
```sh
# Faire la première release (1.0.0)
npm run release -- --first-release
# Faire une prerelease (RC,alpha,beta)
npm run release -- --prerelease <name>
# Faire une release
npm run release
# Forcer la release sur un mode particulier (majeur, mineur ou patch)
# npm run script
npm run release -- --release-as minor
# Or
npm run release -- --release-as 1.1.0
# ignorer les hooks de commit lors de la release
npm run release -- --no-verify
```
- [MO_PREPARATION_MISE_EN_PROD](./docs/MEP/MO_PRE_MEP.md)

90
ci/build.Jenkinsfile Normal file
View File

@ -0,0 +1,90 @@
pipeline {
agent {
label "SLAVE-N3WT"
}
options {
disableConcurrentBuilds()
timestamps()
}
environment {
DOCKER_REGISTRY = "git.v0id.ovh"
ORG_NAME = "n3wt-innov"
APP_NAME = "n3wt-school"
IMAGE_FRONT = "${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/frontend"
IMAGE_BACK = "${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/backend"
}
stages {
stage("Check Tag") {
when {
not {
buildingTag()
}
}
steps {
script {
currentBuild.result = 'NOT_BUILT'
error("⚠️ Pipeline uniquement déclenchée sur les tags. Aucun tag détecté.")
}
}
}
stage("Build Docker Images") {
when {
buildingTag()
}
steps {
sh """
chmod +x ./ci/scripts/makeDocker.sh
./ci/scripts/makeDocker.sh ${TAG_NAME}
"""
}
}
stage("Push Images to Registry") {
when {
buildingTag()
}
steps {
withCredentials([usernamePassword(
credentialsId: "gitea-jenkins",
usernameVariable: "REGISTRY_USER",
passwordVariable: "REGISTRY_PASS"
)]) {
sh """
echo "Login registry..."
docker login ${DOCKER_REGISTRY} \
-u ${REGISTRY_USER} \
-p ${REGISTRY_PASS}
echo "Push version images..."
docker push ${IMAGE_FRONT}:${TAG_NAME}
docker push ${IMAGE_BACK}:${TAG_NAME}
echo "Tag latest..."
docker tag ${IMAGE_FRONT}:${TAG_NAME} ${IMAGE_FRONT}:latest
docker tag ${IMAGE_BACK}:${TAG_NAME} ${IMAGE_BACK}:latest
docker push ${IMAGE_FRONT}:latest
docker push ${IMAGE_BACK}:latest
"""
}
}
}
}
post {
always {
sh """
docker builder prune -f
docker image prune -f
"""
}
}
}

42
ci/deploy.Jenkinsfile Normal file
View File

@ -0,0 +1,42 @@
pipeline {
agent { label "SLAVE-N3WT" }
parameters {
choice(name: 'ENVIRONMENT', choices: ['demo', 'prod'], description: 'Choisir environnement')
string(name: 'VERSION', defaultValue: 'v1.0.0', description: 'Version Docker à déployer')
}
environment {
PLATEFORME_DEMO = 'demo.n3wtschool.com'
PLATEFORME_PROD = 'vps.n3wtschool.com'
DEPLOY_DIR = '~/n3wtschool'
}
stages {
stage('Deploy') {
steps {
script {
def targetHost = params.ENVIRONMENT == 'prod' ? env.PLATEFORME_PROD : env.PLATEFORME_DEMO
def deployDir = env.DEPLOY_DIR
// Le credential id Jenkins qui contient la clé SSH
def sshCredentialId = params.ENVIRONMENT == 'prod' ? 'vps_n3wt_prod' : 'demo_n3wt'
// Le user SSH que tu passes dans la commande ssh
def sshUser = params.ENVIRONMENT == 'prod' ? 'root' : 'demo'
sshagent([sshCredentialId]) {
sh """
ssh -o StrictHostKeyChecking=no ${sshUser}@${targetHost} <<EOF
cd ${deployDir}
docker compose down
docker compose pull
docker compose up -d
EOF
"""
}
}
}
}
}
}

View File

@ -1,5 +1,8 @@
#!/bin/bash
SCRIPT_PATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
# Récupération de la version depuis les arguments
VERSION=$1
@ -8,21 +11,22 @@ if [ -z "$VERSION" ]; then
echo "Usage: ./makeDocker.sh <version>"
exit 1
fi
SCRIPT_PATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
cd $SCRIPT_PATH
# Configuration
DOCKER_REGISTRY="git.v0id.ovh"
ORG_NAME="n3wt-innov"
APP_NAME="n3wt-school"
echo "Début de la construction des images Docker pour la version ${VERSION}"
# Construction de l'image Frontend
echo "Construction de l'image Frontend..."
cd ../Front-End
cd $SCRIPT_PATH/../../Front-End
docker build \
--build-arg BUILD_MODE=production \
-t ${DOCKER_REGISTRY}/n3wt-innov/${APP_NAME}/frontend:${VERSION} \
-t ${DOCKER_REGISTRY}/n3wt-innov/${APP_NAME}/frontend:latest \
-t ${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/frontend:${VERSION} \
-t ${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/frontend:latest \
.
if [ $? -ne 0 ]; then
@ -32,10 +36,10 @@ fi
# Construction de l'image Backend
echo "Construction de l'image Backend..."
cd ../Back-End
cd $SCRIPT_PATH/../../Back-End
docker build \
-t ${DOCKER_REGISTRY}/n3wt-innov/${APP_NAME}/backend:${VERSION} \
-t ${DOCKER_REGISTRY}/n3wt-innov/${APP_NAME}/backend:latest \
-t ${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/backend:${VERSION} \
-t ${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/backend:latest \
.
if [ $? -ne 0 ]; then
@ -45,9 +49,9 @@ fi
echo "Construction des images Docker terminée avec succès"
echo "Images créées :"
echo "- ${DOCKER_REGISTRY}/${APP_NAME}/frontend:${VERSION}"
echo "- ${DOCKER_REGISTRY}/${APP_NAME}/frontend:latest"
echo "- ${DOCKER_REGISTRY}/${APP_NAME}/backend:${VERSION}"
echo "- ${DOCKER_REGISTRY}/${APP_NAME}/backend:latest"
echo "- ${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/frontend:${VERSION}"
echo "- ${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/frontend:latest"
echo "- ${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/backend:${VERSION}"
echo "- ${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/backend:latest"
exit 0

View File

@ -0,0 +1,38 @@
# Préparation de la RELEASE et du CHANGELOG
- Vérifier que l'ensemble des tickets sont mergé dans develop
- Fusionner develop dans main via une [nouvelle demande d'ajout](https://git.v0id.ovh/n3wt-innov/n3wt-school/compare/main...develop)
- Faire une release avec la commande `npm run release` sur la branch main
\*\* NB: si vous souhaité avoir une release particulier (cf. Utilisation de standart-version)
- Pousser le commit de changement de version/Changelog et le tag sur main
- Depuis jenkins lancer le build sur le nouveau tag créé : https://jenkins.v0id.ovh/job/N3WT/job/Newt-Innov/job/n3wt-school/view/tags/
# Faire une Mise en Production
- Depuis jenkins deployer la nouvelle version tagué.
# Utilisation de standart-version
L'utilisation de la norme conventionnal commit permet la génération automatique d'un CHANGELOG
via l'outil [standard-version](https://github.com/conventional-changelog/standard-version)
```sh
# Faire la première release (1.0.0)
npm run release -- --first-release
# Faire une prerelease (RC,alpha,beta)
npm run release -- --prerelease <name>
# Faire une release
npm run release
# Forcer la release sur un mode particulier (majeur, mineur ou patch)
# npm run script
npm run release -- --release-as minor
# Or
npm run release -- --release-as 1.1.0
# ignorer les hooks de commit lors de la release
npm run release -- --no-verify
```

View File

@ -1,84 +0,0 @@
# 🧭 Premiers Pas avec N3WT-SCHOOL
Bienvenue dans **N3WT-SCHOOL** !
Ce guide rapide vous accompagne dans les premières étapes de configuration de votre instance afin de la rendre pleinement opérationnelle pour votre établissement.
> ** Version bêta**
> N3WT-SCHOOL est actuellement en version bêta. Certaines fonctionnalités sont encore en cours de développement (par exemple : création d'une vue dédiée aux professeurs, génération automatique de factures, renforcement de la sécurité du site, etc).
> Il est donc possible que vous rencontriez des bugs ou des comportements inattendus. Merci de votre compréhension et de vos retours !
## ✅ Étapes à suivre :
1. **Configurer la signature électronique des documents via Docuseal**
2. **Activer l'envoi d'e-mails depuis la plateforme**
---
## ✍️ 1. Configuration de la signature électronique (Docuseal)
Pour permettre la signature électronique des documents administratifs (inscriptions, conventions, etc.), N3WT-SCHOOL s'appuie sur [**Docuseal**](https://docuseal.com), un service sécurisé de signature électronique.
### Étapes :
1. Créez un compte sur Docuseal :
👉 [https://docuseal.com/sign_up](https://docuseal.com/sign_up)
2. Une fois connecté, accédez à la section API :
👉 [https://console.docuseal.com/api](https://console.docuseal.com/api)
3. Copiez votre **X-Auth-Token** personnel.
Ce jeton permettra à N3WT-SCHOOL de se connecter à votre compte Docuseal.
4. **Envoyez votre X-Auth-Token à l'équipe N3WT-SCHOOL** pour qu'un administrateur puisse finaliser la configuration :
✉️ Contact : [contact@n3wtschool.com](mailto:contact@n3wtschool.com)
> ⚠️ Cette opération doit impérativement être réalisée par un administrateur N3WT-SCHOOL.
> Ne partagez pas ce token en dehors de ce cadre.
---
## 📧 2. Configuration de l'envoi de-mails
N3WT-SCHOOL assure la gestion des inscriptions de façon entièrement dématérialisée, avec lenvoi automatique de-mails à chaque étape clé du parcours (notifications aux étudiants, accusés de réception, transmission de documents, etc.).
Pour permettre le bon fonctionnement de ces envois automatiques, il est nécessaire que vous configuriez votre mot de passe dans les paramètres de messagerie (SMTP) de votre établissement dans le menu **Paramètres** de lapplication.
### Informations requises :
- Hôte SMTP
- Port SMTP
- Type de sécurité (TLS / SSL)
- Adresse e-mail (utilisateur SMTP)
- Mot de passe ou **mot de passe applicatif**
La plupart des champs ont déjà été pré-remplis grâce aux informations fournies lors de votre inscription : un vrai gain de temps !
Il ne vous reste plus quà saisir votre mot de passe pour finaliser la configuration et profiter pleinement de lenvoi automatique de-mails.
---
## 🔐 Mot de passe applicatif (Gmail, Outlook, etc.)
Certains fournisseurs (notamment **Gmail**, **Yahoo**, **iCloud**) ne permettent pas dutiliser directement votre mot de passe personnel pour des applications tierces.
Vous devez créer un **mot de passe applicatif**.
### Exemple : Créer un mot de passe applicatif avec Gmail
1. Connectez-vous à [votre compte Google](https://myaccount.google.com)
2. Allez dans **Sécurité > Validation en 2 étapes**
3. Activez la validation en 2 étapes si ce nest pas déjà fait
4. Ensuite, allez dans **Mots de passe des applications**
5. Sélectionnez une application (ex. : "Autre (personnalisée)") et nommez-la "N3WT-SCHOOL"
6. Copiez le mot de passe généré et utilisez-le comme **mot de passe SMTP**
> 📎 Consultez laide officielle de Google :
> [Créer un mot de passe dapplication Google](https://support.google.com/accounts/answer/185833)
> Si vous rencontrez la moindre difficulté pour générer ou utiliser un mot de passe applicatif, n'hésitez pas à contacter l'équipe N3WT-SCHOOL : nous sommes à votre disposition pour vous accompagner dans cette démarche.
---
## 🎉 Vous êtes prêt·e !
Une fois ces deux configurations effectuées, votre instance N3WT-SCHOOL est prête à fonctionner pleinement.
Vous pourrez ensuite ajouter vos formations, étudiants, documents et automatiser toute votre gestion scolaire.
Merci de votre confiance et nhésitez pas à nous faire part de vos retours pour améliorer la plateforme !

Binary file not shown.