6 Commits

Author SHA1 Message Date
abb4b525b2 feat: Gestion de l'arborescence des documents d'école en fonction des requêtes CRUD [N3WTS-17] 2026-01-25 11:01:22 +01:00
b4f70e6bad feat: Sauvegarde des formulaires d'école dans les bons dossiers /
utilisation des bons composants dans les modales [N3WTS-17]
2026-01-18 18:44:13 +01:00
8549699dec feat: Réorganisation items dans la page [N3WTS-17] 2026-01-05 14:56:36 +01:00
a034149eae fix: Coquille [N3WTS-17] 2026-01-03 17:53:53 +01:00
12f5fc7aa9 feat: Changement du rendu de la page des documents + gestion des
formulaires d'école déjà existants [N3WTS-17]
2026-01-03 17:49:25 +01:00
2dc0dfa268 fix: Lint 2025-12-14 16:49:48 +01:00
20 changed files with 2389 additions and 530 deletions

View File

@ -2,11 +2,9 @@ from django.db import models
from django.utils.timezone import now
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from datetime import datetime
import os
import logging
import os
logger = logging.getLogger("SubscriptionModels")
@ -316,14 +314,164 @@ 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é
# Utilise l'ID si le nom n'est pas encore disponible
est_name = None
if instance.establishment and instance.establishment.name:
est_name = instance.establishment.name
else:
# fallback si pas d'établissement (devrait être rare)
est_name = "unknown_establishment"
return f"{est_name}/Formulaires/{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)
file = models.FileField(
upload_to=registration_school_file_master_upload_to,
null=True,
blank=True,
help_text="Fichier du formulaire existant (PDF, DOC, etc.)"
)
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='school_file_masters', null=True, blank=True)
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
def save(self, *args, **kwargs):
affected_rf_ids = set()
is_new = self.pk is None
# Log création ou modification du master
if is_new:
logger.info(f"[FormPerso] Création master '{self.name}' pour établissement '{self.establishment}'")
else:
logger.info(f"[FormPerso] Modification master '{self.name}' (id={self.pk}) pour établissement '{self.establishment}'")
# --- Suppression de l'ancien fichier master si le nom change (form existant ou dynamique) ---
if self.pk:
try:
old = RegistrationSchoolFileMaster.objects.get(pk=self.pk)
if old.file and old.file.name:
old_filename = os.path.basename(old.file.name)
# Nouveau nom selon le type (dynamique ou existant)
if (
self.formMasterData
and isinstance(self.formMasterData, dict)
and self.formMasterData.get("fields")
):
new_filename = f"{self.name}.pdf"
else:
# Pour les forms existants, le nom attendu est self.name + extension du fichier existant
extension = os.path.splitext(old_filename)[1]
new_filename = f"{self.name}{extension}" if extension else self.name
if new_filename and old_filename != new_filename:
old_file_path = old.file.path
if os.path.exists(old_file_path):
try:
os.remove(old_file_path)
logger.info(f"[FormPerso] Suppression de l'ancien fichier master: {old_file_path}")
except Exception as e:
logger.error(f"[FormPerso] Erreur suppression ancien fichier master: {e}")
# Correction du nom du fichier pour éviter le suffixe random
if (
not self.formMasterData
or not (isinstance(self.formMasterData, dict) and self.formMasterData.get("fields"))
):
# Si le fichier existe et le nom ne correspond pas, renommer le fichier physique et mettre à jour le FileField
if self.file and self.file.name:
current_filename = os.path.basename(self.file.name)
current_path = self.file.path
expected_filename = new_filename
expected_path = os.path.join(os.path.dirname(current_path), expected_filename)
if current_filename != expected_filename:
try:
if os.path.exists(current_path):
os.rename(current_path, expected_path)
self.file.name = os.path.join(os.path.dirname(self.file.name), expected_filename).replace("\\", "/")
logger.info(f"[FormPerso] Renommage du fichier master: {current_path} -> {expected_path}")
except Exception as e:
logger.error(f"[FormPerso] Erreur lors du renommage du fichier master: {e}")
except RegistrationSchoolFileMaster.DoesNotExist:
pass
# --- Traitement PDF dynamique AVANT le super().save() ---
if (
self.formMasterData
and isinstance(self.formMasterData, dict)
and self.formMasterData.get("fields")
):
from Subscriptions.util import generate_form_json_pdf
pdf_filename = f"{self.name}.pdf"
pdf_file = generate_form_json_pdf(self, self.formMasterData)
self.file.save(pdf_filename, pdf_file, save=False)
super().save(*args, **kwargs)
# Synchronisation des templates pour tous les dossiers d'inscription concernés (création ou modification)
try:
# Import local pour éviter le circular import
from Subscriptions.util import create_templates_for_registration_form
from Subscriptions.models import RegistrationForm, RegistrationSchoolFileTemplate
# Détermination des RF concernés
if is_new:
new_groups = set(self.groups.values_list('id', flat=True))
affected_rf_ids.update(
RegistrationForm.objects.filter(fileGroup__in=list(new_groups)).values_list('pk', flat=True)
)
else:
try:
old = RegistrationSchoolFileMaster.objects.get(pk=self.pk)
old_groups = set(old.groups.values_list('id', flat=True))
new_groups = set(self.groups.values_list('id', flat=True))
affected_rf_ids.update(
RegistrationForm.objects.filter(fileGroup__in=list(old_groups | new_groups)).values_list('pk', flat=True)
)
form_data_changed = (
old.formMasterData != self.formMasterData
and self.formMasterData
and isinstance(self.formMasterData, dict)
and self.formMasterData.get("fields")
)
name_changed = old.name != self.name
if form_data_changed or name_changed:
logger.info(f"[FormPerso] Modification du contenu du master '{self.name}' (id={self.pk})")
except RegistrationSchoolFileMaster.DoesNotExist:
pass
# Pour chaque RF concerné, régénérer les templates
for rf_id in affected_rf_ids:
try:
rf = RegistrationForm.objects.get(pk=rf_id)
logger.info(f"[FormPerso] Synchronisation template pour élève '{rf.student.last_name}_{rf.student.first_name}' (RF id={rf.pk}) suite à modification/ajout du master '{self.name}'")
create_templates_for_registration_form(rf)
except Exception as e:
logger.error(f"Erreur lors de la synchronisation des templates pour RF {rf_id} après modification du master {self.pk}: {e}")
except Exception as e:
logger.error(f"Erreur globale lors de la synchronisation des templates après modification du master {self.pk}: {e}")
def delete(self, *args, **kwargs):
logger.info(f"[FormPerso] Suppression master '{self.name}' (id={self.pk}) et tous ses templates")
# Import local pour éviter le circular import
from Subscriptions.models import RegistrationSchoolFileTemplate
templates = RegistrationSchoolFileTemplate.objects.filter(master=self)
for tmpl in templates:
logger.info(f"[FormPerso] Suppression template '{tmpl.name}' pour élève '{tmpl.registration_form.student.last_name}_{tmpl.registration_form.student.first_name}' (RF id={tmpl.registration_form.pk})")
if self.file and hasattr(self.file, 'path') and os.path.exists(self.file.path):
try:
self.file.delete(save=False)
except Exception as e:
logger.error(f"Erreur lors de la suppression du fichier master: {e}")
super().delete(*args, **kwargs)
####### Parent files masters (documents à fournir par les parents) #######
class RegistrationParentFileMaster(models.Model):
@ -337,10 +485,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"{instance.registration_form.establishment.name}/dossier_{instance.registration_form.student.last_name}_{instance.registration_form.student.first_name}/{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):

