diff --git a/Back-End/Subscriptions/models.py b/Back-End/Subscriptions/models.py index 296a9cf..76420d2 100644 --- a/Back-End/Subscriptions/models.py +++ b/Back-End/Subscriptions/models.py @@ -316,14 +316,31 @@ class RegistrationForm(models.Model): ############################################################# ####### Formulaires masters (documents école, à signer ou pas) ####### +def registration_school_file_master_upload_to(instance, filename): + # Stocke les fichiers masters dans un dossier dédié + return f"registration_files/school_file_masters/{instance.pk}/{filename}" + class RegistrationSchoolFileMaster(models.Model): groups = models.ManyToManyField(RegistrationFileGroup, related_name='school_file_masters', blank=True) name = models.CharField(max_length=255, default="") is_required = models.BooleanField(default=False) formMasterData = models.JSONField(default=list, blank=True, null=True) + # Nouveau champ pour formulaire existant (PDF, DOC, etc.) + file = models.FileField( + upload_to=registration_school_file_master_upload_to, + null=True, + blank=True, + help_text="Fichier du formulaire existant (PDF, DOC, etc.)" + ) def __str__(self): - return f'{self.group.name} - {self.id}' + return f'{self.name} - {self.id}' + + @property + def file_url(self): + if self.file and hasattr(self.file, 'url'): + return self.file.url + return None ####### Parent files masters (documents à fournir par les parents) ####### class RegistrationParentFileMaster(models.Model): @@ -337,10 +354,10 @@ class RegistrationParentFileMaster(models.Model): ############################################################ def registration_school_file_upload_to(instance, filename): - return f"registration_files/dossier_rf_{instance.registration_form.pk}/school/{filename}" + return f"registration_files/{instance.registration_form.establishment.name}/dossier_rf_{instance.registration_form.pk}/school/{filename}" def registration_parent_file_upload_to(instance, filename): - return f"registration_files/dossier_rf_{instance.registration_form.pk}/parent/{filename}" + return f"registration_files/{instance.registration_form.establishment.name}/dossier_rf_{instance.registration_form.pk}/parent/{filename}" ####### Formulaires templates (par dossier d'inscription) ####### class RegistrationSchoolFileTemplate(models.Model): diff --git a/Back-End/Subscriptions/urls.py b/Back-End/Subscriptions/urls.py index 5444187..9a91d11 100644 --- a/Back-End/Subscriptions/urls.py +++ b/Back-End/Subscriptions/urls.py @@ -25,8 +25,6 @@ from .views import ( from .views import RegistrationFileGroupView, RegistrationFileGroupSimpleView, get_registration_files_by_group from .views import ( - registration_school_file_masters_views, - registration_school_file_templates_views, get_school_file_templates_by_rf, get_parent_file_templates_by_rf ) diff --git a/Back-End/Subscriptions/util.py b/Back-End/Subscriptions/util.py index f4e0a1e..d682fa3 100644 --- a/Back-End/Subscriptions/util.py +++ b/Back-End/Subscriptions/util.py @@ -8,6 +8,9 @@ from N3wtSchool import renderers from N3wtSchool import bdd from io import BytesIO +from reportlab.pdfgen import canvas +from reportlab.lib.pagesizes import A4 +from django.core.files.base import ContentFile from django.core.files import File from pathlib import Path import os @@ -100,8 +103,9 @@ def create_templates_for_registration_form(register_form): from Subscriptions.models import ( RegistrationSchoolFileMaster, RegistrationSchoolFileTemplate, - # RegistrationParentFileMaster, - # RegistrationParentFileTemplate, + RegistrationParentFileMaster, + RegistrationParentFileTemplate, + registration_school_file_upload_to, ) created = [] @@ -114,25 +118,26 @@ def create_templates_for_registration_form(register_form): for t in school_existing: try: if getattr(t, "file", None): + logger.info("Deleted school template %s for RF %s", t.pk, register_form.pk) t.file.delete(save=False) except Exception: logger.exception("Erreur suppression fichier school template %s", getattr(t, "pk", None)) t.delete() - # parent_existing = RegistrationParentFileTemplate.objects.filter(registration_form=register_form) - # for t in parent_existing: - # try: - # if getattr(t, "file", None): - # t.file.delete(save=False) - # except Exception: - # logger.exception("Erreur suppression fichier parent template %s", getattr(t, "pk", None)) - # t.delete() + parent_existing = RegistrationParentFileTemplate.objects.filter(registration_form=register_form) + for t in parent_existing: + try: + if getattr(t, "file", None): + t.file.delete(save=False) + except Exception: + logger.exception("Erreur suppression fichier parent template %s", getattr(t, "pk", None)) + t.delete() return created school_masters = RegistrationSchoolFileMaster.objects.filter(groups=current_group).distinct() - # parent_masters = RegistrationParentFileMaster.objects.filter(groups=current_group).distinct() + parent_masters = RegistrationParentFileMaster.objects.filter(groups=current_group).distinct() school_master_ids = {m.pk for m in school_masters} - #parent_master_ids = {m.pk for m in parent_masters} + parent_master_ids = {m.pk for m in parent_masters} # Supprimer les school templates obsolètes for tmpl in RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form): @@ -146,15 +151,15 @@ def create_templates_for_registration_form(register_form): logger.info("Deleted obsolete school template %s for RF %s", getattr(tmpl, "pk", None), register_form.pk) # Supprimer les parent templates obsolètes - # for tmpl in RegistrationParentFileTemplate.objects.filter(registration_form=register_form): - # if not tmpl.master_id or tmpl.master_id not in parent_master_ids: - # try: - # if getattr(tmpl, "file", None): - # tmpl.file.delete(save=False) - # except Exception: - # logger.exception("Erreur suppression fichier parent template obsolète %s", getattr(tmpl, "pk", None)) - # tmpl.delete() - # logger.info("Deleted obsolete parent template %s for RF %s", getattr(tmpl, "pk", None), register_form.pk) + for tmpl in RegistrationParentFileTemplate.objects.filter(registration_form=register_form): + if not tmpl.master_id or tmpl.master_id not in parent_master_ids: + try: + if getattr(tmpl, "file", None): + tmpl.file.delete(save=False) + except Exception: + logger.exception("Erreur suppression fichier parent template obsolète %s", getattr(tmpl, "pk", None)) + tmpl.delete() + logger.info("Deleted obsolete parent template %s for RF %s", getattr(tmpl, "pk", None), register_form.pk) # Créer les school templates manquants for m in school_masters: @@ -163,28 +168,58 @@ def create_templates_for_registration_form(register_form): continue base_slug = (m.name or "master").strip().replace(" ", "_")[:40] slug = f"{base_slug}_{register_form.pk}_{m.pk}" + + # Si le master a un fichier uploadé (formulaire existant) + file_to_attach = None + if m.file: + import os + from django.core.files import File as DjangoFile + master_file_path = m.file.path + if os.path.exists(master_file_path): + filename = os.path.basename(master_file_path) + # Générer le chemin cible pour le template élève + dest_path = registration_school_file_upload_to(None, filename) + dest_dir = os.path.dirname(os.path.join(settings.MEDIA_ROOT, dest_path)) + os.makedirs(dest_dir, exist_ok=True) + # Copier le fichier dans le dossier cible + dest_full_path = os.path.join(settings.MEDIA_ROOT, dest_path) + with open(master_file_path, 'rb') as src, open(dest_full_path, 'wb') as dst: + dst.write(src.read()) + # Préparer le File Django à attacher au template + with open(dest_full_path, 'rb') as f: + file_to_attach = DjangoFile(f, name=dest_path) + else: + # Générer le PDF du template à partir du JSON du master + try: + pdf_file = generate_form_json_pdf(register_form, m.formMasterData) + file_to_attach = pdf_file + except Exception as e: + logger.error(f"Erreur lors de la génération du PDF pour le template: {e}") + file_to_attach = None + tmpl = RegistrationSchoolFileTemplate.objects.create( master=m, registration_form=register_form, name=m.name or "", formTemplateData=m.formMasterData or [], slug=slug, + file=file_to_attach, ) created.append(tmpl) logger.info("Created school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk) # Créer les parent templates manquants - # for m in parent_masters: - # exists = RegistrationParentFileTemplate.objects.filter(master=m, registration_form=register_form).exists() - # if exists: - # continue - # tmpl = RegistrationParentFileTemplate.objects.create( - # master=m, - # registration_form=register_form, - # file=None, - # ) - # created.append(tmpl) - # logger.info("Created parent template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk) + for m in parent_masters: + exists = RegistrationParentFileTemplate.objects.filter(master=m, registration_form=register_form).exists() + if exists: + continue + tmpl = RegistrationParentFileTemplate.objects.create( + master=m, + registration_form=register_form, + file=None, + ) + created.append(tmpl) + logger.info("Created parent template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk) return created @@ -377,4 +412,52 @@ def getHistoricalYears(count=5): historical_start_year = start_year - i historical_years.append(f"{historical_start_year}-{historical_start_year + 1}") - return historical_years \ No newline at end of file + return historical_years + +def generate_form_json_pdf(register_form, form_json): + """ + Génère un PDF du rendu du formulaire dynamique à partir du JSON (formConfig) + et l'associe au RegistrationSchoolFileTemplate. + Le PDF contient le titre, les labels et types de champs. + Retourne un ContentFile prêt à être utilisé dans un FileField, avec uniquement le nom du fichier. + """ + + # Récupérer le nom du formulaire + form_name = (form_json.get("title") or "formulaire").strip().replace(" ", "_") + filename = f"{form_name}.pdf" + + # Générer le PDF + buffer = BytesIO() + c = canvas.Canvas(buffer, pagesize=A4) + y = 800 + + # Titre + c.setFont("Helvetica-Bold", 20) + c.drawString(100, y, form_json.get("title", "Formulaire")) + y -= 40 + + # Champs + c.setFont("Helvetica", 12) + fields = form_json.get("fields", []) + for field in fields: + label = field.get("label", field.get("id", "")) + ftype = field.get("type", "") + c.drawString(100, y, f"{label} [{ftype}]") + y -= 25 + if y < 100: + c.showPage() + y = 800 + + c.save() + buffer.seek(0) + pdf_content = buffer.read() + + # Si un fichier existe déjà sur le template, le supprimer (optionnel, à adapter selon usage) + if hasattr(register_form, "registration_file") and register_form.registration_file and register_form.registration_file.name: + existing_file_path = os.path.join(settings.MEDIA_ROOT, register_form.registration_file.name.lstrip('/')) + if os.path.exists(existing_file_path): + os.remove(existing_file_path) + register_form.registration_file.delete(save=False) + + # Retourner le ContentFile avec uniquement le nom du fichier + return ContentFile(pdf_content, name=os.path.basename(filename)) diff --git a/Back-End/Subscriptions/views/__init__.py b/Back-End/Subscriptions/views/__init__.py index dc413b4..0aa6428 100644 --- a/Back-End/Subscriptions/views/__init__.py +++ b/Back-End/Subscriptions/views/__init__.py @@ -10,14 +10,18 @@ from .register_form_views import ( from .registration_school_file_masters_views import ( RegistrationSchoolFileMasterView, RegistrationSchoolFileMasterSimpleView, - RegistrationParentFileMasterView, - RegistrationParentFileMasterSimpleView, - RegistrationParentFileTemplateSimpleView, - RegistrationParentFileTemplateView ) from .registration_school_file_templates_views import ( RegistrationSchoolFileTemplateView, - RegistrationSchoolFileTemplateSimpleView, + RegistrationSchoolFileTemplateSimpleView +) +from .registration_parent_file_masters_views import ( + RegistrationParentFileMasterView, + RegistrationParentFileMasterSimpleView +) +from .registration_parent_file_templates_views import ( + RegistrationParentFileTemplateSimpleView, + RegistrationParentFileTemplateView ) from .registration_file_group_views import RegistrationFileGroupView, RegistrationFileGroupSimpleView, get_registration_files_by_group from .student_views import StudentView, StudentListView, ChildrenListView, search_students diff --git a/Back-End/Subscriptions/views/registration_parent_file_masters_views.py b/Back-End/Subscriptions/views/registration_parent_file_masters_views.py new file mode 100644 index 0000000..df91d4d --- /dev/null +++ b/Back-End/Subscriptions/views/registration_parent_file_masters_views.py @@ -0,0 +1,272 @@ +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, JSONParser +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework import status + +from Subscriptions.serializers import RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer +from Subscriptions.models import ( + RegistrationForm, + RegistrationParentFileMaster, + RegistrationParentFileTemplate +) +from N3wtSchool import bdd +import logging +import Subscriptions.util as util + +logger = logging.getLogger(__name__) + +class RegistrationParentFileMasterView(APIView): + parser_classes = [MultiPartParser, FormParser, JSONParser] + + @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): + 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 = RegistrationParentFileMasterSerializer(data=payload, partial=True) + if serializer.is_valid(): + obj = serializer.save() + + # Propager la création des templates côté serveur pour les RegistrationForm + try: + groups_qs = obj.groups.all() + if groups_qs.exists(): + rfs = RegistrationForm.objects.filter(fileGroup__in=groups_qs).distinct() + for rf in rfs: + try: + util.create_templates_for_registration_form(rf) + except Exception as e: + logger.exception("Error creating templates for RF %s from parent master %s: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e) + except Exception: + logger.exception("Error while propagating templates after parent master creation %s", getattr(obj, 'pk', None)) + + return Response(RegistrationParentFileMasterSerializer(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 RegistrationParentFileMasterSimpleView(APIView): + parser_classes = [MultiPartParser, FormParser, JSONParser] + @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): + master = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id) + if master is None: + return JsonResponse({'erreur': "Le master de fichier parent n'a pas été trouvé"}, safe=False, status=status.HTTP_404_NOT_FOUND) + + # snapshot des groups avant update + old_group_ids = set(master.groups.values_list('id', flat=True)) + + # 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 = RegistrationParentFileMasterSerializer(master, data=payload, partial=True) + if serializer.is_valid(): + obj = serializer.save() + + # groups après update + new_group_ids = set(obj.groups.values_list('id', flat=True)) + + removed_group_ids = old_group_ids - new_group_ids + added_group_ids = new_group_ids - old_group_ids + + # Pour chaque RF appartenant aux groupes retirés -> nettoyer les templates (idempotent) + if removed_group_ids: + try: + rfs_removed = RegistrationForm.objects.filter(fileGroup__in=list(removed_group_ids)).distinct() + for rf in rfs_removed: + try: + util.create_templates_for_registration_form(rf) + except Exception as e: + logger.exception("Error cleaning templates for RF %s after parent master %s group removal: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e) + except Exception: + logger.exception("Error while processing RFs for removed groups after parent master update %s", getattr(obj, 'pk', None)) + + # Pour chaque RF appartenant aux groupes ajoutés -> créer les templates manquants + if added_group_ids: + try: + rfs_added = RegistrationForm.objects.filter(fileGroup__in=list(added_group_ids)).distinct() + for rf in rfs_added: + try: + util.create_templates_for_registration_form(rf) + except Exception as e: + logger.exception("Error creating templates for RF %s after parent master %s group addition: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e) + except Exception: + logger.exception("Error while processing RFs for added groups after parent master update %s", getattr(obj, 'pk', None)) + + 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 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é 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) diff --git a/Back-End/Subscriptions/views/registration_parent_file_templates_views.py b/Back-End/Subscriptions/views/registration_parent_file_templates_views.py new file mode 100644 index 0000000..eed518e --- /dev/null +++ b/Back-End/Subscriptions/views/registration_parent_file_templates_views.py @@ -0,0 +1,111 @@ +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, JSONParser +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework import status + +from Subscriptions.serializers import RegistrationParentFileTemplateSerializer +from Subscriptions.models import ( + RegistrationParentFileTemplate +) +from N3wtSchool import bdd +import logging +import Subscriptions.util as util + +logger = logging.getLogger(__name__) + +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: + 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) diff --git a/Back-End/Subscriptions/views/registration_school_file_masters_views.py b/Back-End/Subscriptions/views/registration_school_file_masters_views.py index 5dc2d81..2f794a6 100644 --- a/Back-End/Subscriptions/views/registration_school_file_masters_views.py +++ b/Back-End/Subscriptions/views/registration_school_file_masters_views.py @@ -5,15 +5,12 @@ from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from rest_framework.response import Response from rest_framework.views import APIView from rest_framework import status -import json -from django.http import QueryDict -from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer, RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer +from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer from Subscriptions.models import ( RegistrationForm, RegistrationSchoolFileMaster, - RegistrationParentFileMaster, - RegistrationParentFileTemplate + RegistrationSchoolFileTemplate ) from N3wtSchool import bdd import logging @@ -179,188 +176,13 @@ class RegistrationSchoolFileMasterSimpleView(APIView): def delete(self, request, id): master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id) if master is not None: + # Supprimer tous les templates liés et leurs fichiers PDF + templates = RegistrationSchoolFileTemplate.objects.filter(master=master) + for template in templates: + if template.file and template.file.name: + template.file.delete(save=False) + template.delete() 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) + return JsonResponse({'message': 'La suppression du master de template et des fichiers associés 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 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: - 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) diff --git a/Back-End/Subscriptions/views/registration_school_file_templates_views.py b/Back-End/Subscriptions/views/registration_school_file_templates_views.py index 70c6fac..0f25471 100644 --- a/Back-End/Subscriptions/views/registration_school_file_templates_views.py +++ b/Back-End/Subscriptions/views/registration_school_file_templates_views.py @@ -5,8 +5,7 @@ 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 json -from django.http import QueryDict +import os from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer from Subscriptions.models import RegistrationSchoolFileMaster, RegistrationSchoolFileTemplate, RegistrationParentFileMaster, RegistrationParentFileTemplate @@ -209,6 +208,17 @@ class RegistrationSchoolFileTemplateSimpleView(APIView): def delete(self, request, id): template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _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: @@ -390,6 +400,17 @@ class RegistrationParentFileTemplateSimpleView(APIView): 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: diff --git a/Front-End/src/app/[locale]/admin/subscriptions/createSubscription/page.js b/Front-End/src/app/[locale]/admin/subscriptions/createSubscription/page.js index 779e9cb..879ed94 100644 --- a/Front-End/src/app/[locale]/admin/subscriptions/createSubscription/page.js +++ b/Front-End/src/app/[locale]/admin/subscriptions/createSubscription/page.js @@ -34,9 +34,7 @@ import { import { fetchRegistrationFileGroups, fetchRegistrationSchoolFileMasters, - fetchRegistrationParentFileMasters, - createRegistrationSchoolFileTemplate, - createRegistrationParentFileTemplate, + fetchRegistrationParentFileMasters } from '@/app/actions/registerFileGroupAction'; import { fetchProfiles } from '@/app/actions/authAction'; import { useClasses } from '@/context/ClassesContext'; diff --git a/Front-End/src/app/actions/registerFileGroupAction.js b/Front-End/src/app/actions/registerFileGroupAction.js index 1750254..f8a8f6c 100644 --- a/Front-End/src/app/actions/registerFileGroupAction.js +++ b/Front-End/src/app/actions/registerFileGroupAction.js @@ -100,12 +100,13 @@ export async function createRegistrationFileGroup(groupData, csrfToken) { } export const createRegistrationSchoolFileMaster = (data, csrfToken) => { + // Toujours FormData, jamais JSON return fetch(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}`, { method: 'POST', - body: JSON.stringify(data), + body: data, headers: { 'X-CSRFToken': csrfToken, - 'Content-Type': 'application/json', + // Pas de Content-Type, le navigateur gère pour FormData }, credentials: 'include', }) diff --git a/Front-End/src/components/SectionHeaderDocument.js b/Front-End/src/components/SectionHeaderDocument.js new file mode 100644 index 0000000..9194712 --- /dev/null +++ b/Front-End/src/components/SectionHeaderDocument.js @@ -0,0 +1,85 @@ +import React from 'react'; +import { Plus } from 'lucide-react'; + +// Thèmes couleur selon le type +const THEME = { + groupe: { + bg: 'bg-blue-50', + border: 'border-blue-200', + iconBg: 'bg-blue-100', + icon: 'text-blue-600', + title: 'text-blue-800', + desc: 'text-blue-600', + button: 'bg-blue-500 text-white hover:bg-blue-600', + buttonText: 'text-blue-700', + buttonHover: 'hover:bg-blue-100', + }, + formulaire: { + bg: 'bg-emerald-50', + border: 'border-emerald-200', + iconBg: 'bg-emerald-100', + icon: 'text-emerald-600', + title: 'text-emerald-800', + desc: 'text-emerald-600', + button: 'bg-emerald-500 text-white hover:bg-emerald-600', + buttonText: 'text-emerald-700', + buttonHover: 'hover:bg-emerald-100', + }, + parent: { + bg: 'bg-orange-50', + border: 'border-orange-200', + iconBg: 'bg-orange-100', + icon: 'text-orange-500', + title: 'text-orange-700', + desc: 'text-orange-600', + button: 'bg-orange-500 text-white hover:bg-orange-600', + buttonText: 'text-orange-700', + buttonHover: 'hover:bg-orange-100', + }, +}; + +const SectionHeaderDocument = ({ + icon: Icon, + title, + description, + button = false, + buttonOpeningModal = false, + onClick = null, + className = '', + type = 'groupe', // 'groupe', 'formulaire', 'parent' +}) => { + const theme = THEME[type] || THEME.groupe; + + return ( +
+
+ {Icon && ( + + + + )} +
+