View File

@ -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
)

View File

@ -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,11 +103,13 @@ def create_templates_for_registration_form(register_form):
from Subscriptions.models import (
RegistrationSchoolFileMaster,
RegistrationSchoolFileTemplate,
# RegistrationParentFileMaster,
# RegistrationParentFileTemplate,
RegistrationParentFileMaster,
RegistrationParentFileTemplate,
registration_school_file_upload_to,
)
created = []
logger.info("util.create_templates_for_registration_form - create_templates_for_registration_form")
# Récupérer les masters du fileGroup courant
current_group = getattr(register_form, "fileGroup", None)
@ -114,25 +119,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,45 +152,119 @@ 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
# Créer les school templates manquants ou mettre à jour les existants si le master a changé
for m in school_masters:
exists = RegistrationSchoolFileTemplate.objects.filter(master=m, registration_form=register_form).exists()
if exists:
continue
tmpl_qs = RegistrationSchoolFileTemplate.objects.filter(master=m, registration_form=register_form)
tmpl = tmpl_qs.first() if tmpl_qs.exists() else None
base_slug = (m.name or "master").strip().replace(" ", "_")[:40]
slug = f"{base_slug}_{register_form.pk}_{m.pk}"
tmpl = RegistrationSchoolFileTemplate.objects.create(
file_name = None
if m.file and hasattr(m.file, 'name') and m.file.name:
file_name = os.path.basename(m.file.name)
elif m.file:
file_name = str(m.file)
else:
try:
pdf_file = generate_form_json_pdf(register_form, m.formMasterData)
file_name = os.path.basename(pdf_file.name)
except Exception as e:
logger.error(f"Erreur lors de la génération du PDF pour le template: {e}")
file_name = None
from django.core.files.base import ContentFile
upload_rel_path = registration_school_file_upload_to(
type("Tmp", (), {
"registration_form": register_form,
"establishment": getattr(register_form, "establishment", None),
"student": getattr(register_form, "student", None)
})(),
file_name
)
abs_path = os.path.join(settings.MEDIA_ROOT, upload_rel_path)
master_file_path = m.file.path if m.file and hasattr(m.file, 'path') else None
if tmpl:
template_file_name = os.path.basename(tmpl.file.name) if tmpl.file and tmpl.file.name else None
master_file_changed = template_file_name != file_name
# --- GESTION FORM EXISTANT : suppression ancien template si nom ou contenu master changé ---
if master_file_changed or (
master_file_path and os.path.exists(master_file_path) and
(not tmpl.file or not os.path.exists(abs_path) or os.path.getmtime(master_file_path) > os.path.getmtime(abs_path))
):
# Supprimer l'ancien fichier du template (même si le nom change)
if tmpl.file and tmpl.file.name:
old_template_path = os.path.join(settings.MEDIA_ROOT, tmpl.file.name)
if os.path.exists(old_template_path):
try:
os.remove(old_template_path)
logger.info(f"util.create_templates_for_registration_form - Suppression ancien fichier template: {old_template_path}")
except Exception as e:
logger.error(f"Erreur suppression ancien fichier template: {e}")
# Copier le nouveau fichier du master (form existant)
if master_file_path and os.path.exists(master_file_path):
try:
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
import shutil
shutil.copy2(master_file_path, abs_path)
logger.info(f"util.create_templates_for_registration_form - Copie du fichier master {master_file_path} -> {abs_path}")
tmpl.file.name = upload_rel_path
tmpl.name = m.name or ""
tmpl.slug = slug
tmpl.formTemplateData = m.formMasterData or []
tmpl.save()
except Exception as e:
logger.error(f"Erreur lors de la copie du fichier master pour mise à jour du template: {e}")
created.append(tmpl)
logger.info("util.create_templates_for_registration_form - Mise à jour school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
continue
# Sinon, création du template comme avant
tmpl = RegistrationSchoolFileTemplate(
master=m,
registration_form=register_form,
name=m.name or "",
formTemplateData=m.formMasterData or [],
slug=slug,
)
if file_name:
# Copier le fichier du master si besoin (form existant)
if master_file_path and not os.path.exists(abs_path):
try:
import shutil
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
shutil.copy2(master_file_path, abs_path)
logger.info(f"util.create_templates_for_registration_form - Copie du fichier master {master_file_path} -> {abs_path}")
except Exception as e:
logger.error(f"Erreur lors de la copie du fichier master pour le template: {e}")
tmpl.file.name = upload_rel_path
tmpl.save()
created.append(tmpl)
logger.info("Created school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
logger.info("util.create_templates_for_registration_form - 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 +457,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
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))

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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
@ -142,6 +139,7 @@ class RegistrationSchoolFileMasterSimpleView(APIView):
# Pour chaque RF appartenant aux groupes retirés -> nettoyer les templates (idempotent)
if removed_group_ids:
logger.info("REMOVE IDs")
try:
rfs_removed = RegistrationForm.objects.filter(fileGroup__in=list(removed_group_ids)).distinct()
for rf in rfs_removed:
@ -154,6 +152,7 @@ class RegistrationSchoolFileMasterSimpleView(APIView):
# Pour chaque RF appartenant aux groupes ajoutés -> créer les templates manquants
if added_group_ids:
logger.info("ADD IDs")
try:
rfs_added = RegistrationForm.objects.filter(fileGroup__in=list(added_group_ids)).distinct()
for rf in rfs_added:
@ -179,188 +178,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)

View File

@ -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:

View File

@ -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';

View File

@ -100,12 +100,12 @@ 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',
},
credentials: 'include',
})
@ -186,10 +186,9 @@ export const editRegistrationSchoolFileMaster = (fileId, data, csrfToken) => {
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${fileId}`,
{
method: 'PUT',
body: JSON.stringify(data),
body: data,
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
credentials: 'include',
}

View File

@ -37,6 +37,7 @@ import {
FileUp,
PenTool,
} from 'lucide-react';
import CheckBox from '@/components/Form/CheckBox';
const FIELD_TYPES_ICON = {
text: { icon: TextCursorInput },
@ -517,32 +518,30 @@ export default function FormTemplateBuilder({
{/* Sélecteur de groupes */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Groupes d'inscription{' '}
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">
<div className="flex flex-wrap gap-4 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={selectedGroups.includes(group.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedGroups([
...selectedGroups,
group.id,
]);
} else {
setSelectedGroups(
selectedGroups.filter((id) => id !== group.id)
);
}
}}
className="mr-2 text-blue-600"
/>
<span className="text-sm">{group.name}</span>
</label>
<CheckBox
key={group.id}
item={{ id: group.id }}
formData={{
groups: selectedGroups
}}
handleChange={() => {
let group_ids = selectedGroups;
if (group_ids.includes(group.id)) {
group_ids = group_ids.filter((id) => id !== group.id);
} else {
group_ids = [...group_ids, group.id];
}
setSelectedGroups(group_ids);
}}
fieldName="groups"
itemLabelFunc={() => group.name}
/>
))
) : (
<p className="text-gray-500 text-sm">

View File

@ -267,10 +267,10 @@ export default function DynamicFormsList({
<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'est pas encore configuré.
Ce formulaire n&apos;est pas encore configuré.
</p>
<p className="text-sm text-gray-500 mt-2">
Contactez l'administration pour plus d'informations.
Contactez l&apos;administration pour plus d&apos;informations.
</p>
</div>
)}
@ -285,7 +285,7 @@ export default function DynamicFormsList({
Tous les formulaires ont été complétés !
</h3>
<p className="text-gray-600">
Vous pouvez maintenant passer à l'étape suivante.
Vous pouvez maintenant passer à l&apos;étape suivante.
</p>
</div>
)}

View File

@ -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 (
<div className={`flex items-center justify-between border-b ${theme.border} ${theme.bg} px-2 py-3 mb-4 ${className}`}>
<div className="flex items-center gap-3">
{Icon && (
<span className={`${theme.iconBg} p-2 rounded-md flex items-center justify-center`}>
<Icon className={`w-6 h-6 ${theme.icon}`} />
</span>
)}
<div>
<h2 className={`text-lg font-semibold ${theme.title}`}>{title}</h2>
{description && (
<p className={`text-xs ${theme.desc}`}>{description}</p>
)}
</div>
</div>
{button && onClick && (
<button
onClick={onClick}
className={
buttonOpeningModal
? `flex items-center ${theme.button} px-3 py-1 rounded-md shadow transition`
: `flex items-center ${theme.buttonText} ${theme.buttonHover} px-2 py-1 rounded-md`
}
>
<Plus className="w-5 h-5 mr-1" />
<span className="text-sm font-medium">Ajouter</span>
</button>
)}
</div>
);
};
export default SectionHeaderDocument;

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

@ -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,

View File

@ -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 (
<form onSubmit={handleSubmit} className="space-y-4 py-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nom de la pièce <span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
required
onChange={e => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Dossiers d&apos;inscription <span className="text-red-500">*</span>
</label>
<select
multiple
value={selectedGroups}
onChange={e =>
setSelectedGroups(
Array.from(new Set(Array.from(e.target.selectedOptions, opt => Number(opt.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"
required
>
{groups.map(group => (
<option key={`group-option-${group.id}`} value={group.id}>
{group.name}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_required"
checked={isRequired}
onChange={e => setIsRequired(e.target.checked)}
className="mr-2"
/>
<label htmlFor="is_required" className="text-sm text-gray-700">
Obligatoire
</label>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
className="px-4 py-2 rounded-md bg-gray-200 text-gray-700 hover:bg-gray-300"
onClick={onCancel}
>
Annuler
</button>
<button
type="submit"
className="bg-orange-600 text-white px-4 py-2 rounded-md hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2"
disabled={!name || selectedGroups.length === 0}
>
{initialData?.id ? 'Modifier' : 'Créer'}
</button>
</div>
</form>
);
}
export default function ParentFiles({
parentFiles,
groups,
handleCreate,
handleEdit,
handleDelete,
singleForm = false,
initialData = null,
onCancel,
}) {
const [isModalOpen, setIsModalOpen] = useState(singleForm);
const [editingFile, setEditingFile] = useState(initialData);
useEffect(() => {
if (singleForm) {
setIsModalOpen(true);
setEditingFile(initialData);
}
}, [singleForm, initialData]);
const openCreateModal = () => {
setEditingFile(null);
setIsModalOpen(true);
};
const openEditModal = (file) => {
setEditingFile(file);
setIsModalOpen(true);
};
const closeModal = () => {
setEditingFile(null);
setIsModalOpen(false);
if (onCancel) onCancel();
};
const handleFormSubmit = (data) => {
logger.debug('[ParentFiles] handleFormSubmit data:', data);
if (editingFile && editingFile.id) {
logger.debug('[ParentFiles] handleEdit called with:', data.id, data);
handleEdit(data.id, data).then(closeModal);
} else {
logger.debug('[ParentFiles] handleCreate called with:', data);
handleCreate(data).then(closeModal);
}
};
if (singleForm) {
return (
<ParentFileForm
initialData={editingFile}
groups={groups}
onSubmit={handleFormSubmit}
onCancel={closeModal}
/>
);
}
return (
<div className="w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-orange-700">Pièces à fournir</h2>
<button
className="flex items-center gap-2 bg-orange-500 hover:bg-orange-600 text-white px-4 py-2 rounded shadow"
onClick={openCreateModal}
>
<Plus className="w-5 h-5" />
<span>Ajouter une pièce</span>
</button>
</div>
<table className="min-w-full border border-gray-200 rounded bg-white">
<thead>
<tr className="bg-orange-50">
<th className="px-3 py-2 text-left text-xs font-medium text-orange-700 border-b">Nom</th>
<th className="px-3 py-2 text-left text-xs font-medium text-orange-700 border-b">Description</th>
<th className="px-3 py-2 text-left text-xs font-medium text-orange-700 border-b">Dossiers</th>
<th className="px-3 py-2 text-center text-xs font-medium text-orange-700 border-b">Obligatoire</th>
<th className="px-3 py-2 text-center text-xs font-medium text-orange-700 border-b">Actions</th>
</tr>
</thead>
<tbody>
{parentFiles.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-6 text-gray-400">Aucune pièce à fournir</td>
</tr>
) : (
parentFiles.map((file) => (
<tr key={file.id} className="hover:bg-orange-50">
<td className="px-3 py-2 border-b">{file.name}</td>
<td className="px-3 py-2 border-b">{file.description}</td>
<td className="px-3 py-2 border-b">
{(file.groups || []).map(
gid => groups.find(g => g.id === gid)?.name || gid
).join(', ')}
</td>
<td className="px-3 py-2 border-b text-center">
{file.is_required ? (
<span className="bg-green-100 text-green-700 px-2 py-1 rounded text-xs font-semibold">Oui</span>
) : (
<span className="bg-gray-100 text-gray-600 px-2 py-1 rounded text-xs">Non</span>
)}
</td>
<td className="px-3 py-2 border-b text-center">
<button
className="text-blue-500 hover:text-blue-700 mr-2"
onClick={() => openEditModal(file)}
>
<Edit3 className="w-5 h-5" />
</button>
<button
className="text-red-500 hover:text-red-700"
onClick={() => handleDelete(file.id)}
>
<Trash2 className="w-5 h-5" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
<Modal
isOpen={isModalOpen}
setIsOpen={closeModal}
title={editingFile ? 'Modifier la pièce à fournir' : 'Créer une pièce à fournir'}
modalClassName="w-full max-w-md"
>
<ParentFileForm
initialData={editingFile}
groups={groups}
onSubmit={handleFormSubmit}
onCancel={closeModal}
/>
</Modal>
</div>
);
}

View File

@ -1,16 +1,13 @@
import React, { useState } from 'react';
import { Plus, Edit3, Trash2, Check, X, FileText } from 'lucide-react';
import Table from '@/components/Table';
import InputText from '@/components/Form/InputText';
import MultiSelect from '@/components/Form/MultiSelect';
import Popup from '@/components/Popup';
import logger from '@/utils/logger';
import { createRegistrationParentFileTemplate } from '@/app/actions/registerFileGroupAction';
import { useCsrfToken } from '@/context/CsrfContext';
import SectionHeader from '@/components/SectionHeader';
import ToggleSwitch from '@/components/Form/ToggleSwitch';
import { useNotification } from '@/context/NotificationContext';
import AlertMessage from '@/components/AlertMessage';
import Popup from '@/components/Popup';
import InputText from '@/components/Form/InputText';
import MultiSelect from '@/components/Form/MultiSelect';
import ToggleSwitch from '@/components/Form/ToggleSwitch';
export default function ParentFilesSection({
parentFiles,
@ -18,6 +15,11 @@ export default function ParentFilesSection({
handleCreate,
handleEdit,
handleDelete,
hideCreateButton = false,
tableContainerClass = '',
headerClassName = '',
TableComponent,
SectionHeaderComponent,
}) {
const [editingDocumentId, setEditingDocumentId] = useState(null);
const [formData, setFormData] = useState(null);
@ -325,27 +327,30 @@ export default function ParentFilesSection({
},
];
// Ajout : écouteur d'event global pour déclencher la création depuis la popup centrale
React.useEffect(() => {
if (!hideCreateButton) return;
const handler = () => handleAddEmptyRequiredDocument();
window.addEventListener('parentFilesSection:create', handler);
return () => window.removeEventListener('parentFilesSection:create', handler);
}, [hideCreateButton]);
const Table = TableComponent || ((props) => <div />); // fallback
const SectionHeader = SectionHeaderComponent || ((props) => <div />);
return (
<div className="mt-12 w-4/5">
<div className={`w-full h-full flex flex-col ${tableContainerClass}`}>
<SectionHeader
icon={FileText}
title="Pièces à fournir"
description="Configurez la liste des documents que les parents doivent fournir."
button={true}
onClick={handleAddEmptyRequiredDocument}
className={headerClassName}
/>
<Table
data={
editingDocumentId === 'new' ? [formData, ...parentFiles] : parentFiles
}
columns={columnsRequiredDocuments}
emptyMessage={
<AlertMessage
type="warning"
title="Aucune pièce à fournir enregistrée"
message="Veuillez procéder à la création de nouvelles pièces à fournir par les parents"
/>
}
emptyMessage="Aucune pièce à fournir enregistrée"
/>
<Popup
isOpen={removePopupVisible}

View File

@ -1,4 +1,6 @@
import React, { useState, useEffect } from 'react';
import InputText from '@/components/Form/InputText';
import Button from '@/components/Form/Button';
export default function RegistrationFileGroupForm({ onSubmit, initialData }) {
const [name, setName] = useState('');
@ -18,38 +20,28 @@ export default function RegistrationFileGroupForm({ onSubmit, initialData }) {
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nom du groupe
</label>
<input
type="text"
value={name}
onChange={(e) => 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-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* Utilisation de InputText pour le nom du groupe */}
<InputText
label="Nom du groupe"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<InputText
label="Description"
name="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
required
/>
<div className="flex justify-end">
<button
<Button
primary
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{initialData ? 'Modifier le groupe' : 'Créer le groupe'}
</button>
text="Enregistrer"
/>
</div>
</form>
);

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "n3wt-school",
"version": "0.0.1",
"version": "0.0.3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "n3wt-school",
"version": "0.0.1",
"version": "0.0.3",
"devDependencies": {
"@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0",