{title}

+ {description && ( +

{description}

+ )} +
+
+ {button && onClick && ( + + )} +
+ ); +}; + +export default SectionHeaderDocument; diff --git a/Front-End/src/components/Structure/Files/CreateDocumentModal.js b/Front-End/src/components/Structure/Files/CreateDocumentModal.js new file mode 100644 index 0000000..fd3fcac --- /dev/null +++ b/Front-End/src/components/Structure/Files/CreateDocumentModal.js @@ -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 ( + + {step === 'main' && ( +
+ + + +
+ )} + {step === 'choose_form' && ( +
+ + + +
+ )} + {step === 'form_builder' && ( +
+ + { + onCreateSchoolFileMaster(data); + onClose(); + }} + groups={groups} + isEditing={false} + /> +
+ )} + {step === 'file_upload' && ( +
+ +
+ setFileName(e.target.value)} + required + /> + {/* Sélecteur de groupes à cocher */} +
+ +
+ {groups && groups.length > 0 ? ( + groups.map((group) => ( + + )) + ) : ( +

+ Aucun groupe disponible +

+ )} +
+
+ + + +
+ )} +
+ ); +} diff --git a/Front-End/src/components/Structure/Files/FileUploadDocuSeal.js b/Front-End/src/components/Structure/Files/FileUploadDocuSeal.js index 890d7dd..7916105 100644 --- a/Front-End/src/components/Structure/Files/FileUploadDocuSeal.js +++ b/Front-End/src/components/Structure/Files/FileUploadDocuSeal.js @@ -10,8 +10,8 @@ import { useEstablishment } from '@/context/EstablishmentContext'; import Popup from '@/components/Popup'; export default function FileUploadDocuSeal({ - handleCreateTemplateMaster, - handleEditTemplateMaster, + handleCreateSchoolFileMaster, + handleEditSchoolFileMaster, fileToEdit = null, onSuccess, }) { @@ -75,7 +75,7 @@ export default function FileUploadDocuSeal({ const is_required = data.fields.length > 0; if (fileToEdit) { logger.debug('Modification du template master:', templateMaster?.id); - handleEditTemplateMaster({ + handleEditSchoolFileMaster({ name: uploadedFileName, group_ids: selectedGroups.map((group) => group.id), id: templateMaster?.id, @@ -83,7 +83,7 @@ export default function FileUploadDocuSeal({ }); } else { logger.debug('Création du template master:', templateMaster?.id); - handleCreateTemplateMaster({ + handleCreateSchoolFileMaster({ name: uploadedFileName, group_ids: selectedGroups.map((group) => group.id), id: templateMaster?.id, diff --git a/Front-End/src/components/Structure/Files/FilesGroupsManagement.js b/Front-End/src/components/Structure/Files/FilesGroupsManagement.js index 3965db7..b791aff 100644 --- a/Front-End/src/components/Structure/Files/FilesGroupsManagement.js +++ b/Front-End/src/components/Structure/Files/FilesGroupsManagement.js @@ -1,16 +1,10 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { - Download, Edit3, Trash2, - FolderPlus, - FileText, - AlertTriangle, } from 'lucide-react'; import Modal from '@/components/Modal'; -import Table from '@/components/Table'; import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder'; -import { BASE_URL } from '@/utils/Url'; import { // GET fetchRegistrationFileGroups, @@ -31,13 +25,110 @@ import { } from '@/app/actions/registerFileGroupAction'; import RegistrationFileGroupForm from '@/components/Structure/Files/RegistrationFileGroupForm'; import logger from '@/utils/logger'; -import ParentFilesSection from '@/components/Structure/Files/ParentFilesSection'; -import SectionHeader from '@/components/SectionHeader'; +import ParentFiles from './ParentFiles'; import Popup from '@/components/Popup'; import Loader from '@/components/Loader'; import { useNotification } from '@/context/NotificationContext'; -import AlertMessage from '@/components/AlertMessage'; -import FileUploadDocuSeal from '@/components/Structure/Files/FileUploadDocuSeal'; +import CreateDocumentModal from '@/components/Structure/Files/CreateDocumentModal'; +import FileUpload from '@/components/Form/FileUpload'; + +function getItemBgColor(type, selected, forceTheme = false) { + // Colonne gauche : bleu, sélectionné plus soutenu + if (type === 'blue') { + if (selected) return 'bg-blue-200'; + return 'bg-blue-50'; + } + // Colonne droite : thème selon type, jamais sélectionné + if (forceTheme) { + if (type === 'emerald') return 'bg-emerald-50'; + if (type === 'orange') return 'bg-orange-50'; + return 'bg-gray-50'; + } + return 'bg-white'; +} + +function SimpleList({ + items, + onSelect, + selectedId, + actionButtons, + getItemType, + title, + minHeight = 'min-h-[200px]', + selectable = true, + forceTheme = false, +}) { + return ( +
+ {title && ( +
+ {title} +
+ )} + +
+ ); +} export default function FilesGroupsManagement({ csrfToken, @@ -57,22 +148,34 @@ export default function FilesGroupsManagement({ const [removePopupMessage, setRemovePopupMessage] = useState(''); const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); const [isLoading, setIsLoading] = useState(false); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [createModalKey, setCreateModalKey] = useState(0); + const [selectedGroupId, setSelectedGroupId] = useState(null); + const [showHelp, setShowHelp] = useState(false); const { showNotification } = useNotification(); + const [isParentFileModalOpen, setIsParentFileModalOpen] = useState(false); + const [editingParentFile, setEditingParentFile] = useState(null); + const [isFileUploadModalOpen, setIsFileUploadModalOpen] = useState(false); const transformFileData = (file, groups) => { - const groupInfos = file.groups.map( - (groupId) => - groups.find((g) => g.id === groupId) || { - id: groupId, - name: 'Groupe inconnu', - } - ); + // file.groups peut contenir des IDs (number/string) ou des objets {id, name} + const groupInfos = (file.groups || []).map((group) => { + if (typeof group === 'object' && group !== null && 'id' in group) { + // Déjà un objet groupe + const found = groups.find((g) => g.id === group.id); + return found || group; + } else { + // C'est un ID + return groups.find((g) => g.id === group) || { id: group, name: 'Groupe inconnu' }; + } + }); return { ...file, groups: groupInfos, }; }; + // Ne pas transformer ici, stocker la donnée brute useEffect(() => { if (selectedEstablishmentId) { Promise.all([ @@ -83,11 +186,7 @@ export default function FilesGroupsManagement({ .then(([dataSchoolFileMasters, groupsData, dataParentFileMasters]) => { setGroups(groupsData); setParentFileMasters(dataParentFileMasters); - // Transformer chaque fichier pour inclure les informations complètes du groupe - const transformedFiles = dataSchoolFileMasters.map((file) => - transformFileData(file, groupsData) - ); - setSchoolFileMasters(transformedFiles); + setSchoolFileMasters(dataSchoolFileMasters); // donnée brute }) .catch((err) => { logger.debug(err.message); @@ -148,19 +247,22 @@ export default function FilesGroupsManagement({ setIsModalOpen(true); }; - const handleCreateTemplateMaster = ({ name, group_ids, formMasterData }) => { - const data = { - name: name, + const handleCreateSchoolFileMaster = ({ name, group_ids, formMasterData, file }) => { + // Toujours envoyer en FormData, même sans fichier + const dataToSend = new FormData(); + const jsonData = { + name, groups: group_ids, - formMasterData: formMasterData, // Envoyer directement l'objet + formMasterData, }; - logger.debug(data); + dataToSend.append('data', JSON.stringify(jsonData)); + if (file) { + dataToSend.append('file', file, file.path || file.name); + } - createRegistrationSchoolFileMaster(data, csrfToken) + createRegistrationSchoolFileMaster(dataToSend, csrfToken) .then((data) => { - // Transformer le nouveau fichier avec les informations du groupe - const transformedFile = transformFileData(data, groups); - setSchoolFileMasters((prevFiles) => [...prevFiles, transformedFile]); + setSchoolFileMasters((prevFiles) => [...prevFiles, data]); setIsModalOpen(false); showNotification( `Le formulaire "${name}" a été créé avec succès.`, @@ -178,7 +280,7 @@ export default function FilesGroupsManagement({ }); }; - const handleEditTemplateMaster = ({ + const handleEditSchoolFileMaster = ({ name, group_ids, formMasterData, @@ -193,10 +295,8 @@ export default function FilesGroupsManagement({ editRegistrationSchoolFileMaster(id, data, csrfToken) .then((data) => { - // Transformer le fichier mis à jour avec les informations du groupe - const transformedFile = transformFileData(data, groups); setSchoolFileMasters((prevFichiers) => - prevFichiers.map((f) => (f.id === id ? transformedFile : f)) + prevFichiers.map((f) => (f.id === id ? data : f)) ); setIsModalOpen(false); showNotification( @@ -292,6 +392,10 @@ export default function FilesGroupsManagement({ throw new Error('Erreur lors de la suppression du groupe.'); } setGroups(groups.filter((group) => group.id !== groupId)); + // Si le groupe supprimé était sélectionné, on désélectionne + if (String(selectedGroupId) === String(groupId)) { + setSelectedGroupId(null); + } setRemovePopupVisible(false); setIsLoading(false); showNotification('Groupe supprimé avec succès.', 'success', 'Succès'); @@ -331,15 +435,22 @@ export default function FilesGroupsManagement({ }; const handleEdit = (id, updatedFile) => { + logger.debug('[FilesGroupsManagement] handleEdit called with:', id, updatedFile); + // Correction : vérifier si updatedFile est bien un objet (et pas juste un id) + if (typeof updatedFile !== 'object' || updatedFile === null) { + logger.error('[FilesGroupsManagement] handleEdit: updatedFile is not an object', updatedFile); + return Promise.reject(new Error('updatedFile is not an object')); + } + logger.debug('[FilesGroupsManagement] handleEdit payload:', JSON.stringify(updatedFile)); return editRegistrationParentFileMaster(id, updatedFile, csrfToken) .then((response) => { + logger.debug('[FilesGroupsManagement] editRegistrationParentFileMaster response:', response); const modifiedFile = response.data; // Extraire les données mises à jour - // Mettre à jour la liste des fichiers parents setParentFileMasters((prevFiles) => prevFiles.map((file) => (file.id === id ? modifiedFile : file)) ); logger.debug('Document parent mis à jour avec succès:', modifiedFile); - return modifiedFile; // Retourner le fichier mis à jour + return modifiedFile; }) .catch((error) => { logger.error( @@ -369,77 +480,321 @@ export default function FilesGroupsManagement({ }); }; - const filteredFiles = schoolFileMasters.filter((file) => { - if (!selectedGroup) return true; + // Ouvre la modale de création d'une pièce à fournir + const openParentFileModal = () => { + setEditingParentFile(null); + setIsParentFileModalOpen(true); + }; + + // Ouvre la modale d'édition d'une pièce à fournir + const openEditParentFileModal = (file) => { + setEditingParentFile(file); + setIsParentFileModalOpen(true); + }; + + // Ferme la modale de pièce à fournir + const closeParentFileModal = () => { + setEditingParentFile(null); + setIsParentFileModalOpen(false); + }; + + // Ouvre le menu de choix pour formulaire d'école + const handleCreateFormMenu = () => { + setIsCreateModalOpen(false); + setTimeout(() => setIsCreateModalOpen(true), 0); // force le reset du menu + }; + + // Ouvre la modale FormTemplateBuilder + const handleOpenFormTemplateBuilder = () => { + setIsModalOpen(true); + setIsEditing(false); + setFileToEdit(null); + }; + + // Ouvre la modale FileUpload + const handleOpenFileUpload = () => { + setIsFileUploadModalOpen(true); + }; + + // Ferme la modale FileUpload + const handleCloseFileUpload = () => { + setIsFileUploadModalOpen(false); + }; + + // Soumission du formulaire existant (upload) + const handleSubmitFileUpload = ({ name, group_ids, file }) => { + // On centralise la logique dans handleCreateSchoolFileMaster + handleCreateSchoolFileMaster({ name, group_ids, file }); + setIsFileUploadModalOpen(false); + }; + + // Filtrage des formulaires et pièces selon le dossier sélectionné + // Appliquer la transformation à la volée ici et comparer en string + const filteredFiles = schoolFileMasters + .map((file) => transformFileData(file, groups)) + .filter((file) => { + if (!selectedGroupId) return false; + return ( + file.groups && + file.groups.some((group) => String(group.id) === String(selectedGroupId)) + ); + }); + + const filteredParentFiles = parentFiles.filter((file) => { + if (!selectedGroupId) return false; return ( file.groups && - file.groups.some((group) => group.id === parseInt(selectedGroup)) + file.groups.map(String).includes(String(selectedGroupId)) ); }); - const columnsFiles = [ - { name: 'Nom du formulaire', transform: (row) => row.name }, - { - name: "Dossiers d'inscription", - transform: (row) => - row.groups && row.groups.length > 0 - ? row.groups.map((group) => group.name).join(', ') - : 'Aucun', - }, - { - name: 'Actions', - transform: (row) => ( -
- - -
- ), - }, + // Fusion des deux types de documents pour la colonne de droite + const mergedDocuments = [ + ...filteredFiles.map((doc) => ({ ...doc, _type: 'emerald' })), + ...filteredParentFiles.map((doc) => ({ ...doc, _type: 'orange' })), ]; - const columnsGroups = [ - { name: 'Nom du dossier', transform: (row) => row.name }, - { name: 'Description', transform: (row) => row.description }, - { - name: 'Actions', - transform: (row) => ( -
- - + // Nouvelle explication : adaptée au contexte "dossier lié à une classe/groupe de classes" + const renderExplanation = () => ( +
+ + {showHelp && ( +
+

+ Gestion des dossiers et documents d'inscription +

+
+

+ 1. Créez un ou plusieurs dossiers d&aposinscription :
+ Chaque dossier correspond à une classe ou un groupe de classes (ex : Dossier Maternelles, Dossier Élémentaires). Lors de la création d'une inscription élève, un seul dossier d'inscription sera rattaché à l'élève. +

+

+ 2. Pour chaque dossier, ajoutez des documents à fournir : +

+
    +
  • + Formulaires personnalisés : créés dynamiquement par l&aposécole, à remplir et/ou signer électroniquement par la famille (ex : autorisation de sortie, fiche sanitaire, etc.). +
  • +
  • + Pièces à fournir : documents à déposer par la famille (ex : RIB, justificatif de domicile, ou formulaire PDF à télécharger, remplir puis ré-uploader). +
  • +
+
+ Astuce : Commencez toujours par créer vos dossiers d&aposinscription (liés à vos classes) avant d&aposajouter des documents à fournir. +
+
- ), - }, - ]; + )} +
+ ); - if (isLoading) { - return ; - } + // Correction : définition de handleBackToCreateMenu + const handleBackToCreateMenu = () => { + setCreateModalKey((k) => k + 1); // force le reset du composant modal + setIsCreateModalOpen(true); + }; + // Nouvelle disposition : sections côte à côte alignées return (
- {/* Modal pour les formulaires */} + {/* Aide optionnelle + bouton global de création */} +
+ {renderExplanation()} +
+ +
+
+ +
+ {/* Colonne gauche : Dossiers d'inscription */} +
+ 'blue'} + title="Dossiers d'inscription" + minHeight="min-h-[240px]" + selectable={true} + forceTheme={false} + actionButtons={(row) => ( +
+ + +
+ )} + /> +
+ {/* Colonne droite : Documents (fusion des formulaires et pièces à fournir) */} +
+
+
+ Documents +
+ {selectedGroupId === null ? ( +
+ Sélectionnez un dossier pour voir les documents associés. +
+ ) : ( + item._type} + // title="Documents" // Supprimé ici, header déjà affiché au-dessus + minHeight="min-h-[240px]" + selectable={false} + forceTheme={true} + actionButtons={(row) => ( +
+ {row._type === 'emerald' ? ( + <> + + + + ) : ( + <> + + + + )} +
+ )} + /> + )} +
+
+
+ {/* Modal de création centralisée */} + setIsCreateModalOpen(false)} + onCreateGroup={() => { + setIsGroupModalOpen(true); + setIsCreateModalOpen(false); + }} + onCreateForm={() => { + setIsCreateModalOpen(false); + setTimeout(() => setIsModalOpen(true), 0); + }} + onCreateParentFile={() => { + openParentFileModal(); + setIsCreateModalOpen(false); + }} + onBack={handleBackToCreateMenu} + onCreateSchoolFileMaster={handleCreateSchoolFileMaster} + groups={groups} + /> + + {/* Modal pour upload de formulaire existant */} + + { + // Ici, il faut gérer le nom et les groupes (à adapter selon vos besoins UI) + // Par exemple, ouvrir un petit formulaire pour compléter les infos puis submit + }} + onSubmit={handleSubmitFileUpload} + required + enable + /> + + + {/* Modal création/édition pièce à fournir */} + { + if (!open) closeParentFileModal(); + }} + title={editingParentFile ? 'Modifier la pièce à fournir' : 'Créer une pièce à fournir'} + modalClassName="w-full max-w-md" + > + + + + {/* Modals et popups */} { @@ -447,100 +802,51 @@ export default function FilesGroupsManagement({ if (!isOpen) { setFileToEdit(null); setIsEditing(false); + // Retour au menu principal de création si fermeture + handleBackToCreateMenu(); } }} title={isEditing ? 'Modification du formulaire' : 'Créer un formulaire'} modalClassName="w-11/12 h-5/6" > { + (isEditing ? handleEditSchoolFileMaster : handleCreateSchoolFileMaster)(data); + }} initialData={fileToEdit} groups={groups} isEditing={isEditing} /> - {/* Modal pour les groupes */} { + setIsGroupModalOpen(isOpen); + if (!isOpen) { + // Retour au menu principal de création si fermeture + handleBackToCreateMenu(); + } + }} title={ groupToEdit ? 'Modifier le dossier' : "Création d'un nouveau dossier" } > { + handleGroupSubmit(data); + }} initialData={groupToEdit} /> - {/* Section Groupes de fichiers */} -
- setIsGroupModalOpen(true)} - /> - - } - /> - - - {/* Section Formulaires */} -
- { - setIsModalOpen(true); - setIsEditing(false); - setFileToEdit(null); - }} - /> -
- } - /> - - - {/* Section Pièces à fournir */} - setRemovePopupVisible(false)} /> + {isLoading && } ); } diff --git a/Front-End/src/components/Structure/Files/ParentFiles.js b/Front-End/src/components/Structure/Files/ParentFiles.js new file mode 100644 index 0000000..8092fab --- /dev/null +++ b/Front-End/src/components/Structure/Files/ParentFiles.js @@ -0,0 +1,267 @@ +import React, { useState, useEffect } from 'react'; +import Modal from '@/components/Modal'; +import logger from '@/utils/logger'; +import { Edit3, Trash2, Plus } from 'lucide-react'; + +function ParentFileForm({ initialData, groups, onSubmit, onCancel }) { + const [name, setName] = useState(initialData?.name || ''); + const [description, setDescription] = useState(initialData?.description || ''); + // Correction : s'assurer que selectedGroups ne contient que des IDs uniques + const [selectedGroups, setSelectedGroups] = useState( + Array.isArray(initialData?.groups) + ? Array.from( + new Set( + initialData.groups.map(g => (typeof g === 'object' && g !== null && 'id' in g ? g.id : g)) + ) + ) + : [] + ); + const [isRequired, setIsRequired] = useState(initialData?.is_required || false); + + useEffect(() => { + if (initialData) { + setName(initialData.name || ''); + setDescription(initialData.description || ''); + setSelectedGroups( + Array.isArray(initialData.groups) + ? Array.from( + new Set( + initialData.groups.map(g => (typeof g === 'object' && g !== null && 'id' in g ? g.id : g)) + ) + ) + : [] + ); + setIsRequired(initialData.is_required || false); + } + }, [initialData]); + + const handleSubmit = (e) => { + e.preventDefault(); + if (!name || selectedGroups.length === 0) return; + const data = { + name, + description, + groups: selectedGroups, + is_required: isRequired, + id: initialData?.id, + }; + logger.debug('[ParentFileForm] handleSubmit data:', data); + onSubmit(data); + }; + + return ( +
+
+ + setName(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-orange-500 focus:border-orange-500" + /> +
+
+ +