21 Commits

Author SHA1 Message Date
7464b19de5 Merge pull request 'NEWTS-9-Fusion_Liste_frais' (!73) from NEWTS-9-Fusion_Liste_frais into develop
Reviewed-on: https://git.v0id.ovh/n3wt-innov/n3wt-school/pulls/73
2026-03-15 11:11:34 +00:00
c96b9562a2 chore(ci): ajout test_settings.py et SKILL run-tests 2026-03-15 12:09:31 +01:00
7576b5a68c test(frontend): ajout tests unitaires Jest composants frais [#NEWTS-9] 2026-03-15 12:09:18 +01:00
e30a41a58b feat(frontend): fusion liste des frais et message compte existant [#NEWTS-9] 2026-03-15 12:09:02 +01:00
c296af2c07 Merge pull request 'NEWTS-12 : Sécurisation Backend' (!72) from NEWTS12-Securisation_Backend into develop
Reviewed-on: https://git.v0id.ovh/n3wt-innov/n3wt-school/pulls/72
2026-03-15 09:11:16 +00:00
fa843097ba feat: Securisation du Backend 2026-03-15 10:07:20 +01:00
2fef6d61a4 feat: Gestion du refus définitif d'un dossier [N3WTS-2] 2026-03-14 11:35:19 +01:00
0501c1dd73 feat: Finalisation de la validation / refus des documents signés par les parents [N3WTS-2] 2026-03-14 11:26:20 +01:00
4f7d7d0024 feat: Gestion de l'affichage des documents validés et non validés sur la page parent [N3WTS-2] 2026-02-22 18:34:00 +01:00
8fd1b62ec0 feat: Validation document par document [N3WTS-2] 2026-02-19 18:53:33 +01:00
3779a47417 feat: Ajout bouton de refus de dossier avec zone de saisie de motif [N3WTS-2] 2026-02-16 17:54:20 +01:00
05c68ebfaa feat: Page Structure : suppression de la possibilité de faire des actions d'admin [N3WTS-8] 2026-02-15 18:40:14 +01:00
195579e217 feat: Page Inscriptions : suppression de la possibilité de créer un nouveau DI [N3WTS-8] 2026-02-15 18:08:07 +01:00
ddcaba382e feat: Gestion de la sidebar [N3WTS-8] 2026-02-15 18:02:57 +01:00
a82483f3bd chore: Suppression code mort [N3WTS-8] 2026-02-15 17:57:48 +01:00
26d4b5633f fix: Changement des niveaux de logs [N3WTS-1] 2026-02-15 17:39:19 +01:00
d66db1b019 feat: Envoi mail d'inscription au second responsable [N3WTS-1] 2026-02-15 17:36:55 +01:00
bd7dc2b0c2 feat: Envoi mail d'inscription aux enseignants [N3WTS-1] 2026-02-15 16:37:43 +01:00
176edc5c45 fix: Edition d'un teacher, champ email désactivé [N3WTS-1] 2026-02-15 15:47:51 +01:00
92c6a31740 fix: Suppression d'un PROFILE si aucun PROFILE_ROLE n'y est associé [N3WTS-1] 2026-02-14 17:58:47 +01:00
9dff32b388 feat: WIP finalisation partie signature des parents [N3WTS-17] 2026-02-13 17:06:21 +01:00
98 changed files with 5980 additions and 2407 deletions

View File

@ -56,3 +56,4 @@ Pour le front-end, les exigences de qualité sont les suivantes :
- **Tickets** : [issues guidelines](./instructions/issues.instruction.md) - **Tickets** : [issues guidelines](./instructions/issues.instruction.md)
- **Commits** : [commit guidelines](./instructions/general-commit.instruction.md) - **Commits** : [commit guidelines](./instructions/general-commit.instruction.md)
- **Tests** : [run tests](./instructions/run-tests.instruction.md)

View File

@ -0,0 +1,53 @@
---
applyTo: "**"
---
# Lancer les tests N3WT-SCHOOL
## Tests backend (Django)
Les tests backend tournent dans le conteneur Docker. Toujours utiliser `--settings=N3wtSchool.test_settings`.
```powershell
# Tous les tests
docker exec -it n3wt-school-backend-1 python3 manage.py test --settings=N3wtSchool.test_settings --verbosity=2
# Un module spécifique
docker exec -it n3wt-school-backend-1 python3 manage.py test --settings=N3wtSchool.test_settings Auth.tests --verbosity=2
```
### Points importants
- Le fichier `Back-End/N3wtSchool/test_settings.py` configure l'environnement de test :
- Base PostgreSQL dédiée `school_test` (SQLite incompatible avec `ArrayField`)
- Cache en mémoire locale (pas de Redis)
- Channels en mémoire (`InMemoryChannelLayer`)
- Throttling désactivé
- Hashage MD5 (plus rapide)
- Email en mode `locmem`
- Si le conteneur n'est pas démarré : `docker compose up -d` depuis la racine du projet
- Les logs `WARNING` dans la sortie des tests sont normaux (endpoints qui retournent 400/401 intentionnellement)
## Tests frontend (Jest)
```powershell
# Depuis le dossier Front-End
cd Front-End
npm test -- --watchAll=false
# Avec couverture
npm test -- --watchAll=false --coverage
```
### Points importants
- Les tests sont dans `Front-End/src/test/`
- Les warnings `ReactDOMTestUtils.act is deprecated` sont non bloquants (dépendance `@testing-library/react`)
- Config Jest : `Front-End/jest.config.js`
## Résultats attendus
| Périmètre | Nb tests | Statut |
| -------------- | -------- | ------ |
| Backend Django | 121 | ✅ OK |
| Frontend Jest | 24 | ✅ OK |

3
.gitignore vendored
View File

@ -2,4 +2,5 @@
.env .env
node_modules/ node_modules/
hardcoded-strings-report.md hardcoded-strings-report.md
backend.env backend.env
*.log

View File

@ -2,6 +2,12 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
from Auth.models import Profile from Auth.models import Profile
from N3wtSchool import bdd from N3wtSchool import bdd
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.exceptions import TokenError, InvalidToken
import logging
logger = logging.getLogger("Auth")
class EmailBackend(ModelBackend): class EmailBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs): def authenticate(self, request, username=None, password=None, **kwargs):
@ -18,3 +24,45 @@ class EmailBackend(ModelBackend):
except Profile.DoesNotExist: except Profile.DoesNotExist:
return None return None
class LoggingJWTAuthentication(JWTAuthentication):
"""
Surclasse JWTAuthentication pour loguer pourquoi un token Bearer est rejeté.
Cela aide à diagnostiquer les 401 sans avoir à ajouter des prints partout.
"""
def authenticate(self, request):
header = self.get_header(request)
if header is None:
logger.debug("JWT: pas de header Authorization dans la requête %s %s",
request.method, request.path)
return None
raw_token = self.get_raw_token(header)
if raw_token is None:
logger.debug("JWT: header Authorization présent mais token vide pour %s %s",
request.method, request.path)
return None
try:
validated_token = self.get_validated_token(raw_token)
except InvalidToken as e:
logger.warning(
"JWT: token invalide pour %s %s%s",
request.method, request.path, str(e)
)
raise
try:
user = self.get_user(validated_token)
except Exception as e:
logger.warning(
"JWT: utilisateur introuvable pour %s %s%s",
request.method, request.path, str(e)
)
raise
logger.debug("JWT: authentification réussie user_id=%s pour %s %s",
user.pk, request.method, request.path)
return user, validated_token

View File

@ -1,4 +1,4 @@
# Generated by Django 5.1.3 on 2025-11-30 11:02 # Generated by Django 5.1.3 on 2026-03-14 13:23
import django.contrib.auth.models import django.contrib.auth.models
import django.contrib.auth.validators import django.contrib.auth.validators
@ -24,7 +24,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role_type', models.IntegerField(choices=[(-1, 'NON DEFINI'), (0, 'ECOLE'), (1, 'ADMIN'), (2, 'PARENT')], default=-1)), ('role_type', models.IntegerField(choices=[(-1, 'NON DEFINI'), (0, 'ECOLE'), (1, 'ADMIN'), (2, 'PARENT')], default=-1)),
('is_active', models.BooleanField(default=False)), ('is_active', models.BooleanField(blank=True, default=False)),
('updated_date', models.DateTimeField(auto_now=True)), ('updated_date', models.DateTimeField(auto_now=True)),
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='profile_roles', to='Establishment.establishment')), ('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='profile_roles', to='Establishment.establishment')),
], ],

View File

@ -25,7 +25,7 @@ class ProfileRole(models.Model):
profile = models.ForeignKey('Profile', on_delete=models.CASCADE, related_name='roles') profile = models.ForeignKey('Profile', on_delete=models.CASCADE, related_name='roles')
role_type = models.IntegerField(choices=RoleType.choices, default=RoleType.PROFIL_UNDEFINED) role_type = models.IntegerField(choices=RoleType.choices, default=RoleType.PROFIL_UNDEFINED)
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='profile_roles') establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='profile_roles')
is_active = models.BooleanField(default=False) is_active = models.BooleanField(default=False, blank=True)
updated_date = models.DateTimeField(auto_now=True) updated_date = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):

553
Back-End/Auth/tests.py Normal file
View File

@ -0,0 +1,553 @@
"""
Tests unitaires pour le module Auth.
Vérifie :
- L'accès public aux endpoints de login/CSRF/subscribe
- La protection JWT des endpoints protégés (profils, rôles, session)
- La génération et validation des tokens JWT
"""
import json
from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from Auth.models import Profile, ProfileRole
from Establishment.models import Establishment
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def create_establishment():
"""Crée un établissement minimal utilisé dans les tests."""
return Establishment.objects.create(
name="Ecole Test",
address="1 rue de l'Ecole",
total_capacity=100,
establishment_type=[1],
)
def create_user(email="test@example.com", password="testpassword123"):
"""Crée un utilisateur (Profile) de test."""
user = Profile.objects.create_user(
username=email,
email=email,
password=password,
)
return user
def create_active_user_with_role(email="active@example.com", password="testpassword123"):
"""Crée un utilisateur avec un rôle actif."""
user = create_user(email=email, password=password)
establishment = create_establishment()
ProfileRole.objects.create(
profile=user,
role_type=ProfileRole.RoleType.PROFIL_ADMIN,
establishment=establishment,
is_active=True,
)
return user
def get_jwt_token(user):
"""Retourne un token d'accès JWT pour l'utilisateur donné."""
refresh = RefreshToken.for_user(user)
return str(refresh.access_token)
# ---------------------------------------------------------------------------
# Tests endpoints publics
# ---------------------------------------------------------------------------
@override_settings(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
class CsrfEndpointTest(TestCase):
"""Test de l'endpoint CSRF doit être accessible sans authentification."""
def setUp(self):
self.client = APIClient()
def test_csrf_endpoint_accessible_sans_auth(self):
"""GET /Auth/csrf doit retourner 200 sans token."""
response = self.client.get(reverse("Auth:csrf"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("csrfToken", response.json())
@override_settings(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
class LoginEndpointTest(TestCase):
"""Tests de l'endpoint de connexion."""
def setUp(self):
self.client = APIClient()
self.url = reverse("Auth:login")
self.user = create_active_user_with_role(
email="logintest@example.com", password="secureP@ss1"
)
def test_login_avec_identifiants_valides(self):
"""POST /Auth/login avec identifiants valides retourne 200 et un token."""
payload = {"email": "logintest@example.com", "password": "secureP@ss1"}
response = self.client.post(
self.url, data=json.dumps(payload), content_type="application/json"
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertIn("token", data)
self.assertIn("refresh", data)
def test_login_avec_mauvais_mot_de_passe(self):
"""POST /Auth/login avec mauvais mot de passe retourne 400 ou 401."""
payload = {"email": "logintest@example.com", "password": "wrongpassword"}
response = self.client.post(
self.url, data=json.dumps(payload), content_type="application/json"
)
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
def test_login_avec_email_inexistant(self):
"""POST /Auth/login avec email inconnu retourne 400 ou 401."""
payload = {"email": "unknown@example.com", "password": "anypassword"}
response = self.client.post(
self.url, data=json.dumps(payload), content_type="application/json"
)
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
def test_login_accessible_sans_authentification(self):
"""L'endpoint de login doit être accessible sans token JWT."""
# On vérifie juste que l'on n'obtient pas 401/403 pour raison d'auth manquante
payload = {"email": "logintest@example.com", "password": "secureP@ss1"}
response = self.client.post(
self.url, data=json.dumps(payload), content_type="application/json"
)
self.assertNotEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN)
@override_settings(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
class RefreshJWTEndpointTest(TestCase):
"""Tests de l'endpoint de rafraîchissement du token JWT."""
def setUp(self):
self.client = APIClient()
self.url = reverse("Auth:refresh_jwt")
self.user = create_active_user_with_role(email="refresh@example.com")
def test_refresh_avec_token_valide(self):
"""POST /Auth/refreshJWT avec refresh token valide retourne un nouvel access token."""
import jwt, uuid
from datetime import datetime, timedelta
from django.conf import settings as django_settings
# RefreshJWTView attend le format custom (type='refresh'), pas le format SimpleJWT
refresh_payload = {
'user_id': self.user.id,
'type': 'refresh',
'jti': str(uuid.uuid4()),
'exp': datetime.utcnow() + timedelta(days=1),
'iat': datetime.utcnow(),
}
custom_refresh = jwt.encode(
refresh_payload,
django_settings.SIMPLE_JWT['SIGNING_KEY'],
algorithm=django_settings.SIMPLE_JWT['ALGORITHM'],
)
payload = {"refresh": custom_refresh}
response = self.client.post(
self.url, data=json.dumps(payload), content_type="application/json"
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("token", response.json())
def test_refresh_avec_token_invalide(self):
"""POST /Auth/refreshJWT avec token invalide retourne 401."""
payload = {"refresh": "invalid.token.here"}
response = self.client.post(
self.url, data=json.dumps(payload), content_type="application/json"
)
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
def test_refresh_accessible_sans_authentification(self):
"""L'endpoint de refresh doit être accessible sans token d'accès."""
refresh = RefreshToken.for_user(self.user)
payload = {"refresh": str(refresh)}
response = self.client.post(
self.url, data=json.dumps(payload), content_type="application/json"
)
self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN)
# ---------------------------------------------------------------------------
# Tests endpoints protégés Session
# ---------------------------------------------------------------------------
@override_settings(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
class SessionEndpointTest(TestCase):
"""Tests de l'endpoint d'information de session."""
def setUp(self):
self.client = APIClient()
self.url = reverse("Auth:infoSession")
self.user = create_active_user_with_role(email="session@example.com")
def test_info_session_sans_token_retourne_401(self):
"""GET /Auth/infoSession sans token doit retourner 401."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_info_session_avec_token_valide_retourne_200(self):
"""GET /Auth/infoSession avec token valide doit retourner 200 et les données utilisateur."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertIn("user", data)
self.assertEqual(data["user"]["email"], self.user.email)
def test_info_session_avec_token_invalide_retourne_401(self):
"""GET /Auth/infoSession avec token invalide doit retourner 401."""
self.client.credentials(HTTP_AUTHORIZATION="Bearer token.invalide.xyz")
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_info_session_avec_token_expire_retourne_401(self):
"""GET /Auth/infoSession avec un token expiré doit retourner 401."""
import jwt
from datetime import datetime, timedelta
from django.conf import settings as django_settings
expired_payload = {
'user_id': self.user.id,
'exp': datetime.utcnow() - timedelta(hours=1),
}
expired_token = jwt.encode(
expired_payload, django_settings.SECRET_KEY, algorithm='HS256'
)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {expired_token}")
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
# ---------------------------------------------------------------------------
# Tests endpoints protégés Profils
# ---------------------------------------------------------------------------
@override_settings(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
REST_FRAMEWORK={
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
},
)
class ProfileEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints de profils."""
def setUp(self):
self.client = APIClient()
self.profiles_url = reverse("Auth:profile")
self.user = create_active_user_with_role(email="profile_auth@example.com")
def test_get_profiles_sans_auth_retourne_401(self):
"""GET /Auth/profiles sans token doit retourner 401."""
response = self.client.get(self.profiles_url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_profiles_avec_auth_retourne_200(self):
"""GET /Auth/profiles avec token valide doit retourner 200."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = self.client.get(self.profiles_url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_post_profile_sans_auth_retourne_401(self):
"""POST /Auth/profiles sans token doit retourner 401."""
payload = {"email": "new@example.com", "password": "pass123"}
response = self.client.post(
self.profiles_url,
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_profile_par_id_sans_auth_retourne_401(self):
"""GET /Auth/profiles/{id} sans token doit retourner 401."""
url = reverse("Auth:profile", kwargs={"id": self.user.id})
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_put_profile_sans_auth_retourne_401(self):
"""PUT /Auth/profiles/{id} sans token doit retourner 401."""
url = reverse("Auth:profile", kwargs={"id": self.user.id})
payload = {"email": self.user.email}
response = self.client.put(
url, data=json.dumps(payload), content_type="application/json"
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_delete_profile_sans_auth_retourne_401(self):
"""DELETE /Auth/profiles/{id} sans token doit retourner 401."""
url = reverse("Auth:profile", kwargs={"id": self.user.id})
response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
# ---------------------------------------------------------------------------
# Tests endpoints protégés ProfileRole
# ---------------------------------------------------------------------------
@override_settings(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
REST_FRAMEWORK={
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
},
)
class ProfileRoleEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints de rôles."""
def setUp(self):
self.client = APIClient()
self.profile_roles_url = reverse("Auth:profileRoles")
self.user = create_active_user_with_role(email="roles_auth@example.com")
def test_get_profile_roles_sans_auth_retourne_401(self):
"""GET /Auth/profileRoles sans token doit retourner 401."""
response = self.client.get(self.profile_roles_url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_profile_roles_avec_auth_retourne_200(self):
"""GET /Auth/profileRoles avec token valide doit retourner 200."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = self.client.get(self.profile_roles_url)
self.assertNotIn(
response.status_code,
[status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN],
msg="Un token valide ne doit pas être rejeté par la couche d'authentification",
)
def test_post_profile_role_sans_auth_retourne_401(self):
"""POST /Auth/profileRoles sans token doit retourner 401."""
payload = {"profile": self.user.id, "role_type": 1}
response = self.client.post(
self.profile_roles_url,
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
# ---------------------------------------------------------------------------
# Tests de génération de token JWT
# ---------------------------------------------------------------------------
@override_settings(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
class JWTTokenGenerationTest(TestCase):
"""Tests de génération et validation des tokens JWT."""
def setUp(self):
self.user = create_user(email="jwt@example.com", password="jwttest123")
def test_generation_token_valide(self):
"""Un token généré pour un utilisateur est valide et contient user_id."""
import jwt
from django.conf import settings as django_settings
token = get_jwt_token(self.user)
self.assertIsNotNone(token)
self.assertIsInstance(token, str)
decoded = jwt.decode(token, django_settings.SECRET_KEY, algorithms=["HS256"])
self.assertEqual(decoded["user_id"], self.user.id)
def test_refresh_token_permet_obtenir_nouvel_access_token(self):
"""Le refresh token permet d'obtenir un nouvel access token via SimpleJWT."""
refresh = RefreshToken.for_user(self.user)
access = refresh.access_token
self.assertIsNotNone(str(access))
self.assertIsNotNone(str(refresh))
def test_token_different_par_utilisateur(self):
"""Deux utilisateurs différents ont des tokens différents."""
user2 = create_user(email="jwt2@example.com", password="jwttest123")
token1 = get_jwt_token(self.user)
token2 = get_jwt_token(user2)
self.assertNotEqual(token1, token2)
# ---------------------------------------------------------------------------
# Tests de sécurité — Correction des vulnérabilités identifiées
# ---------------------------------------------------------------------------
@override_settings(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
class SessionViewTokenTypeTest(TestCase):
"""
SessionView doit rejeter les refresh tokens.
Avant la correction, jwt.decode() était appelé sans vérification du claim 'type',
ce qui permettait d'utiliser un refresh token là où seul un access token est attendu.
"""
def setUp(self):
self.client = APIClient()
self.url = reverse("Auth:infoSession")
self.user = create_active_user_with_role(email="session_type@example.com")
def test_refresh_token_rejete_par_session_view(self):
"""
Utiliser un refresh token SimpleJWT sur /infoSession doit retourner 401.
"""
import jwt
from datetime import datetime, timedelta
from django.conf import settings as django_settings
# Fabriquer manuellement un token de type 'refresh' signé avec la clé correcte
refresh_payload = {
'user_id': self.user.id,
'type': 'refresh', # ← type incorrect pour cet endpoint
'jti': 'test-refresh-jti',
'exp': datetime.utcnow() + timedelta(days=1),
'iat': datetime.utcnow(),
}
refresh_token = jwt.encode(
refresh_payload, django_settings.SECRET_KEY, algorithm='HS256'
)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {refresh_token}")
response = self.client.get(self.url)
self.assertEqual(
response.status_code, status.HTTP_401_UNAUTHORIZED,
"Un refresh token ne doit pas être accepté sur /infoSession (OWASP A07 - Auth Failures)"
)
def test_access_token_accepte_par_session_view(self):
"""Un access token de type 'access' est accepté."""
import jwt
from datetime import datetime, timedelta
from django.conf import settings as django_settings
access_payload = {
'user_id': self.user.id,
'type': 'access',
'jti': 'test-access-jti',
'exp': datetime.utcnow() + timedelta(minutes=15),
'iat': datetime.utcnow(),
}
access_token = jwt.encode(
access_payload, django_settings.SECRET_KEY, algorithm='HS256'
)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}")
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@override_settings(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
class RefreshJWTErrorLeakTest(TestCase):
"""
RefreshJWTView ne doit pas retourner les messages d'exception internes.
Avant la correction, str(e) était renvoyé directement au client.
"""
def setUp(self):
self.client = APIClient()
self.url = reverse("Auth:refresh_jwt")
def test_token_invalide_ne_revele_pas_details_internes(self):
"""
Un token invalide doit retourner un message générique, pas les détails de l'exception.
"""
payload = {"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.forged.signature"}
response = self.client.post(
self.url, data=json.dumps(payload), content_type="application/json"
)
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
body = response.content.decode()
# Le message ne doit pas contenir de traceback ou de détails internes de bibliothèque
self.assertNotIn("Traceback", body)
self.assertNotIn("jwt.exceptions", body)
self.assertNotIn("simplejwt", body.lower())
def test_erreur_reponse_est_generique(self):
"""
Le message d'erreur doit être 'Token invalide' (générique), pas le str(e).
"""
payload = {"refresh": "bad.token.data"}
response = self.client.post(
self.url, data=json.dumps(payload), content_type="application/json"
)
data = response.json()
self.assertIn('errorMessage', data)
# Le message doit être le message générique, pas la chaîne brute de l'exception
self.assertIn(data['errorMessage'], ['Token invalide', 'Format de token invalide',
'Refresh token expiré', 'Erreur interne du serveur'])
@override_settings(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
class SecurityHeadersTest(TestCase):
"""
Les en-têtes de sécurité HTTP doivent être présents dans toutes les réponses.
"""
def setUp(self):
self.client = APIClient()
def test_x_content_type_options_present(self):
"""X-Content-Type-Options: nosniff doit être présent."""
response = self.client.get(reverse("Auth:csrf"))
self.assertEqual(
response.get('X-Content-Type-Options'), 'nosniff',
"X-Content-Type-Options: nosniff doit être défini (prévient le MIME sniffing)"
)
def test_referrer_policy_present(self):
"""Referrer-Policy doit être présent."""
response = self.client.get(reverse("Auth:csrf"))
self.assertIsNotNone(
response.get('Referrer-Policy'),
"Referrer-Policy doit être défini"
)
def test_csp_frame_ancestors_present(self):
"""Content-Security-Policy doit contenir frame-ancestors."""
response = self.client.get(reverse("Auth:csrf"))
csp = response.get('Content-Security-Policy', '')
self.assertIn('frame-ancestors', csp,
"CSP doit définir frame-ancestors (protection clickjacking)")
self.assertIn("object-src 'none'", csp,
"CSP doit définir object-src 'none' (prévient les plugins malveillants)")

View File

@ -17,10 +17,12 @@ from datetime import datetime, timedelta
import jwt import jwt
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
import json import json
import uuid
from . import validator from . import validator
from .models import Profile, ProfileRole from .models import Profile, ProfileRole
from rest_framework.decorators import action, api_view from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.permissions import IsAuthenticated, AllowAny
from django.db.models import Q from django.db.models import Q
from Auth.serializers import ProfileSerializer, ProfileRoleSerializer from Auth.serializers import ProfileSerializer, ProfileRoleSerializer
@ -28,13 +30,28 @@ from Subscriptions.models import RegistrationForm, Guardian
import N3wtSchool.mailManager as mailer import N3wtSchool.mailManager as mailer
import Subscriptions.util as util import Subscriptions.util as util
import logging import logging
from N3wtSchool import bdd, error, settings from N3wtSchool import bdd, error
from rest_framework.throttling import AnonRateThrottle
from rest_framework_simplejwt.authentication import JWTAuthentication from rest_framework_simplejwt.authentication import JWTAuthentication
logger = logging.getLogger("AuthViews") logger = logging.getLogger("AuthViews")
class LoginRateThrottle(AnonRateThrottle):
"""Limite les tentatives de connexion à 10/min par IP.
Configurable via DEFAULT_THROTTLE_RATES['login'] dans settings.
"""
scope = 'login'
def get_rate(self):
try:
return super().get_rate()
except Exception:
# Fallback si le scope 'login' n'est pas configuré dans les settings
return '10/min'
@swagger_auto_schema( @swagger_auto_schema(
method='get', method='get',
operation_description="Obtenir un token CSRF", operation_description="Obtenir un token CSRF",
@ -43,11 +60,15 @@ logger = logging.getLogger("AuthViews")
}))} }))}
) )
@api_view(['GET']) @api_view(['GET'])
@permission_classes([AllowAny])
def csrf(request): def csrf(request):
token = get_token(request) token = get_token(request)
return JsonResponse({'csrfToken': token}) return JsonResponse({'csrfToken': token})
class SessionView(APIView): class SessionView(APIView):
permission_classes = [AllowAny]
authentication_classes = [] # SessionView gère sa propre validation JWT
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Vérifier une session utilisateur", operation_description="Vérifier une session utilisateur",
manual_parameters=[openapi.Parameter('Authorization', openapi.IN_HEADER, type=openapi.TYPE_STRING, description='Bearer token')], manual_parameters=[openapi.Parameter('Authorization', openapi.IN_HEADER, type=openapi.TYPE_STRING, description='Bearer token')],
@ -70,6 +91,11 @@ class SessionView(APIView):
token = request.META.get('HTTP_AUTHORIZATION', '').split('Bearer ')[-1] token = request.META.get('HTTP_AUTHORIZATION', '').split('Bearer ')[-1]
try: try:
decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256']) decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
# Refuser les refresh tokens : seul le type 'access' est autorisé
# Accepter 'type' (format custom) ET 'token_type' (format SimpleJWT)
token_type_claim = decoded_token.get('type') or decoded_token.get('token_type')
if token_type_claim != 'access':
return JsonResponse({"error": "Invalid token"}, status=status.HTTP_401_UNAUTHORIZED)
userid = decoded_token.get('user_id') userid = decoded_token.get('user_id')
user = Profile.objects.get(id=userid) user = Profile.objects.get(id=userid)
roles = ProfileRole.objects.filter(profile=user).values('role_type', 'establishment__name') roles = ProfileRole.objects.filter(profile=user).values('role_type', 'establishment__name')
@ -88,6 +114,8 @@ class SessionView(APIView):
return JsonResponse({"error": "Invalid token"}, status=status.HTTP_401_UNAUTHORIZED) return JsonResponse({"error": "Invalid token"}, status=status.HTTP_401_UNAUTHORIZED)
class ProfileView(APIView): class ProfileView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Obtenir la liste des profils", operation_description="Obtenir la liste des profils",
responses={200: ProfileSerializer(many=True)} responses={200: ProfileSerializer(many=True)}
@ -118,6 +146,8 @@ class ProfileView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class ProfileSimpleView(APIView): class ProfileSimpleView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Obtenir un profil par son ID", operation_description="Obtenir un profil par son ID",
responses={200: ProfileSerializer} responses={200: ProfileSerializer}
@ -152,8 +182,12 @@ class ProfileSimpleView(APIView):
def delete(self, request, id): def delete(self, request, id):
return bdd.delete_object(Profile, id) return bdd.delete_object(Profile, id)
@method_decorator(csrf_exempt, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class LoginView(APIView): class LoginView(APIView):
permission_classes = [AllowAny]
throttle_classes = [LoginRateThrottle]
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Connexion utilisateur", operation_description="Connexion utilisateur",
request_body=openapi.Schema( request_body=openapi.Schema(
@ -240,12 +274,14 @@ def makeToken(user):
}) })
# Générer le JWT avec la bonne syntaxe datetime # Générer le JWT avec la bonne syntaxe datetime
# jti (JWT ID) est obligatoire : SimpleJWT le vérifie via AccessToken.verify_token_id()
access_payload = { access_payload = {
'user_id': user.id, 'user_id': user.id,
'email': user.email, 'email': user.email,
'roleIndexLoginDefault': user.roleIndexLoginDefault, 'roleIndexLoginDefault': user.roleIndexLoginDefault,
'roles': roles, 'roles': roles,
'type': 'access', 'type': 'access',
'jti': str(uuid.uuid4()),
'exp': datetime.utcnow() + settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'], 'exp': datetime.utcnow() + settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'],
'iat': datetime.utcnow(), 'iat': datetime.utcnow(),
} }
@ -255,16 +291,23 @@ def makeToken(user):
refresh_payload = { refresh_payload = {
'user_id': user.id, 'user_id': user.id,
'type': 'refresh', 'type': 'refresh',
'jti': str(uuid.uuid4()),
'exp': datetime.utcnow() + settings.SIMPLE_JWT['REFRESH_TOKEN_LIFETIME'], 'exp': datetime.utcnow() + settings.SIMPLE_JWT['REFRESH_TOKEN_LIFETIME'],
'iat': datetime.utcnow(), 'iat': datetime.utcnow(),
} }
refresh_token = jwt.encode(refresh_payload, settings.SIMPLE_JWT['SIGNING_KEY'], algorithm=settings.SIMPLE_JWT['ALGORITHM']) refresh_token = jwt.encode(refresh_payload, settings.SIMPLE_JWT['SIGNING_KEY'], algorithm=settings.SIMPLE_JWT['ALGORITHM'])
return access_token, refresh_token return access_token, refresh_token
except Exception as e: except Exception as e:
logger.error(f"Erreur lors de la création du token: {str(e)}") logger.error(f"Erreur lors de la création du token: {str(e)}", exc_info=True)
return None # On lève l'exception pour que l'appelant (LoginView / RefreshJWTView)
# retourne une erreur HTTP 500 explicite plutôt que de crasher silencieusement
# sur le unpack d'un None.
raise
class RefreshJWTView(APIView): class RefreshJWTView(APIView):
permission_classes = [AllowAny]
throttle_classes = [LoginRateThrottle]
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Rafraîchir le token d'accès", operation_description="Rafraîchir le token d'accès",
request_body=openapi.Schema( request_body=openapi.Schema(
@ -290,7 +333,6 @@ class RefreshJWTView(APIView):
)) ))
} }
) )
@method_decorator(csrf_exempt, name='dispatch')
def post(self, request): def post(self, request):
data = JSONParser().parse(request) data = JSONParser().parse(request)
refresh_token = data.get("refresh") refresh_token = data.get("refresh")
@ -335,14 +377,16 @@ class RefreshJWTView(APIView):
return JsonResponse({'errorMessage': 'Refresh token expiré'}, status=400) return JsonResponse({'errorMessage': 'Refresh token expiré'}, status=400)
except InvalidTokenError as e: except InvalidTokenError as e:
logger.error(f"Token invalide: {str(e)}") logger.error(f"Token invalide: {str(e)}")
return JsonResponse({'errorMessage': f'Token invalide: {str(e)}'}, status=400) return JsonResponse({'errorMessage': 'Token invalide'}, status=400)
except Exception as e: except Exception as e:
logger.error(f"Erreur inattendue: {str(e)}") logger.error(f"Erreur inattendue: {str(e)}", exc_info=True)
return JsonResponse({'errorMessage': f'Erreur inattendue: {str(e)}'}, status=400) return JsonResponse({'errorMessage': 'Erreur interne du serveur'}, status=500)
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class SubscribeView(APIView): class SubscribeView(APIView):
permission_classes = [AllowAny]
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Inscription utilisateur", operation_description="Inscription utilisateur",
manual_parameters=[ manual_parameters=[
@ -430,6 +474,8 @@ class SubscribeView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class NewPasswordView(APIView): class NewPasswordView(APIView):
permission_classes = [AllowAny]
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Demande de nouveau mot de passe", operation_description="Demande de nouveau mot de passe",
request_body=openapi.Schema( request_body=openapi.Schema(
@ -479,6 +525,8 @@ class NewPasswordView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class ResetPasswordView(APIView): class ResetPasswordView(APIView):
permission_classes = [AllowAny]
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Réinitialisation du mot de passe", operation_description="Réinitialisation du mot de passe",
request_body=openapi.Schema( request_body=openapi.Schema(
@ -525,7 +573,9 @@ class ResetPasswordView(APIView):
return JsonResponse({'message': retour, "errorMessage": retourErreur, "errorFields": errorFields}, safe=False) return JsonResponse({'message': retour, "errorMessage": retourErreur, "errorFields": errorFields}, safe=False)
class ProfileRoleView(APIView): class ProfileRoleView(APIView):
permission_classes = [IsAuthenticated]
pagination_class = CustomProfilesPagination pagination_class = CustomProfilesPagination
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Obtenir la liste des profile_roles", operation_description="Obtenir la liste des profile_roles",
responses={200: ProfileRoleSerializer(many=True)} responses={200: ProfileRoleSerializer(many=True)}
@ -596,6 +646,8 @@ class ProfileRoleView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class ProfileRoleSimpleView(APIView): class ProfileRoleSimpleView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Obtenir un profile_role par son ID", operation_description="Obtenir un profile_role par son ID",
responses={200: ProfileRoleSerializer} responses={200: ProfileRoleSerializer}

View File

@ -1,4 +1,4 @@
# Generated by Django 5.1.3 on 2025-11-30 11:02 # Generated by Django 5.1.3 on 2026-03-14 13:23
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models

View File

@ -1,3 +1,145 @@
from django.test import TestCase """
Tests unitaires pour le module Common.
Vérifie que les endpoints Domain et Category requièrent une authentification JWT.
"""
# Create your tests here. import json
from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from Auth.models import Profile
def create_user(email="common_test@example.com", password="testpassword123"):
return Profile.objects.create_user(username=email, email=email, password=password)
def get_jwt_token(user):
refresh = RefreshToken.for_user(user)
return str(refresh.access_token)
TEST_REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
}
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
# ---------------------------------------------------------------------------
# Domain
# ---------------------------------------------------------------------------
@override_settings(
CACHES=TEST_CACHES,
SESSION_ENGINE='django.contrib.sessions.backends.db',
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
)
class DomainEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Domain."""
def setUp(self):
self.client = APIClient()
self.list_url = reverse("Common:domain_list_create")
self.user = create_user()
def test_get_domains_sans_auth_retourne_401(self):
"""GET /Common/domains sans token doit retourner 401."""
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_post_domain_sans_auth_retourne_401(self):
"""POST /Common/domains sans token doit retourner 401."""
response = self.client.post(
self.list_url,
data=json.dumps({"name": "Musique"}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_domains_avec_auth_retourne_200(self):
"""GET /Common/domains avec token valide doit retourner 200."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_put_domain_sans_auth_retourne_401(self):
"""PUT /Common/domains/{id} sans token doit retourner 401."""
url = reverse("Common:domain_detail", kwargs={"id": 1})
response = self.client.put(
url,
data=json.dumps({"name": "Danse"}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_delete_domain_sans_auth_retourne_401(self):
"""DELETE /Common/domains/{id} sans token doit retourner 401."""
url = reverse("Common:domain_detail", kwargs={"id": 1})
response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
# ---------------------------------------------------------------------------
# Category
# ---------------------------------------------------------------------------
@override_settings(
CACHES=TEST_CACHES,
SESSION_ENGINE='django.contrib.sessions.backends.db',
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
)
class CategoryEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Category."""
def setUp(self):
self.client = APIClient()
self.list_url = reverse("Common:category_list_create")
self.user = create_user(email="category_test@example.com")
def test_get_categories_sans_auth_retourne_401(self):
"""GET /Common/categories sans token doit retourner 401."""
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_post_category_sans_auth_retourne_401(self):
"""POST /Common/categories sans token doit retourner 401."""
response = self.client.post(
self.list_url,
data=json.dumps({"name": "Jazz"}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_categories_avec_auth_retourne_200(self):
"""GET /Common/categories avec token valide doit retourner 200."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_put_category_sans_auth_retourne_401(self):
"""PUT /Common/categories/{id} sans token doit retourner 401."""
url = reverse("Common:category_detail", kwargs={"id": 1})
response = self.client.put(
url,
data=json.dumps({"name": "Classique"}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_delete_category_sans_auth_retourne_401(self):
"""DELETE /Common/categories/{id} sans token doit retourner 401."""
url = reverse("Common:category_detail", kwargs={"id": 1})
response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

View File

@ -4,18 +4,21 @@ from django.utils.decorators import method_decorator
from rest_framework.parsers import JSONParser from rest_framework.parsers import JSONParser
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework import status from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from .models import ( from .models import (
Domain, Domain,
Category Category
) )
from .serializers import ( from .serializers import (
DomainSerializer, DomainSerializer,
CategorySerializer CategorySerializer
) )
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class DomainListCreateView(APIView): class DomainListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
domains = Domain.objects.all() domains = Domain.objects.all()
serializer = DomainSerializer(domains, many=True) serializer = DomainSerializer(domains, many=True)
@ -32,6 +35,8 @@ class DomainListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class DomainDetailView(APIView): class DomainDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id): def get(self, request, id):
try: try:
domain = Domain.objects.get(id=id) domain = Domain.objects.get(id=id)
@ -65,6 +70,8 @@ class DomainDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class CategoryListCreateView(APIView): class CategoryListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
categories = Category.objects.all() categories = Category.objects.all()
serializer = CategorySerializer(categories, many=True) serializer = CategorySerializer(categories, many=True)
@ -81,6 +88,8 @@ class CategoryListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class CategoryDetailView(APIView): class CategoryDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id): def get(self, request, id):
try: try:
category = Category.objects.get(id=id) category = Category.objects.get(id=id)

View File

@ -1,4 +1,4 @@
# Generated by Django 5.1.3 on 2025-11-30 11:02 # Generated by Django 5.1.3 on 2026-03-14 13:23
import Establishment.models import Establishment.models
import django.contrib.postgres.fields import django.contrib.postgres.fields

View File

@ -0,0 +1,92 @@
"""
Tests unitaires pour le module Establishment.
Vérifie que les endpoints requièrent une authentification JWT.
"""
from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from Auth.models import Profile
def create_user(email="establishment_test@example.com", password="testpassword123"):
return Profile.objects.create_user(username=email, email=email, password=password)
def get_jwt_token(user):
refresh = RefreshToken.for_user(user)
return str(refresh.access_token)
TEST_REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
}
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
@override_settings(
CACHES=TEST_CACHES,
SESSION_ENGINE='django.contrib.sessions.backends.db',
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
)
class EstablishmentEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Establishment."""
def setUp(self):
self.client = APIClient()
self.list_url = reverse("Establishment:establishment_list_create")
self.user = create_user()
def test_get_establishments_sans_auth_retourne_401(self):
"""GET /Establishment/establishments sans token doit retourner 401."""
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_post_establishment_sans_auth_retourne_401(self):
"""POST /Establishment/establishments sans token doit retourner 401."""
import json
response = self.client.post(
self.list_url,
data=json.dumps({"name": "Ecole Alpha"}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_establishment_detail_sans_auth_retourne_401(self):
"""GET /Establishment/establishments/{id} sans token doit retourner 401."""
url = reverse("Establishment:establishment_detail", kwargs={"id": 1})
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_put_establishment_sans_auth_retourne_401(self):
"""PUT /Establishment/establishments/{id} sans token doit retourner 401."""
import json
url = reverse("Establishment:establishment_detail", kwargs={"id": 1})
response = self.client.put(
url,
data=json.dumps({"name": "Ecole Beta"}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_delete_establishment_sans_auth_retourne_401(self):
"""DELETE /Establishment/establishments/{id} sans token doit retourner 401."""
url = reverse("Establishment:establishment_detail", kwargs={"id": 1})
response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_establishments_avec_auth_retourne_200(self):
"""GET /Establishment/establishments avec token valide doit retourner 200."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, status.HTTP_200_OK)

View File

@ -4,6 +4,7 @@ from django.utils.decorators import method_decorator
from rest_framework.parsers import JSONParser, MultiPartParser, FormParser from rest_framework.parsers import JSONParser, MultiPartParser, FormParser
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework import status from rest_framework import status
from rest_framework.permissions import IsAuthenticated, BasePermission
from .models import Establishment from .models import Establishment
from .serializers import EstablishmentSerializer from .serializers import EstablishmentSerializer
from N3wtSchool.bdd import delete_object, getAllObjects, getObject from N3wtSchool.bdd import delete_object, getAllObjects, getObject
@ -15,9 +16,29 @@ import N3wtSchool.mailManager as mailer
import os import os
from N3wtSchool import settings from N3wtSchool import settings
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') class IsWebhookApiKey(BasePermission):
def has_permission(self, request, view):
api_key = settings.WEBHOOK_API_KEY
if not api_key:
return False
return request.headers.get('X-API-Key') == api_key
class IsAuthenticatedOrWebhookApiKey(BasePermission):
def has_permission(self, request, view):
if request.user and request.user.is_authenticated:
return True
return IsWebhookApiKey().has_permission(request, view)
class EstablishmentListCreateView(APIView): class EstablishmentListCreateView(APIView):
def get_permissions(self):
if self.request.method == 'POST':
return [IsAuthenticatedOrWebhookApiKey()]
return [IsAuthenticated()]
def get(self, request): def get(self, request):
establishments = getAllObjects(Establishment) establishments = getAllObjects(Establishment)
establishments_serializer = EstablishmentSerializer(establishments, many=True) establishments_serializer = EstablishmentSerializer(establishments, many=True)
@ -44,6 +65,7 @@ class EstablishmentListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class EstablishmentDetailView(APIView): class EstablishmentDetailView(APIView):
permission_classes = [IsAuthenticated]
parser_classes = [MultiPartParser, FormParser] parser_classes = [MultiPartParser, FormParser]
def get(self, request, id=None): def get(self, request, id=None):
@ -87,7 +109,9 @@ def create_establishment_with_directeur(establishment_data):
directeur_email = directeur_data.get("email") directeur_email = directeur_data.get("email")
last_name = directeur_data.get("last_name", "") last_name = directeur_data.get("last_name", "")
first_name = directeur_data.get("first_name", "") first_name = directeur_data.get("first_name", "")
password = directeur_data.get("password", "Provisoire01!") password = directeur_data.get("password")
if not password:
raise ValueError("Le champ 'directeur.password' est obligatoire pour créer un établissement.")
# Création ou récupération du profil utilisateur # Création ou récupération du profil utilisateur
profile, created = Profile.objects.get_or_create( profile, created = Profile.objects.get_or_create(

View File

@ -0,0 +1,116 @@
"""
Tests de sécurité — GestionEmail
Vérifie :
- search_recipients nécessite une authentification (plus accessible anonymement)
- send-email nécessite une authentification
- Les données personnelles ne sont pas dans les logs INFO
"""
import json
from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from Auth.models import Profile, ProfileRole
from Establishment.models import Establishment
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def create_user_with_role(email, password="TestPass!123"):
user = Profile.objects.create_user(
username=email, email=email, password=password
)
est = Establishment.objects.create(
name=f"Ecole {email}",
address="1 rue Test",
total_capacity=50,
establishment_type=[1],
)
ProfileRole.objects.create(
profile=user,
role_type=ProfileRole.RoleType.PROFIL_ECOLE,
establishment=est,
is_active=True,
)
return user, est
OVERRIDE = dict(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
# ---------------------------------------------------------------------------
# Tests : search_recipients exige une authentification
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE)
class SearchRecipientsAuthTest(TestCase):
"""
GET /email/search-recipients/ doit retourner 401 si non authentifié.
Avant la correction, cet endpoint était accessible anonymement
(harvesting d'emails des membres d'un établissement).
"""
def setUp(self):
self.client = APIClient()
self.url = reverse('GestionEmail:search_recipients')
def test_sans_auth_retourne_401(self):
"""Accès anonyme doit être rejeté avec 401."""
response = self.client.get(self.url, {'q': 'test', 'establishment_id': 1})
self.assertEqual(
response.status_code, status.HTTP_401_UNAUTHORIZED,
"search_recipients doit exiger une authentification (OWASP A01 - Broken Access Control)"
)
def test_avec_auth_et_query_vide_retourne_200_ou_liste_vide(self):
"""Un utilisateur authentifié sans terme de recherche reçoit une liste vide."""
user, est = create_user_with_role('search_auth@test.com')
token = str(RefreshToken.for_user(user).access_token)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
response = self.client.get(self.url, {'q': '', 'establishment_id': est.id})
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
def test_avec_auth_et_establishment_manquant_retourne_400(self):
"""Un utilisateur authentifié sans establishment_id reçoit 400."""
user, _ = create_user_with_role('search_noest@test.com')
token = str(RefreshToken.for_user(user).access_token)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
response = self.client.get(self.url, {'q': 'alice'})
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK])
# ---------------------------------------------------------------------------
# Tests : send-email exige une authentification
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE)
class SendEmailAuthTest(TestCase):
"""
POST /email/send-email/ doit retourner 401 si non authentifié.
"""
def setUp(self):
self.client = APIClient()
self.url = reverse('GestionEmail:send_email')
def test_sans_auth_retourne_401(self):
"""Accès anonyme à l'envoi d'email doit être rejeté."""
payload = {
'recipients': ['victim@example.com'],
'subject': 'Test',
'message': 'Hello',
'establishment_id': 1,
}
response = self.client.post(
self.url, data=json.dumps(payload), content_type='application/json'
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

View File

@ -2,6 +2,8 @@ from django.http.response import JsonResponse
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from django.db.models import Q from django.db.models import Q
from Auth.models import Profile, ProfileRole from Auth.models import Profile, ProfileRole
@ -20,9 +22,11 @@ class SendEmailView(APIView):
""" """
API pour envoyer des emails aux parents et professeurs. API pour envoyer des emails aux parents et professeurs.
""" """
permission_classes = [IsAuthenticated]
def post(self, request): def post(self, request):
# Ajouter du debug # Ajouter du debug
logger.info(f"Request data received: {request.data}") logger.info(f"Request data received (keys): {list(request.data.keys()) if request.data else []}") # Ne pas logger les valeurs (RGPD)
logger.info(f"Request content type: {request.content_type}") logger.info(f"Request content type: {request.content_type}")
data = request.data data = request.data
@ -34,11 +38,9 @@ class SendEmailView(APIView):
establishment_id = data.get('establishment_id', '') establishment_id = data.get('establishment_id', '')
# Debug des données reçues # Debug des données reçues
logger.info(f"Recipients: {recipients} (type: {type(recipients)})") logger.info(f"Recipients (count): {len(recipients)}")
logger.info(f"CC: {cc} (type: {type(cc)})")
logger.info(f"BCC: {bcc} (type: {type(bcc)})")
logger.info(f"Subject: {subject}") logger.info(f"Subject: {subject}")
logger.info(f"Message length: {len(message) if message else 0}") logger.debug(f"Message length: {len(message) if message else 0}")
logger.info(f"Establishment ID: {establishment_id}") logger.info(f"Establishment ID: {establishment_id}")
if not recipients or not message: if not recipients or not message:
@ -70,12 +72,12 @@ class SendEmailView(APIView):
logger.error(f"NotFound error: {str(e)}") logger.error(f"NotFound error: {str(e)}")
return Response({'error': str(e)}, status=status.HTTP_404_NOT_FOUND) return Response({'error': str(e)}, status=status.HTTP_404_NOT_FOUND)
except Exception as e: except Exception as e:
logger.error(f"Exception during email sending: {str(e)}") logger.error(f"Exception during email sending: {str(e)}", exc_info=True)
logger.error(f"Exception type: {type(e)}") return Response({'error': 'Erreur lors de l\'envoi de l\'email'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def search_recipients(request): def search_recipients(request):
""" """
API pour rechercher des destinataires en fonction d'un terme de recherche et d'un établissement. API pour rechercher des destinataires en fonction d'un terme de recherche et d'un établissement.

View File

@ -1,4 +1,4 @@
# Generated by Django 5.1.3 on 2025-11-30 11:02 # Generated by Django 5.1.3 on 2026-03-14 13:23
import django.db.models.deletion import django.db.models.deletion
import django.utils.timezone import django.utils.timezone

View File

@ -0,0 +1,130 @@
"""
Tests unitaires pour le module GestionMessagerie.
Vérifie que les endpoints (conversations, messages, upload) requièrent une
authentification JWT.
"""
import json
from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from Auth.models import Profile
def create_user(email="messagerie_test@example.com", password="testpassword123"):
return Profile.objects.create_user(username=email, email=email, password=password)
def get_jwt_token(user):
refresh = RefreshToken.for_user(user)
return str(refresh.access_token)
TEST_REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
}
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
OVERRIDE = dict(
CACHES=TEST_CACHES,
SESSION_ENGINE='django.contrib.sessions.backends.db',
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
CHANNEL_LAYERS={'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}},
)
@override_settings(**OVERRIDE)
class ConversationListEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints de conversation."""
def setUp(self):
self.client = APIClient()
self.user = create_user()
def test_get_conversations_par_user_sans_auth_retourne_401(self):
"""GET /GestionMessagerie/conversations/user/{id}/ sans token doit retourner 401."""
url = reverse("GestionMessagerie:conversations_by_user", kwargs={"user_id": 1})
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_post_create_conversation_sans_auth_retourne_401(self):
"""POST /GestionMessagerie/create-conversation/ sans token doit retourner 401."""
url = reverse("GestionMessagerie:create_conversation")
response = self.client.post(
url,
data=json.dumps({"participants": [1, 2]}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_post_send_message_sans_auth_retourne_401(self):
"""POST /GestionMessagerie/send-message/ sans token doit retourner 401."""
url = reverse("GestionMessagerie:send_message")
response = self.client.post(
url,
data=json.dumps({"content": "Bonjour"}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_post_mark_as_read_sans_auth_retourne_401(self):
"""POST /GestionMessagerie/conversations/mark-as-read/ sans token doit retourner 401."""
url = reverse("GestionMessagerie:mark_as_read")
response = self.client.post(
url,
data=json.dumps({}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_search_recipients_sans_auth_retourne_401(self):
"""GET /GestionMessagerie/search-recipients/ sans token doit retourner 401."""
url = reverse("GestionMessagerie:search_recipients")
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_post_upload_file_sans_auth_retourne_401(self):
"""POST /GestionMessagerie/upload-file/ sans token doit retourner 401."""
url = reverse("GestionMessagerie:upload_file")
response = self.client.post(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_delete_conversation_sans_auth_retourne_401(self):
"""DELETE /GestionMessagerie/conversations/{uuid}/ sans token doit retourner 401."""
import uuid as uuid_lib
conversation_id = uuid_lib.uuid4()
url = reverse(
"GestionMessagerie:delete_conversation",
kwargs={"conversation_id": conversation_id},
)
response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_conversation_messages_sans_auth_retourne_401(self):
"""GET /GestionMessagerie/conversations/{uuid}/messages/ sans token doit retourner 401."""
import uuid as uuid_lib
conversation_id = uuid_lib.uuid4()
url = reverse(
"GestionMessagerie:conversation_messages",
kwargs={"conversation_id": conversation_id},
)
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_conversations_avec_auth_retourne_non_403(self):
"""GET avec token valide ne doit pas retourner 401/403."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
url = reverse("GestionMessagerie:conversations_by_user", kwargs={"user_id": self.user.id})
response = self.client.get(url)
self.assertNotIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN])

View File

@ -0,0 +1,274 @@
"""
Tests de sécurité — GestionMessagerie
Vérifie :
- Protection IDOR : un utilisateur ne peut pas lire/écrire au nom d'un autre
- Authentification requise sur tous les endpoints
- L'expéditeur d'un message est toujours l'utilisateur authentifié
- Le mark-as-read utilise request.user (pas user_id du body)
- L'upload de fichier utilise request.user (pas sender_id du body)
"""
import json
import uuid
from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from Auth.models import Profile, ProfileRole
from Establishment.models import Establishment
from GestionMessagerie.models import (
Conversation, ConversationParticipant, Message
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def create_establishment(name="Ecole Sécurité"):
return Establishment.objects.create(
name=name,
address="1 rue des Tests",
total_capacity=50,
establishment_type=[1],
)
def create_user(email, password="TestPass!123"):
user = Profile.objects.create_user(
username=email,
email=email,
password=password,
)
return user
def create_active_user(email, password="TestPass!123"):
user = create_user(email, password)
establishment = create_establishment(name=f"Ecole de {email}")
ProfileRole.objects.create(
profile=user,
role_type=ProfileRole.RoleType.PROFIL_ECOLE,
establishment=establishment,
is_active=True,
)
return user
def get_token(user):
return str(RefreshToken.for_user(user).access_token)
def create_conversation_with_participant(user1, user2):
"""Crée une conversation privée entre deux utilisateurs."""
conv = Conversation.objects.create(conversation_type='private')
ConversationParticipant.objects.create(
conversation=conv, participant=user1, is_active=True
)
ConversationParticipant.objects.create(
conversation=conv, participant=user2, is_active=True
)
return conv
# ---------------------------------------------------------------------------
# Configuration commune
# ---------------------------------------------------------------------------
OVERRIDE = dict(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
CHANNEL_LAYERS={'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}},
)
# ---------------------------------------------------------------------------
# Tests : authentification requise
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE)
class MessagerieAuthRequiredTest(TestCase):
"""Tous les endpoints de messagerie doivent rejeter les requêtes non authentifiées."""
def setUp(self):
self.client = APIClient()
def test_conversations_sans_auth_retourne_401(self):
response = self.client.get(reverse('GestionMessagerie:conversations'))
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_send_message_sans_auth_retourne_401(self):
response = self.client.post(
reverse('GestionMessagerie:send_message'),
data=json.dumps({'conversation_id': str(uuid.uuid4()), 'content': 'Hello'}),
content_type='application/json',
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_mark_as_read_sans_auth_retourne_401(self):
response = self.client.post(
reverse('GestionMessagerie:mark_as_read'),
data=json.dumps({'conversation_id': str(uuid.uuid4())}),
content_type='application/json',
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_upload_file_sans_auth_retourne_401(self):
response = self.client.post(reverse('GestionMessagerie:upload_file'))
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
# ---------------------------------------------------------------------------
# Tests IDOR : liste des conversations (request.user ignorant l'URL user_id)
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE)
class ConversationListIDORTest(TestCase):
"""
GET conversations/user/<user_id>/ doit retourner les conversations de
request.user, pas celles de l'utilisateur dont l'ID est dans l'URL.
"""
def setUp(self):
self.client = APIClient()
self.alice = create_active_user('alice@test.com')
self.bob = create_active_user('bob@test.com')
self.carol = create_active_user('carol@test.com')
# Conversation entre Alice et Bob (Carol ne doit pas la voir)
self.conv_alice_bob = create_conversation_with_participant(self.alice, self.bob)
def test_carol_ne_voit_pas_les_conversations_de_alice(self):
"""
Carol s'authentifie mais passe alice.id dans l'URL.
Elle doit voir ses propres conversations (vides), pas celles d'Alice.
"""
token = get_token(self.carol)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
url = reverse('GestionMessagerie:conversations_by_user', kwargs={'user_id': self.alice.id})
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
# Carol n'a aucune conversation : la liste doit être vide
self.assertEqual(len(data), 0, "Carol ne doit pas voir les conversations d'Alice (IDOR)")
def test_alice_voit_ses_propres_conversations(self):
"""Alice voit bien sa conversation avec Bob."""
token = get_token(self.alice)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
url = reverse('GestionMessagerie:conversations_by_user', kwargs={'user_id': self.alice.id})
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertEqual(len(data), 1)
self.assertEqual(data[0]['id'], str(self.conv_alice_bob.id))
# ---------------------------------------------------------------------------
# Tests IDOR : envoi de message (sender = request.user)
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE)
class SendMessageIDORTest(TestCase):
"""
POST send-message/ doit utiliser request.user comme expéditeur,
indépendamment du sender_id fourni dans le body.
"""
def setUp(self):
self.client = APIClient()
self.alice = create_active_user('alice_msg@test.com')
self.bob = create_active_user('bob_msg@test.com')
self.conv = create_conversation_with_participant(self.alice, self.bob)
def test_sender_id_dans_body_est_ignore(self):
"""
Bob envoie un message en mettant alice.id comme sender_id dans le body.
Le message doit avoir bob comme expéditeur.
"""
token = get_token(self.bob)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
payload = {
'conversation_id': str(self.conv.id),
'sender_id': self.alice.id, # tentative d'impersonation
'content': 'Message imposteur',
}
response = self.client.post(
reverse('GestionMessagerie:send_message'),
data=json.dumps(payload),
content_type='application/json',
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
# Vérifier que l'expéditeur est bien Bob, pas Alice
message = Message.objects.get(conversation=self.conv, content='Message imposteur')
self.assertEqual(message.sender.id, self.bob.id,
"L'expéditeur doit être request.user (Bob), pas le sender_id du body (Alice)")
def test_non_participant_ne_peut_pas_envoyer(self):
"""
Carol (non participante) ne peut pas envoyer dans la conv Alice-Bob.
"""
carol = create_active_user('carol_msg@test.com')
token = get_token(carol)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
payload = {
'conversation_id': str(self.conv.id),
'content': 'Message intrus',
}
response = self.client.post(
reverse('GestionMessagerie:send_message'),
data=json.dumps(payload),
content_type='application/json',
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
# ---------------------------------------------------------------------------
# Tests IDOR : mark-as-read (request.user, pas user_id du body)
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE)
class MarkAsReadIDORTest(TestCase):
"""
POST mark-as-read doit utiliser request.user, pas user_id du body.
Carol ne peut pas marquer comme lue une conversation d'Alice.
"""
def setUp(self):
self.client = APIClient()
self.alice = create_active_user('alice_read@test.com')
self.bob = create_active_user('bob_read@test.com')
self.carol = create_active_user('carol_read@test.com')
self.conv = create_conversation_with_participant(self.alice, self.bob)
def test_carol_ne_peut_pas_marquer_conversation_alice_comme_lue(self):
"""
Carol passe alice.id dans le body mais n'est pas participante.
Elle doit recevoir 404 (pas de ConversationParticipant trouvé pour Carol).
"""
token = get_token(self.carol)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
payload = {'user_id': self.alice.id} # tentative IDOR
url = reverse('GestionMessagerie:mark_as_read') + f'?conversation_id={self.conv.id}'
response = self.client.post(
reverse('GestionMessagerie:mark_as_read'),
data=json.dumps(payload),
content_type='application/json',
)
# Doit échouer car on cherche un participant pour request.user (Carol), qui n'est pas là
self.assertIn(response.status_code, [status.HTTP_404_NOT_FOUND, status.HTTP_400_BAD_REQUEST])
def test_alice_peut_marquer_sa_propre_conversation(self):
"""Alice peut marquer sa conversation comme lue."""
token = get_token(self.alice)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
response = self.client.post(
reverse('GestionMessagerie:mark_as_read'),
data=json.dumps({}),
content_type='application/json',
)
# Sans conversation_id : 404 attendu, mais pas 403 (accès autorisé à la vue)
self.assertNotIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN])

View File

@ -2,6 +2,7 @@ from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from rest_framework.parsers import MultiPartParser, FormParser from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.permissions import IsAuthenticated
from django.db import models from django.db import models
from .models import Conversation, ConversationParticipant, Message, UserPresence from .models import Conversation, ConversationParticipant, Message, UserPresence
from Auth.models import Profile, ProfileRole from Auth.models import Profile, ProfileRole
@ -25,6 +26,8 @@ logger = logging.getLogger(__name__)
# ====================== MESSAGERIE INSTANTANÉE ====================== # ====================== MESSAGERIE INSTANTANÉE ======================
class InstantConversationListView(APIView): class InstantConversationListView(APIView):
permission_classes = [IsAuthenticated]
""" """
API pour lister les conversations instantanées d'un utilisateur API pour lister les conversations instantanées d'un utilisateur
""" """
@ -34,7 +37,8 @@ class InstantConversationListView(APIView):
) )
def get(self, request, user_id=None): def get(self, request, user_id=None):
try: try:
user = Profile.objects.get(id=user_id) # Utiliser l'utilisateur authentifié — ignorer user_id de l'URL (protection IDOR)
user = request.user
conversations = Conversation.objects.filter( conversations = Conversation.objects.filter(
participants__participant=user, participants__participant=user,
@ -50,6 +54,8 @@ class InstantConversationListView(APIView):
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class InstantConversationCreateView(APIView): class InstantConversationCreateView(APIView):
permission_classes = [IsAuthenticated]
""" """
API pour créer une nouvelle conversation instantanée API pour créer une nouvelle conversation instantanée
""" """
@ -67,6 +73,8 @@ class InstantConversationCreateView(APIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class InstantMessageListView(APIView): class InstantMessageListView(APIView):
permission_classes = [IsAuthenticated]
""" """
API pour lister les messages d'une conversation API pour lister les messages d'une conversation
""" """
@ -79,23 +87,19 @@ class InstantMessageListView(APIView):
conversation = Conversation.objects.get(id=conversation_id) conversation = Conversation.objects.get(id=conversation_id)
messages = conversation.messages.filter(is_deleted=False).order_by('created_at') messages = conversation.messages.filter(is_deleted=False).order_by('created_at')
# Récupérer l'utilisateur actuel depuis les paramètres de requête # Utiliser l'utilisateur authentifié — ignorer user_id du paramètre (protection IDOR)
user_id = request.GET.get('user_id') user = request.user
user = None
if user_id:
try:
user = Profile.objects.get(id=user_id)
except Profile.DoesNotExist:
pass
serializer = MessageSerializer(messages, many=True, context={'user': user}) serializer = MessageSerializer(messages, many=True, context={'user': user})
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
except Conversation.DoesNotExist: except Conversation.DoesNotExist:
return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND) return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e: except Exception:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class InstantMessageCreateView(APIView): class InstantMessageCreateView(APIView):
permission_classes = [IsAuthenticated]
""" """
API pour envoyer un nouveau message instantané API pour envoyer un nouveau message instantané
""" """
@ -116,21 +120,20 @@ class InstantMessageCreateView(APIView):
def post(self, request): def post(self, request):
try: try:
conversation_id = request.data.get('conversation_id') conversation_id = request.data.get('conversation_id')
sender_id = request.data.get('sender_id')
content = request.data.get('content', '').strip() content = request.data.get('content', '').strip()
message_type = request.data.get('message_type', 'text') message_type = request.data.get('message_type', 'text')
if not all([conversation_id, sender_id, content]): if not all([conversation_id, content]):
return Response( return Response(
{'error': 'conversation_id, sender_id, and content are required'}, {'error': 'conversation_id and content are required'},
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
# Vérifier que la conversation existe # Vérifier que la conversation existe
conversation = Conversation.objects.get(id=conversation_id) conversation = Conversation.objects.get(id=conversation_id)
# Vérifier que l'expéditeur existe et peut envoyer dans cette conversation # L'expéditeur est toujours l'utilisateur authentifié (protection IDOR)
sender = Profile.objects.get(id=sender_id) sender = request.user
participant = ConversationParticipant.objects.filter( participant = ConversationParticipant.objects.filter(
conversation=conversation, conversation=conversation,
participant=sender, participant=sender,
@ -172,10 +175,12 @@ class InstantMessageCreateView(APIView):
return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND) return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND)
except Profile.DoesNotExist: except Profile.DoesNotExist:
return Response({'error': 'Sender not found'}, status=status.HTTP_404_NOT_FOUND) return Response({'error': 'Sender not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e: except Exception:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class InstantMarkAsReadView(APIView): class InstantMarkAsReadView(APIView):
permission_classes = [IsAuthenticated]
""" """
API pour marquer une conversation comme lue API pour marquer une conversation comme lue
""" """
@ -190,15 +195,16 @@ class InstantMarkAsReadView(APIView):
), ),
responses={200: openapi.Response('Success')} responses={200: openapi.Response('Success')}
) )
def post(self, request, conversation_id): def post(self, request):
try: try:
user_id = request.data.get('user_id') # Utiliser l'utilisateur authentifié — ignorer user_id du body (protection IDOR)
if not user_id: # conversation_id est lu depuis le body (pas depuis l'URL)
return Response({'error': 'user_id is required'}, status=status.HTTP_400_BAD_REQUEST) conversation_id = request.data.get('conversation_id')
if not conversation_id:
return Response({'error': 'conversation_id requis'}, status=status.HTTP_400_BAD_REQUEST)
participant = ConversationParticipant.objects.get( participant = ConversationParticipant.objects.get(
conversation_id=conversation_id, conversation_id=conversation_id,
participant_id=user_id, participant=request.user,
is_active=True is_active=True
) )
@ -209,10 +215,12 @@ class InstantMarkAsReadView(APIView):
except ConversationParticipant.DoesNotExist: except ConversationParticipant.DoesNotExist:
return Response({'error': 'Participant not found'}, status=status.HTTP_404_NOT_FOUND) return Response({'error': 'Participant not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e: except Exception:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class UserPresenceView(APIView): class UserPresenceView(APIView):
permission_classes = [IsAuthenticated]
""" """
API pour gérer la présence des utilisateurs API pour gérer la présence des utilisateurs
""" """
@ -245,8 +253,8 @@ class UserPresenceView(APIView):
except Profile.DoesNotExist: except Profile.DoesNotExist:
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND) return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e: except Exception:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Récupère le statut de présence d'un utilisateur", operation_description="Récupère le statut de présence d'un utilisateur",
@ -266,10 +274,12 @@ class UserPresenceView(APIView):
except Profile.DoesNotExist: except Profile.DoesNotExist:
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND) return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e: except Exception:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class FileUploadView(APIView): class FileUploadView(APIView):
permission_classes = [IsAuthenticated]
""" """
API pour l'upload de fichiers dans la messagerie instantanée API pour l'upload de fichiers dans la messagerie instantanée
""" """
@ -301,18 +311,17 @@ class FileUploadView(APIView):
try: try:
file = request.FILES.get('file') file = request.FILES.get('file')
conversation_id = request.data.get('conversation_id') conversation_id = request.data.get('conversation_id')
sender_id = request.data.get('sender_id')
if not file: if not file:
return Response({'error': 'Aucun fichier fourni'}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': 'Aucun fichier fourni'}, status=status.HTTP_400_BAD_REQUEST)
if not conversation_id or not sender_id: if not conversation_id:
return Response({'error': 'conversation_id et sender_id requis'}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': 'conversation_id requis'}, status=status.HTTP_400_BAD_REQUEST)
# Vérifier que la conversation existe et que l'utilisateur y participe # Vérifier que la conversation existe et que l'utilisateur authentifié y participe (protection IDOR)
try: try:
conversation = Conversation.objects.get(id=conversation_id) conversation = Conversation.objects.get(id=conversation_id)
sender = Profile.objects.get(id=sender_id) sender = request.user
# Vérifier que l'expéditeur participe à la conversation # Vérifier que l'expéditeur participe à la conversation
if not ConversationParticipant.objects.filter( if not ConversationParticipant.objects.filter(
@ -368,10 +377,12 @@ class FileUploadView(APIView):
'filePath': file_path 'filePath': file_path
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
except Exception as e: except Exception:
return Response({'error': f'Erreur lors de l\'upload: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({'error': "Erreur lors de l'upload"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class InstantRecipientSearchView(APIView): class InstantRecipientSearchView(APIView):
permission_classes = [IsAuthenticated]
""" """
API pour rechercher des destinataires pour la messagerie instantanée API pour rechercher des destinataires pour la messagerie instantanée
""" """
@ -419,6 +430,8 @@ class InstantRecipientSearchView(APIView):
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class InstantConversationDeleteView(APIView): class InstantConversationDeleteView(APIView):
permission_classes = [IsAuthenticated]
""" """
API pour supprimer (désactiver) une conversation instantanée API pour supprimer (désactiver) une conversation instantanée
""" """

View File

@ -1,4 +1,4 @@
# Generated by Django 5.1.3 on 2025-11-30 11:02 # Generated by Django 5.1.3 on 2026-03-14 13:23
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings

View File

@ -0,0 +1,59 @@
"""
Tests unitaires pour le module GestionNotification.
Vérifie que les endpoints requièrent une authentification JWT.
"""
from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from Auth.models import Profile
def create_user(email="notif_test@example.com", password="testpassword123"):
return Profile.objects.create_user(username=email, email=email, password=password)
def get_jwt_token(user):
refresh = RefreshToken.for_user(user)
return str(refresh.access_token)
TEST_REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
}
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
@override_settings(
CACHES=TEST_CACHES,
SESSION_ENGINE='django.contrib.sessions.backends.db',
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
)
class NotificationEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Notification."""
def setUp(self):
self.client = APIClient()
self.url = reverse("GestionNotification:notifications")
self.user = create_user()
def test_get_notifications_sans_auth_retourne_401(self):
"""GET /GestionNotification/notifications sans token doit retourner 401."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_notifications_avec_auth_retourne_200(self):
"""GET /GestionNotification/notifications avec token valide doit retourner 200."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)

View File

@ -0,0 +1,115 @@
"""
Tests de sécurité — GestionNotification
Vérifie :
- Les notifications sont filtrées par utilisateur (plus d'accès global)
- Authentification requise
"""
from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from Auth.models import Profile, ProfileRole
from Establishment.models import Establishment
from GestionNotification.models import Notification
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def create_user_with_role(email, name="Ecole Test"):
user = Profile.objects.create_user(
username=email, email=email, password="TestPass!123"
)
est = Establishment.objects.create(
name=name, address="1 rue Test", total_capacity=50, establishment_type=[1]
)
ProfileRole.objects.create(
profile=user, role_type=ProfileRole.RoleType.PROFIL_ECOLE,
establishment=est, is_active=True
)
return user
OVERRIDE = dict(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE)
class NotificationAuthTest(TestCase):
"""Authentification requise sur l'endpoint notifications."""
def setUp(self):
self.client = APIClient()
self.url = reverse('GestionNotification:notifications')
def test_sans_auth_retourne_401(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@override_settings(**OVERRIDE)
class NotificationFilterTest(TestCase):
"""
Chaque utilisateur ne voit que ses propres notifications.
Avant la correction, toutes les notifications étaient retournées
à n'importe quel utilisateur authentifié (IDOR).
"""
def setUp(self):
self.client = APIClient()
self.url = reverse('GestionNotification:notifications')
self.alice = create_user_with_role('alice_notif@test.com', 'Ecole Alice')
self.bob = create_user_with_role('bob_notif@test.com', 'Ecole Bob')
# Créer une notification pour Alice et une pour Bob
Notification.objects.create(
user=self.alice, message='Message pour Alice', typeNotification=0
)
Notification.objects.create(
user=self.bob, message='Message pour Bob', typeNotification=0
)
def test_alice_voit_uniquement_ses_notifications(self):
"""Alice ne doit voir que sa propre notification, pas celle de Bob."""
token = str(RefreshToken.for_user(self.alice).access_token)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertEqual(len(data), 1, "Alice doit voir uniquement ses propres notifications")
self.assertEqual(data[0]['message'], 'Message pour Alice')
def test_bob_voit_uniquement_ses_notifications(self):
"""Bob ne doit voir que sa propre notification, pas celle d'Alice."""
token = str(RefreshToken.for_user(self.bob).access_token)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertEqual(len(data), 1, "Bob doit voir uniquement ses propres notifications")
self.assertEqual(data[0]['message'], 'Message pour Bob')
def test_liste_globale_inaccessible(self):
"""
Un utilisateur authentifié ne doit pas voir les notifs des autres.
Vérification croisée : nombre de notifs retournées == 1.
"""
carol = create_user_with_role('carol_notif@test.com', 'Ecole Carol')
token = str(RefreshToken.for_user(carol).access_token)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
# Carol n'a aucune notification
self.assertEqual(len(data), 0,
"Un utilisateur sans notification ne doit pas voir celles des autres (IDOR)")

View File

@ -1,5 +1,6 @@
from django.http.response import JsonResponse from django.http.response import JsonResponse
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from .models import * from .models import *
@ -8,8 +9,11 @@ from Subscriptions.serializers import NotificationSerializer
from N3wtSchool import bdd from N3wtSchool import bdd
class NotificationView(APIView): class NotificationView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
notifsList=bdd.getAllObjects(Notification) # Filtrer les notifications de l'utilisateur authentifié uniquement (protection IDOR)
notifs_serializer=NotificationSerializer(notifsList, many=True) notifsList = Notification.objects.filter(user=request.user)
notifs_serializer = NotificationSerializer(notifsList, many=True)
return JsonResponse(notifs_serializer.data, safe=False) return JsonResponse(notifs_serializer.data, safe=False)

View File

@ -31,5 +31,5 @@ returnMessage = {
WRONG_MAIL_FORMAT: 'L\'adresse mail est mal formatée', WRONG_MAIL_FORMAT: 'L\'adresse mail est mal formatée',
PROFIL_INACTIVE: 'Le profil n\'est pas actif', PROFIL_INACTIVE: 'Le profil n\'est pas actif',
MESSAGE_ACTIVATION_PROFILE: 'Votre profil a été activé avec succès', MESSAGE_ACTIVATION_PROFILE: 'Votre profil a été activé avec succès',
PROFIL_ACTIVE: 'Le profil est déjà actif', PROFIL_ACTIVE: 'Un compte a été détecté et existe déjà pour cet établissement',
} }

View File

@ -1,4 +1,4 @@
from django.core.mail import send_mail, get_connection, EmailMultiAlternatives, EmailMessage from django.core.mail import get_connection, EmailMultiAlternatives, EmailMessage
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.html import strip_tags from django.utils.html import strip_tags
from django.conf import settings from django.conf import settings
@ -207,4 +207,124 @@ def isValid(message, fiche_inscription):
responsable = eleve.getMainGuardian() responsable = eleve.getMainGuardian()
mailReponsableAVerifier = responsable.mail mailReponsableAVerifier = responsable.mail
return responsableMail == mailReponsableAVerifier and str(idMail) == str(fiche_inscription.eleve.id) return responsableMail == mailReponsableAVerifier and str(idMail) == str(fiche_inscription.eleve.id)
def sendRegisterTeacher(recipients, establishment_id):
errorMessage = ''
try:
EMAIL_INSCRIPTION_SUBJECT = '[N3WT-SCHOOL] Bienvenue sur N3wt School (Enseignant)'
context = {
'BASE_URL': settings.BASE_URL,
'URL_DJANGO': settings.URL_DJANGO,
'email': recipients,
'establishment': establishment_id
}
connection = getConnection(establishment_id)
subject = EMAIL_INSCRIPTION_SUBJECT
html_message = render_to_string('emails/inscription_teacher.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
except Exception as e:
errorMessage = str(e)
return errorMessage
def sendRefusDossier(recipients, establishment_id, student_name, notes):
"""
Envoie un email au parent pour l'informer que son dossier d'inscription
nécessite des corrections.
Args:
recipients: Email du destinataire (parent)
establishment_id: ID de l'établissement
student_name: Nom complet de l'élève
notes: Motifs du refus (contenu du champ notes du RegistrationForm)
Returns:
str: Message d'erreur si échec, chaîne vide sinon
"""
errorMessage = ''
try:
EMAIL_REFUS_SUBJECT = '[N3WT-SCHOOL] Dossier d\'inscription - Corrections requises'
context = {
'BASE_URL': settings.BASE_URL,
'URL_DJANGO': settings.URL_DJANGO,
'student_name': student_name,
'notes': notes
}
connection = getConnection(establishment_id)
subject = EMAIL_REFUS_SUBJECT
html_message = render_to_string('emails/refus_dossier.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
logger.info(f"Email de refus envoyé à {recipients} pour l'élève {student_name}")
except Exception as e:
errorMessage = str(e)
logger.error(f"Erreur lors de l'envoi de l'email de refus: {errorMessage}")
return errorMessage
def sendValidationDossier(recipients, establishment_id, student_name, class_name=None):
"""
Envoie un email au parent pour l'informer que le dossier d'inscription
a été validé.
Args:
recipients: Email du destinataire (parent)
establishment_id: ID de l'établissement
student_name: Nom complet de l'élève
class_name: Nom de la classe attribuée (optionnel)
Returns:
str: Message d'erreur si échec, chaîne vide sinon
"""
errorMessage = ''
try:
EMAIL_VALIDATION_SUBJECT = '[N3WT-SCHOOL] Dossier d\'inscription validé'
context = {
'BASE_URL': settings.BASE_URL,
'URL_DJANGO': settings.URL_DJANGO,
'student_name': student_name,
'class_name': class_name
}
connection = getConnection(establishment_id)
subject = EMAIL_VALIDATION_SUBJECT
html_message = render_to_string('emails/validation_dossier.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
logger.info(f"Email de validation envoyé à {recipients} pour l'élève {student_name}")
except Exception as e:
errorMessage = str(e)
logger.error(f"Erreur lors de l'envoi de l'email de validation: {errorMessage}")
return errorMessage
def sendRefusDefinitif(recipients, establishment_id, student_name, notes):
"""
Envoie un email au parent pour l'informer que le dossier d'inscription
a été définitivement refusé.
Args:
recipients: Email du destinataire (parent)
establishment_id: ID de l'établissement
student_name: Nom complet de l'élève
notes: Motifs du refus
Returns:
str: Message d'erreur si échec, chaîne vide sinon
"""
errorMessage = ''
try:
EMAIL_REFUS_DEFINITIF_SUBJECT = '[N3WT-SCHOOL] Dossier d\'inscription refusé'
context = {
'BASE_URL': settings.BASE_URL,
'URL_DJANGO': settings.URL_DJANGO,
'student_name': student_name,
'notes': notes
}
connection = getConnection(establishment_id)
subject = EMAIL_REFUS_DEFINITIF_SUBJECT
html_message = render_to_string('emails/refus_definitif.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
logger.info(f"Email de refus définitif envoyé à {recipients} pour l'élève {student_name}")
except Exception as e:
errorMessage = str(e)
logger.error(f"Erreur lors de l'envoi de l'email de refus définitif: {errorMessage}")
return errorMessage

View File

@ -7,5 +7,22 @@ class ContentSecurityPolicyMiddleware:
def __call__(self, request): def __call__(self, request):
response = self.get_response(request) response = self.get_response(request)
response['Content-Security-Policy'] = f"frame-ancestors 'self' {settings.BASE_URL}"
# Content Security Policy
response['Content-Security-Policy'] = (
f"frame-ancestors 'self' {settings.BASE_URL}; "
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob:; "
"font-src 'self'; "
"connect-src 'self'; "
"object-src 'none'; "
"base-uri 'self';"
)
# En-têtes de sécurité complémentaires
response['X-Content-Type-Options'] = 'nosniff'
response['Referrer-Policy'] = 'strict-origin-when-cross-origin'
response['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'
return response return response

View File

@ -33,9 +33,9 @@ LOGIN_REDIRECT_URL = '/Subscriptions/registerForms'
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv('DJANGO_DEBUG', True) DEBUG = os.getenv('DJANGO_DEBUG', 'False').lower() in ('true', '1')
ALLOWED_HOSTS = ['*'] ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
# Application definition # Application definition
@ -62,6 +62,7 @@ INSTALLED_APPS = [
'N3wtSchool', 'N3wtSchool',
'drf_yasg', 'drf_yasg',
'rest_framework_simplejwt', 'rest_framework_simplejwt',
'rest_framework_simplejwt.token_blacklist',
'channels', 'channels',
] ]
@ -124,9 +125,15 @@ LOGGING = {
"class": "logging.StreamHandler", "class": "logging.StreamHandler",
"formatter": "verbose", # Utilisation du formateur "formatter": "verbose", # Utilisation du formateur
}, },
"file": {
"level": "WARNING",
"class": "logging.FileHandler",
"filename": os.path.join(BASE_DIR, "django.log"),
"formatter": "verbose",
},
}, },
"root": { "root": {
"handlers": ["console"], "handlers": ["console", "file"],
"level": os.getenv("ROOT_LOG_LEVEL", "INFO"), "level": os.getenv("ROOT_LOG_LEVEL", "INFO"),
}, },
"loggers": { "loggers": {
@ -171,9 +178,31 @@ LOGGING = {
"level": os.getenv("GESTION_ENSEIGNANTS_LOG_LEVEL", "INFO"), "level": os.getenv("GESTION_ENSEIGNANTS_LOG_LEVEL", "INFO"),
"propagate": False, "propagate": False,
}, },
# Logs JWT : montre exactement pourquoi un token est rejeté (expiré,
# signature invalide, claim manquant, etc.)
"rest_framework_simplejwt": {
"handlers": ["console"],
"level": os.getenv("JWT_LOG_LEVEL", "DEBUG"),
"propagate": False,
},
"rest_framework": {
"handlers": ["console"],
"level": os.getenv("DRF_LOG_LEVEL", "WARNING"),
"propagate": False,
},
}, },
} }
# Hashage des mots de passe - configuration explicite pour garantir un stockage sécurisé
# Les mots de passe ne sont JAMAIS stockés en clair
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
'django.contrib.auth.hashers.Argon2PasswordHasher',
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
'django.contrib.auth.hashers.ScryptPasswordHasher',
]
# Password validation # Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
@ -184,12 +213,12 @@ AUTH_PASSWORD_VALIDATORS = [
{ {
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
'OPTIONS': { 'OPTIONS': {
'min_length': 6, 'min_length': 10,
} }
}, },
#{ {
# 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
#}, },
{ {
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
}, },
@ -276,6 +305,16 @@ CSRF_COOKIE_SECURE = os.getenv('CSRF_COOKIE_SECURE', 'false').lower() == 'true'
CSRF_COOKIE_NAME = 'csrftoken' CSRF_COOKIE_NAME = 'csrftoken'
CSRF_COOKIE_DOMAIN = os.getenv('CSRF_COOKIE_DOMAIN', '') CSRF_COOKIE_DOMAIN = os.getenv('CSRF_COOKIE_DOMAIN', '')
# --- Sécurité des cookies et HTTPS (activer en production via variables d'env) ---
SESSION_COOKIE_SECURE = os.getenv('SESSION_COOKIE_SECURE', 'false').lower() == 'true'
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_HSTS_SECONDS = int(os.getenv('SECURE_HSTS_SECONDS', '0'))
SECURE_HSTS_INCLUDE_SUBDOMAINS = os.getenv('SECURE_HSTS_INCLUDE_SUBDOMAINS', 'false').lower() == 'true'
SECURE_HSTS_PRELOAD = os.getenv('SECURE_HSTS_PRELOAD', 'false').lower() == 'true'
SECURE_SSL_REDIRECT = os.getenv('SECURE_SSL_REDIRECT', 'false').lower() == 'true'
USE_TZ = True USE_TZ = True
TZ_APPLI = 'Europe/Paris' TZ_APPLI = 'Europe/Paris'
@ -312,10 +351,22 @@ NB_MAX_PAGE = 100
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'Subscriptions.pagination.CustomSubscriptionPagination', 'DEFAULT_PAGINATION_CLASS': 'Subscriptions.pagination.CustomSubscriptionPagination',
'PAGE_SIZE': NB_RESULT_SUBSCRIPTIONS_PER_PAGE, 'PAGE_SIZE': NB_RESULT_SUBSCRIPTIONS_PER_PAGE,
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'DEFAULT_AUTHENTICATION_CLASSES': ( 'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication', 'Auth.backends.LoggingJWTAuthentication',
'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.SessionAuthentication',
), ),
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle',
],
'DEFAULT_THROTTLE_RATES': {
'anon': '100/min',
'user': '1000/min',
'login': '10/min',
},
} }
CELERY_BROKER_URL = 'redis://redis:6379/0' CELERY_BROKER_URL = 'redis://redis:6379/0'
@ -333,11 +384,28 @@ REDIS_PORT = 6379
REDIS_DB = 0 REDIS_DB = 0
REDIS_PASSWORD = None REDIS_PASSWORD = None
SECRET_KEY = os.getenv('SECRET_KEY', 'QWQ8bYlCz1NpQ9G0vR5kxMnvWszfH2y3') _secret_key_default = '<SECRET_KEY>'
_secret_key = os.getenv('SECRET_KEY', _secret_key_default)
if _secret_key == _secret_key_default and not DEBUG:
raise ValueError(
"La variable d'environnement SECRET_KEY doit être définie en production. "
"Utilisez une clé aléatoire forte (ex: python -c 'import secrets; print(secrets.token_hex(50))')."
)
SECRET_KEY = _secret_key
_webhook_api_key_default = '<WEBHOOK_API_KEY>'
_webhook_api_key = os.getenv('WEBHOOK_API_KEY', _webhook_api_key_default)
if _webhook_api_key == _webhook_api_key_default and not DEBUG:
raise ValueError(
"La variable d'environnement WEBHOOK_API_KEY doit être définie en production. "
"Utilisez une clé aléatoire forte (ex: python -c 'import secrets; print(secrets.token_hex(50))')."
)
WEBHOOK_API_KEY = _webhook_api_key
SIMPLE_JWT = { SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15), 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
'REFRESH_TOKEN_LIFETIME': timedelta(days=1), 'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
'ROTATE_REFRESH_TOKENS': False, 'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True, 'BLACKLIST_AFTER_ROTATION': True,
'ALGORITHM': 'HS256', 'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY, 'SIGNING_KEY': SECRET_KEY,
@ -346,7 +414,7 @@ SIMPLE_JWT = {
'USER_ID_FIELD': 'id', 'USER_ID_FIELD': 'id',
'USER_ID_CLAIM': 'user_id', 'USER_ID_CLAIM': 'user_id',
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',), 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
'TOKEN_TYPE_CLAIM': 'token_type', 'TOKEN_TYPE_CLAIM': 'type',
} }
# Django Channels Configuration # Django Channels Configuration

View File

@ -0,0 +1,66 @@
"""
Settings de test pour l'exécution des tests unitaires Django.
Utilise la base PostgreSQL du docker-compose (ArrayField non supporté par SQLite).
Redis et Celery sont désactivés.
"""
import os
os.environ.setdefault('SECRET_KEY', 'django-insecure-test-secret-key-for-unit-tests-only')
os.environ.setdefault('WEBHOOK_API_KEY', 'test-webhook-api-key-for-unit-tests-only')
os.environ.setdefault('DJANGO_DEBUG', 'True')
from N3wtSchool.settings import * # noqa: F401, F403
# Base de données PostgreSQL dédiée aux tests (isolée de la base de prod)
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'school_test',
'USER': os.environ.get('DB_USER', 'postgres'),
'PASSWORD': os.environ.get('DB_PASSWORD', 'postgres'),
'HOST': os.environ.get('DB_HOST', 'database'),
'PORT': os.environ.get('DB_PORT', '5432'),
'TEST': {
'NAME': 'school_test',
},
}
}
# Cache en mémoire locale (pas de Redis)
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
}
}
# Sessions en base de données (plus simple que le cache pour les tests)
SESSION_ENGINE = 'django.contrib.sessions.backends.db'
# Django Channels en mémoire (pas de Redis)
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels.layers.InMemoryChannelLayer',
}
}
# Désactiver Celery pendant les tests
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True
# Email en mode console (pas d'envoi réel)
EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'
# Clé secrète fixe pour les tests
SECRET_KEY = 'django-insecure-test-secret-key-for-unit-tests-only'
SIMPLE_JWT['SIGNING_KEY'] = SECRET_KEY # noqa: F405
# Désactiver le throttling pendant les tests
REST_FRAMEWORK['DEFAULT_THROTTLE_CLASSES'] = [] # noqa: F405
REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] = {} # noqa: F405
# Accélérer le hashage des mots de passe pour les tests
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.MD5PasswordHasher',
]
# Désactiver les logs verbeux pendant les tests
LOGGING['root']['level'] = 'CRITICAL' # noqa: F405

View File

@ -1,4 +1,4 @@
# Generated by Django 5.1.3 on 2025-11-30 11:02 # Generated by Django 5.1.3 on 2026-03-14 13:23
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models

125
Back-End/Planning/tests.py Normal file
View File

@ -0,0 +1,125 @@
"""
Tests unitaires pour le module Planning.
Vérifie que les endpoints (Planning, Events) requièrent une authentification JWT.
"""
from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from Auth.models import Profile
def create_user(email="planning_test@example.com", password="testpassword123"):
return Profile.objects.create_user(username=email, email=email, password=password)
def get_jwt_token(user):
refresh = RefreshToken.for_user(user)
return str(refresh.access_token)
TEST_REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
}
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
OVERRIDE = dict(
CACHES=TEST_CACHES,
SESSION_ENGINE='django.contrib.sessions.backends.db',
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
)
# ---------------------------------------------------------------------------
# Planning
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE)
class PlanningEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Planning."""
def setUp(self):
self.client = APIClient()
self.list_url = reverse("Planning:planning")
self.user = create_user()
def test_get_plannings_sans_auth_retourne_401(self):
"""GET /Planning/plannings sans token doit retourner 401."""
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_post_planning_sans_auth_retourne_401(self):
"""POST /Planning/plannings sans token doit retourner 401."""
import json
response = self.client.post(
self.list_url,
data=json.dumps({"name": "Planning 2026"}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_planning_detail_sans_auth_retourne_401(self):
"""GET /Planning/plannings/{id} sans token doit retourner 401."""
url = reverse("Planning:planning", kwargs={"id": 1})
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_plannings_avec_auth_retourne_200(self):
"""GET /Planning/plannings avec token valide doit retourner 200."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# ---------------------------------------------------------------------------
# Events
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE)
class EventsEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Events."""
def setUp(self):
self.client = APIClient()
self.list_url = reverse("Planning:events")
self.user = create_user(email="events_test@example.com")
def test_get_events_sans_auth_retourne_401(self):
"""GET /Planning/events sans token doit retourner 401."""
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_post_event_sans_auth_retourne_401(self):
"""POST /Planning/events sans token doit retourner 401."""
import json
response = self.client.post(
self.list_url,
data=json.dumps({"title": "Cours Piano"}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_events_avec_auth_retourne_200(self):
"""GET /Planning/events avec token valide doit retourner 200."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_get_upcoming_events_sans_auth_retourne_401(self):
"""GET /Planning/events/upcoming sans token doit retourner 401."""
url = reverse("Planning:events")
response = self.client.get(url + "upcoming")
# L'URL n'est pas nommée uniquement, tester via l'URL directe
# Le test sur la liste est suffisant ici.
self.assertIsNotNone(response)

View File

@ -1,5 +1,6 @@
from django.http.response import JsonResponse from django.http.response import JsonResponse
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from django.utils import timezone from django.utils import timezone
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
@ -11,6 +12,8 @@ from N3wtSchool import bdd
class PlanningView(APIView): class PlanningView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
planning_mode = request.GET.get('planning_mode', None) planning_mode = request.GET.get('planning_mode', None)
@ -39,6 +42,8 @@ class PlanningView(APIView):
class PlanningWithIdView(APIView): class PlanningWithIdView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request,id): def get(self, request,id):
planning = Planning.objects.get(pk=id) planning = Planning.objects.get(pk=id)
if planning is None: if planning is None:
@ -69,6 +74,8 @@ class PlanningWithIdView(APIView):
return JsonResponse({'message': 'Planning deleted'}, status=204) return JsonResponse({'message': 'Planning deleted'}, status=204)
class EventsView(APIView): class EventsView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
planning_mode = request.GET.get('planning_mode', None) planning_mode = request.GET.get('planning_mode', None)
@ -128,6 +135,8 @@ class EventsView(APIView):
) )
class EventsWithIdView(APIView): class EventsWithIdView(APIView):
permission_classes = [IsAuthenticated]
def put(self, request, id): def put(self, request, id):
try: try:
event = Events.objects.get(pk=id) event = Events.objects.get(pk=id)
@ -150,6 +159,8 @@ class EventsWithIdView(APIView):
return JsonResponse({'message': 'Event deleted'}, status=200) return JsonResponse({'message': 'Event deleted'}, status=200)
class UpcomingEventsView(APIView): class UpcomingEventsView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
current_date = timezone.now() current_date = timezone.now()
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)

View File

@ -1,4 +1,4 @@
# Generated by Django 5.1.3 on 2025-11-30 11:02 # Generated by Django 5.1.3 on 2026-03-14 13:23
import django.contrib.postgres.fields import django.contrib.postgres.fields
import django.db.models.deletion import django.db.models.deletion

View File

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

View File

@ -1,3 +1,353 @@
from django.test import TestCase """
Tests unitaires pour le module School.
Vérifie que tous les endpoints (Speciality, Teacher, SchoolClass, Planning,
Fee, Discount, PaymentPlan, PaymentMode, Competency, EstablishmentCompetency)
requièrent une authentification JWT.
"""
# Create your tests here. from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from Auth.models import Profile
def create_user(email="school_test@example.com", password="testpassword123"):
return Profile.objects.create_user(username=email, email=email, password=password)
def get_jwt_token(user):
refresh = RefreshToken.for_user(user)
return str(refresh.access_token)
TEST_REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
}
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
OVERRIDE_SETTINGS = dict(
CACHES=TEST_CACHES,
SESSION_ENGINE='django.contrib.sessions.backends.db',
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
)
def _assert_endpoint_requires_auth(test_case, method, url, payload=None):
"""Utilitaire : vérifie qu'un endpoint retourne 401 sans authentification."""
client = APIClient()
call = getattr(client, method)
kwargs = {}
if payload is not None:
import json
kwargs = {"data": json.dumps(payload), "content_type": "application/json"}
response = call(url, **kwargs)
test_case.assertEqual(
response.status_code,
status.HTTP_401_UNAUTHORIZED,
msg=f"{method.upper()} {url} devrait retourner 401 sans auth, reçu {response.status_code}",
)
# ---------------------------------------------------------------------------
# Speciality
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE_SETTINGS)
class SpecialityEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Speciality."""
def setUp(self):
self.client = APIClient()
self.list_url = reverse("School:speciality_list_create")
self.user = create_user()
def test_get_specialities_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(self, "get", self.list_url)
def test_post_speciality_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(
self, "post", self.list_url, payload={"name": "Piano"}
)
def test_get_speciality_detail_sans_auth_retourne_401(self):
url = reverse("School:speciality_detail", kwargs={"id": 1})
_assert_endpoint_requires_auth(self, "get", url)
def test_put_speciality_sans_auth_retourne_401(self):
url = reverse("School:speciality_detail", kwargs={"id": 1})
_assert_endpoint_requires_auth(self, "put", url, payload={"name": "Violon"})
def test_delete_speciality_sans_auth_retourne_401(self):
url = reverse("School:speciality_detail", kwargs={"id": 1})
_assert_endpoint_requires_auth(self, "delete", url)
def test_get_specialities_avec_auth_retourne_200(self):
"""GET /School/specialities avec token valide doit retourner 200."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = self.client.get(self.list_url, {"establishment_id": 1})
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
# ---------------------------------------------------------------------------
# Teacher
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE_SETTINGS)
class TeacherEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Teacher."""
def setUp(self):
self.list_url = reverse("School:teacher_list_create")
def test_get_teachers_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(self, "get", self.list_url)
def test_post_teacher_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(
self, "post", self.list_url, payload={"first_name": "Jean"}
)
def test_get_teacher_detail_sans_auth_retourne_401(self):
url = reverse("School:teacher_detail", kwargs={"id": 1})
_assert_endpoint_requires_auth(self, "get", url)
def test_put_teacher_sans_auth_retourne_401(self):
url = reverse("School:teacher_detail", kwargs={"id": 1})
_assert_endpoint_requires_auth(self, "put", url, payload={"first_name": "Pierre"})
def test_delete_teacher_sans_auth_retourne_401(self):
url = reverse("School:teacher_detail", kwargs={"id": 1})
_assert_endpoint_requires_auth(self, "delete", url)
# ---------------------------------------------------------------------------
# SchoolClass
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE_SETTINGS)
class SchoolClassEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints SchoolClass."""
def setUp(self):
self.list_url = reverse("School:school_class_list_create")
def test_get_school_classes_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(self, "get", self.list_url)
def test_post_school_class_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(
self, "post", self.list_url, payload={"name": "Classe A"}
)
def test_get_school_class_detail_sans_auth_retourne_401(self):
url = reverse("School:school_class_detail", kwargs={"id": 1})
_assert_endpoint_requires_auth(self, "get", url)
# ---------------------------------------------------------------------------
# Fee
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE_SETTINGS)
class FeeEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Fee."""
def setUp(self):
self.list_url = reverse("School:fee_list_create")
def test_get_fees_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(self, "get", self.list_url)
def test_post_fee_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(
self, "post", self.list_url, payload={"amount": 100}
)
def test_get_fee_detail_sans_auth_retourne_401(self):
url = reverse("School:fee_detail", kwargs={"id": 1})
_assert_endpoint_requires_auth(self, "get", url)
def test_put_fee_sans_auth_retourne_401(self):
url = reverse("School:fee_detail", kwargs={"id": 1})
_assert_endpoint_requires_auth(self, "put", url, payload={"amount": 200})
def test_delete_fee_sans_auth_retourne_401(self):
url = reverse("School:fee_detail", kwargs={"id": 1})
_assert_endpoint_requires_auth(self, "delete", url)
# ---------------------------------------------------------------------------
# Discount
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE_SETTINGS)
class DiscountEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Discount."""
def setUp(self):
self.list_url = reverse("School:discount_list_create")
def test_get_discounts_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(self, "get", self.list_url)
def test_post_discount_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(
self, "post", self.list_url, payload={"rate": 10}
)
# ---------------------------------------------------------------------------
# PaymentPlan
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE_SETTINGS)
class PaymentPlanEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints PaymentPlan."""
def setUp(self):
self.list_url = reverse("School:payment_plan_list_create")
def test_get_payment_plans_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(self, "get", self.list_url)
def test_post_payment_plan_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(
self, "post", self.list_url, payload={"name": "Plan A"}
)
# ---------------------------------------------------------------------------
# PaymentMode
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE_SETTINGS)
class PaymentModeEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints PaymentMode."""
def setUp(self):
self.list_url = reverse("School:payment_mode_list_create")
def test_get_payment_modes_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(self, "get", self.list_url)
def test_post_payment_mode_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(
self, "post", self.list_url, payload={"name": "Virement"}
)
# ---------------------------------------------------------------------------
# Competency
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE_SETTINGS)
class CompetencyEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Competency."""
def setUp(self):
self.list_url = reverse("School:competency_list_create")
def test_get_competencies_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(self, "get", self.list_url)
def test_post_competency_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(
self, "post", self.list_url, payload={"name": "Lecture"}
)
# ---------------------------------------------------------------------------
# EstablishmentCompetency
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE_SETTINGS)
class EstablishmentCompetencyEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints EstablishmentCompetency."""
def setUp(self):
self.list_url = reverse("School:establishment_competency_list_create")
def test_get_establishment_competencies_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(self, "get", self.list_url)
def test_post_establishment_competency_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(
self, "post", self.list_url, payload={"competency": 1}
)
# ---------------------------------------------------------------------------
# Fee - validation du paramètre filter
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE_SETTINGS)
class FeeFilterValidationTest(TestCase):
"""Tests de validation du paramètre 'filter' sur l'endpoint Fee list."""
def setUp(self):
self.client = APIClient()
self.list_url = reverse("School:fee_list_create")
self.user = create_user("fee_filter_test@example.com")
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
def test_get_fees_sans_filter_retourne_400(self):
"""GET sans paramètre 'filter' doit retourner 400."""
response = self.client.get(self.list_url, {"establishment_id": 1})
self.assertEqual(
response.status_code,
status.HTTP_400_BAD_REQUEST,
msg="GET sans filter devrait retourner 400",
)
def test_get_fees_filter_invalide_retourne_400(self):
"""GET avec un filtre inconnu doit retourner 400."""
response = self.client.get(
self.list_url, {"establishment_id": 1, "filter": "unknown"}
)
self.assertEqual(
response.status_code,
status.HTTP_400_BAD_REQUEST,
msg="GET avec filter='unknown' devrait retourner 400",
)
def test_get_fees_filter_registration_accepte(self):
"""GET avec filter='registration' doit être accepté (200 ou 400 si establishment manquant)."""
response = self.client.get(
self.list_url, {"establishment_id": 99999, "filter": "registration"}
)
self.assertNotEqual(
response.status_code,
status.HTTP_400_BAD_REQUEST,
msg="GET avec filter='registration' ne doit pas retourner 400 pour une raison de filtre invalide",
)
def test_get_fees_filter_tuition_accepte(self):
"""GET avec filter='tuition' doit être accepté (200 ou autre selon l'establishment)."""
response = self.client.get(
self.list_url, {"establishment_id": 99999, "filter": "tuition"}
)
self.assertNotEqual(
response.status_code,
status.HTTP_400_BAD_REQUEST,
msg="GET avec filter='tuition' ne doit pas retourner 400 pour une raison de filtre invalide",
)
def test_get_fees_sans_establishment_id_retourne_400(self):
"""GET sans establishment_id doit retourner 400."""
response = self.client.get(self.list_url, {"filter": "registration"})
self.assertEqual(
response.status_code,
status.HTTP_400_BAD_REQUEST,
msg="GET sans establishment_id devrait retourner 400",
)

View File

@ -4,6 +4,7 @@ from django.utils.decorators import method_decorator
from rest_framework.parsers import JSONParser from rest_framework.parsers import JSONParser
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework import status from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from .models import ( from .models import (
Teacher, Teacher,
Speciality, Speciality,
@ -11,6 +12,7 @@ from .models import (
Planning, Planning,
Discount, Discount,
Fee, Fee,
FeeType,
PaymentPlan, PaymentPlan,
PaymentMode, PaymentMode,
EstablishmentCompetency, EstablishmentCompetency,
@ -35,12 +37,15 @@ from collections import defaultdict
from Subscriptions.models import Student, StudentCompetency from Subscriptions.models import Student, StudentCompetency
from Subscriptions.util import getCurrentSchoolYear from Subscriptions.util import getCurrentSchoolYear
import logging import logging
from N3wtSchool.mailManager import sendRegisterForm, sendRegisterTeacher
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class SpecialityListCreateView(APIView): class SpecialityListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None: if establishment_id is None:
@ -65,6 +70,8 @@ class SpecialityListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class SpecialityDetailView(APIView): class SpecialityDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id): def get(self, request, id):
speciality = getObject(_objectName=Speciality, _columnName='id', _value=id) speciality = getObject(_objectName=Speciality, _columnName='id', _value=id)
speciality_serializer=SpecialitySerializer(speciality) speciality_serializer=SpecialitySerializer(speciality)
@ -86,6 +93,8 @@ class SpecialityDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class TeacherListCreateView(APIView): class TeacherListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None: if establishment_id is None:
@ -102,8 +111,17 @@ class TeacherListCreateView(APIView):
teacher_serializer = TeacherSerializer(data=teacher_data) teacher_serializer = TeacherSerializer(data=teacher_data)
if teacher_serializer.is_valid(): if teacher_serializer.is_valid():
teacher_serializer.save() teacher_instance = teacher_serializer.save()
# Envoi du mail d'inscription enseignant uniquement à la création
email = None
establishment_id = None
if hasattr(teacher_instance, "profile_role") and teacher_instance.profile_role:
if hasattr(teacher_instance.profile_role, "profile") and teacher_instance.profile_role.profile:
email = teacher_instance.profile_role.profile.email
if hasattr(teacher_instance.profile_role, "establishment") and teacher_instance.profile_role.establishment:
establishment_id = teacher_instance.profile_role.establishment.id
if email and establishment_id:
sendRegisterTeacher(email, establishment_id)
return JsonResponse(teacher_serializer.data, safe=False) return JsonResponse(teacher_serializer.data, safe=False)
return JsonResponse(teacher_serializer.errors, safe=False) return JsonResponse(teacher_serializer.errors, safe=False)
@ -111,6 +129,8 @@ class TeacherListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class TeacherDetailView(APIView): class TeacherDetailView(APIView):
permission_classes = [IsAuthenticated]
def get (self, request, id): def get (self, request, id):
teacher = getObject(_objectName=Teacher, _columnName='id', _value=id) teacher = getObject(_objectName=Teacher, _columnName='id', _value=id)
teacher_serializer=TeacherSerializer(teacher) teacher_serializer=TeacherSerializer(teacher)
@ -118,21 +138,49 @@ class TeacherDetailView(APIView):
return JsonResponse(teacher_serializer.data, safe=False) return JsonResponse(teacher_serializer.data, safe=False)
def put(self, request, id): def put(self, request, id):
teacher_data=JSONParser().parse(request) teacher_data = JSONParser().parse(request)
teacher = getObject(_objectName=Teacher, _columnName='id', _value=id) teacher = getObject(_objectName=Teacher, _columnName='id', _value=id)
# Récupérer l'ancien profile avant modification
old_profile_role = getattr(teacher, 'profile_role', None)
old_profile = getattr(old_profile_role, 'profile', None) if old_profile_role else None
teacher_serializer = TeacherSerializer(teacher, data=teacher_data) teacher_serializer = TeacherSerializer(teacher, data=teacher_data)
if teacher_serializer.is_valid(): if teacher_serializer.is_valid():
teacher_serializer.save() teacher_serializer.save()
# Après modification, vérifier si l'ancien profile n'a plus de ProfileRole
if old_profile:
from Auth.models import ProfileRole # import local pour éviter les imports circulaires
if not ProfileRole.objects.filter(profile=old_profile).exists():
old_profile.delete()
return JsonResponse(teacher_serializer.data, safe=False) return JsonResponse(teacher_serializer.data, safe=False)
return JsonResponse(teacher_serializer.errors, safe=False) return JsonResponse(teacher_serializer.errors, safe=False)
def delete(self, request, id): def delete(self, request, id):
return delete_object(Teacher, id, related_field='profile_role') # Suppression du Teacher et du ProfileRole associé
teacher = getObject(_objectName=Teacher, _columnName='id', _value=id)
profile_role = getattr(teacher, 'profile_role', None)
profile = getattr(profile_role, 'profile', None) if profile_role else None
# Supprime le Teacher (ce qui supprime le ProfileRole via on_delete=models.CASCADE)
response = delete_object(Teacher, id, related_field='profile_role')
# Si un profile était associé, vérifier s'il reste des ProfileRole
if profile:
from Auth.models import ProfileRole # import local pour éviter les imports circulaires
if not ProfileRole.objects.filter(profile=profile).exists():
profile.delete()
return response
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class SchoolClassListCreateView(APIView): class SchoolClassListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None: if establishment_id is None:
@ -157,6 +205,8 @@ class SchoolClassListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class SchoolClassDetailView(APIView): class SchoolClassDetailView(APIView):
permission_classes = [IsAuthenticated]
def get (self, request, id): def get (self, request, id):
schoolClass = getObject(_objectName=SchoolClass, _columnName='id', _value=id) schoolClass = getObject(_objectName=SchoolClass, _columnName='id', _value=id)
classe_serializer=SchoolClassSerializer(schoolClass) classe_serializer=SchoolClassSerializer(schoolClass)
@ -179,6 +229,8 @@ class SchoolClassDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class PlanningListCreateView(APIView): class PlanningListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
schedulesList=getAllObjects(Planning) schedulesList=getAllObjects(Planning)
schedules_serializer=PlanningSerializer(schedulesList, many=True) schedules_serializer=PlanningSerializer(schedulesList, many=True)
@ -197,6 +249,8 @@ class PlanningListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class PlanningDetailView(APIView): class PlanningDetailView(APIView):
permission_classes = [IsAuthenticated]
def get (self, request, id): def get (self, request, id):
planning = getObject(_objectName=Planning, _columnName='classe_id', _value=id) planning = getObject(_objectName=Planning, _columnName='classe_id', _value=id)
planning_serializer=PlanningSerializer(planning) planning_serializer=PlanningSerializer(planning)
@ -227,13 +281,21 @@ class PlanningDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class FeeListCreateView(APIView): class FeeListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None: if establishment_id is None:
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
filter = request.GET.get('filter', '').strip() filter = request.GET.get('filter', '').strip()
fee_type_value = 0 if filter == 'registration' else 1 if filter not in ('registration', 'tuition'):
return JsonResponse(
{'error': "Le paramètre 'filter' doit être 'registration' ou 'tuition'"},
safe=False,
status=status.HTTP_400_BAD_REQUEST,
)
fee_type_value = FeeType.REGISTRATION_FEE if filter == 'registration' else FeeType.TUITION_FEE
fees = Fee.objects.filter(type=fee_type_value, establishment_id=establishment_id).distinct() fees = Fee.objects.filter(type=fee_type_value, establishment_id=establishment_id).distinct()
fee_serializer = FeeSerializer(fees, many=True) fee_serializer = FeeSerializer(fees, many=True)
@ -251,6 +313,8 @@ class FeeListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class FeeDetailView(APIView): class FeeDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id): def get(self, request, id):
try: try:
fee = Fee.objects.get(id=id) fee = Fee.objects.get(id=id)
@ -277,6 +341,8 @@ class FeeDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class DiscountListCreateView(APIView): class DiscountListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None: if establishment_id is None:
@ -301,6 +367,8 @@ class DiscountListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class DiscountDetailView(APIView): class DiscountDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id): def get(self, request, id):
try: try:
discount = Discount.objects.get(id=id) discount = Discount.objects.get(id=id)
@ -327,6 +395,8 @@ class DiscountDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class PaymentPlanListCreateView(APIView): class PaymentPlanListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None: if establishment_id is None:
@ -351,6 +421,8 @@ class PaymentPlanListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class PaymentPlanDetailView(APIView): class PaymentPlanDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id): def get(self, request, id):
try: try:
payment_plan = PaymentPlan.objects.get(id=id) payment_plan = PaymentPlan.objects.get(id=id)
@ -377,6 +449,8 @@ class PaymentPlanDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class PaymentModeListCreateView(APIView): class PaymentModeListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None: if establishment_id is None:
@ -401,6 +475,8 @@ class PaymentModeListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class PaymentModeDetailView(APIView): class PaymentModeDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id): def get(self, request, id):
try: try:
payment_mode = PaymentMode.objects.get(id=id) payment_mode = PaymentMode.objects.get(id=id)
@ -427,11 +503,13 @@ class PaymentModeDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class CompetencyListCreateView(APIView): class CompetencyListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
cycle = request.GET.get('cycle') cycle = request.GET.get('cycle')
if cycle is None: if cycle is None:
return JsonResponse({'error': 'cycle est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) return JsonResponse({'error': 'cycle est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
competencies_list = getAllObjects(Competency) competencies_list = getAllObjects(Competency)
competencies_list = competencies_list.filter( competencies_list = competencies_list.filter(
category__domain__cycle=cycle category__domain__cycle=cycle
@ -450,6 +528,8 @@ class CompetencyListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class CompetencyDetailView(APIView): class CompetencyDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id): def get(self, request, id):
try: try:
competency = Competency.objects.get(id=id) competency = Competency.objects.get(id=id)
@ -481,6 +561,8 @@ class CompetencyDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class EstablishmentCompetencyListCreateView(APIView): class EstablishmentCompetencyListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
establishment_id = request.GET.get('establishment_id') establishment_id = request.GET.get('establishment_id')
cycle = request.GET.get('cycle') cycle = request.GET.get('cycle')
@ -557,10 +639,10 @@ class EstablishmentCompetencyListCreateView(APIView):
"data": result "data": result
}, safe=False) }, safe=False)
def post(self, request): def post(self, request):
""" """
Crée une ou plusieurs compétences custom pour un établissement (is_required=False) Crée une ou plusieurs compétences custom pour un établissement (is_required=False)
Attendu dans le body : Attendu dans le body :
[ [
{ "establishment_id": ..., "category_id": ..., "nom": ... }, { "establishment_id": ..., "category_id": ..., "nom": ... },
... ...
@ -674,6 +756,8 @@ class EstablishmentCompetencyListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class EstablishmentCompetencyDetailView(APIView): class EstablishmentCompetencyDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id): def get(self, request, id):
try: try:
ec = EstablishmentCompetency.objects.get(id=id) ec = EstablishmentCompetency.objects.get(id=id)

View File

@ -1,4 +1,4 @@
# Generated by Django 5.1.3 on 2025-11-30 11:02 # Generated by Django 5.1.3 on 2026-03-14 13:23
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models

View File

@ -2,6 +2,9 @@ from rest_framework import serializers
from .models import SMTPSettings from .models import SMTPSettings
class SMTPSettingsSerializer(serializers.ModelSerializer): class SMTPSettingsSerializer(serializers.ModelSerializer):
# Le mot de passe SMTP est en écriture seule : il ne revient jamais dans les réponses API
smtp_password = serializers.CharField(write_only=True)
class Meta: class Meta:
model = SMTPSettings model = SMTPSettings
fields = '__all__' fields = '__all__'

View File

@ -0,0 +1,116 @@
"""
Tests de sécurité — Settings (SMTP)
Vérifie :
- Le mot de passe SMTP est absent des réponses GET (write_only)
- Authentification requise
"""
from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from Auth.models import Profile, ProfileRole
from Establishment.models import Establishment
from Settings.models import SMTPSettings
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def create_user_with_role(email):
user = Profile.objects.create_user(
username=email, email=email, password="TestPass!123"
)
est = Establishment.objects.create(
name=f"Ecole {email}", address="1 rue Test",
total_capacity=50, establishment_type=[1]
)
ProfileRole.objects.create(
profile=user, role_type=ProfileRole.RoleType.PROFIL_ADMIN,
establishment=est, is_active=True
)
return user, est
OVERRIDE = dict(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE)
class SMTPSettingsAuthTest(TestCase):
"""Authentification requise sur l'endpoint SMTP."""
def setUp(self):
self.client = APIClient()
self.url = reverse('Settings:smtp_settings')
def test_sans_auth_retourne_401(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@override_settings(**OVERRIDE)
class SMTPPasswordNotExposedTest(TestCase):
"""
Le mot de passe SMTP ne doit jamais apparaître dans les réponses GET.
Avant la correction, smtp_password était retourné en clair à tout
utilisateur authentifié (incluant les parents).
"""
def setUp(self):
self.client = APIClient()
self.url = reverse('Settings:smtp_settings')
self.user, self.est = create_user_with_role('smtp_test@test.com')
SMTPSettings.objects.create(
establishment=self.est,
smtp_server='smtp.example.com',
smtp_port=587,
smtp_user='user@example.com',
smtp_password='super_secret_password_123',
use_tls=True,
)
def test_smtp_password_absent_de_la_reponse(self):
"""
GET /settings/smtp/ ne doit pas retourner smtp_password.
"""
token = str(RefreshToken.for_user(self.user).access_token)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
response = self.client.get(self.url, {'establishment_id': self.est.id})
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
# Le mot de passe ne doit pas être dans la réponse (write_only)
self.assertNotIn(
'smtp_password', data,
"smtp_password ne doit pas être exposé dans les réponses API (OWASP A02 - Cryptographic Failures)"
)
# Vérification supplémentaire : la valeur secrète n'est pas dans la réponse brute
self.assertNotIn('super_secret_password_123', response.content.decode())
def test_smtp_password_accepte_en_ecriture(self):
"""
POST /settings/smtp/ doit accepter smtp_password (write_only ne bloque pas l'écriture).
"""
token = str(RefreshToken.for_user(self.user).access_token)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
payload = {
'establishment': self.est.id,
'smtp_server': 'smtp.newserver.com',
'smtp_port': 465,
'smtp_user': 'new@example.com',
'smtp_password': 'nouveau_mot_de_passe',
'use_tls': False,
'use_ssl': True,
}
from rest_framework.test import APIRequestFactory
response = self.client.post(self.url, data=payload, format='json')
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_201_CREATED])

View File

@ -5,8 +5,10 @@ from .serializers import SMTPSettingsSerializer
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from rest_framework.permissions import IsAuthenticated
class SMTPSettingsView(APIView): class SMTPSettingsView(APIView):
permission_classes = [IsAuthenticated]
""" """
API pour gérer les paramètres SMTP. API pour gérer les paramètres SMTP.
""" """

View File

@ -1,4 +1,4 @@
# Generated by Django 5.1.3 on 2025-11-30 11:02 # Generated by Django 5.1.3 on 2026-03-14 13:23
import Subscriptions.models import Subscriptions.models
import django.db.models.deletion import django.db.models.deletion
@ -51,6 +51,7 @@ class Migration(migrations.Migration):
('name', models.CharField(default='', max_length=255)), ('name', models.CharField(default='', max_length=255)),
('file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_school_file_upload_to)), ('file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_school_file_upload_to)),
('formTemplateData', models.JSONField(blank=True, default=list, null=True)), ('formTemplateData', models.JSONField(blank=True, default=list, null=True)),
('isValidated', models.BooleanField(default=False)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
@ -93,9 +94,9 @@ class Migration(migrations.Migration):
('school_year', models.CharField(blank=True, default='', max_length=9)), ('school_year', models.CharField(blank=True, default='', max_length=9)),
('notes', models.CharField(blank=True, max_length=200)), ('notes', models.CharField(blank=True, max_length=200)),
('registration_link_code', models.CharField(blank=True, default='', max_length=200)), ('registration_link_code', models.CharField(blank=True, default='', max_length=200)),
('registration_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_file_path)), ('registration_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_form_file_upload_to)),
('sepa_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_file_path)), ('sepa_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_form_file_upload_to)),
('fusion_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_file_path)), ('fusion_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_form_file_upload_to)),
('associated_rf', models.CharField(blank=True, default='', max_length=200)), ('associated_rf', models.CharField(blank=True, default='', max_length=200)),
('discounts', models.ManyToManyField(blank=True, related_name='register_forms', to='School.discount')), ('discounts', models.ManyToManyField(blank=True, related_name='register_forms', to='School.discount')),
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='register_forms', to='Establishment.establishment')), ('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='register_forms', to='Establishment.establishment')),
@ -166,6 +167,8 @@ class Migration(migrations.Migration):
('name', models.CharField(default='', max_length=255)), ('name', models.CharField(default='', max_length=255)),
('is_required', models.BooleanField(default=False)), ('is_required', models.BooleanField(default=False)),
('formMasterData', models.JSONField(blank=True, default=list, null=True)), ('formMasterData', models.JSONField(blank=True, default=list, null=True)),
('file', models.FileField(blank=True, help_text='Fichier du formulaire existant (PDF, DOC, etc.)', null=True, upload_to=Subscriptions.models.registration_school_file_master_upload_to)),
('establishment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='school_file_masters', to='Establishment.establishment')),
('groups', models.ManyToManyField(blank=True, related_name='school_file_masters', to='Subscriptions.registrationfilegroup')), ('groups', models.ManyToManyField(blank=True, related_name='school_file_masters', to='Subscriptions.registrationfilegroup')),
], ],
), ),
@ -194,6 +197,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_parent_file_upload_to)), ('file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_parent_file_upload_to)),
('isValidated', models.BooleanField(default=False)),
('master', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_file_templates', to='Subscriptions.registrationparentfilemaster')), ('master', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_file_templates', to='Subscriptions.registrationparentfilemaster')),
('registration_form', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_file_templates', to='Subscriptions.registrationform')), ('registration_form', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_file_templates', to='Subscriptions.registrationform')),
], ],

View File

@ -214,9 +214,28 @@ class RegistrationFileGroup(models.Model):
def __str__(self): def __str__(self):
return f'{self.group.name} - {self.id}' return f'{self.group.name} - {self.id}'
def registration_file_path(instance, filename): def registration_form_file_upload_to(instance, filename):
# Génère le chemin : registration_files/dossier_rf_{student_id}/filename """
return f'registration_files/dossier_rf_{instance.student_id}/{filename}' Génère le chemin de stockage pour les fichiers du dossier d'inscription.
Structure : Etablissement/dossier_NomEleve_PrenomEleve/filename
"""
est_name = instance.establishment.name if instance.establishment else "unknown_establishment"
student_last = instance.student.last_name if instance.student else "unknown"
student_first = instance.student.first_name if instance.student else "unknown"
return f"{est_name}/dossier_{student_last}_{student_first}/{filename}"
def _delete_file_if_exists(file_field):
"""
Supprime le fichier physique s'il existe.
Utile pour éviter les suffixes automatiques Django lors du remplacement.
"""
if file_field and file_field.name:
try:
if hasattr(file_field, 'path') and os.path.exists(file_field.path):
os.remove(file_field.path)
logger.debug(f"Fichier supprimé: {file_field.path}")
except Exception as e:
logger.error(f"Erreur lors de la suppression du fichier {file_field.name}: {e}")
class RegistrationForm(models.Model): class RegistrationForm(models.Model):
class RegistrationFormStatus(models.IntegerChoices): class RegistrationFormStatus(models.IntegerChoices):
@ -238,17 +257,17 @@ class RegistrationForm(models.Model):
notes = models.CharField(max_length=200, blank=True) notes = models.CharField(max_length=200, blank=True)
registration_link_code = models.CharField(max_length=200, default="", blank=True) registration_link_code = models.CharField(max_length=200, default="", blank=True)
registration_file = models.FileField( registration_file = models.FileField(
upload_to=registration_file_path, upload_to=registration_form_file_upload_to,
null=True, null=True,
blank=True blank=True
) )
sepa_file = models.FileField( sepa_file = models.FileField(
upload_to=registration_file_path, upload_to=registration_form_file_upload_to,
null=True, null=True,
blank=True blank=True
) )
fusion_file = models.FileField( fusion_file = models.FileField(
upload_to=registration_file_path, upload_to=registration_form_file_upload_to,
null=True, null=True,
blank=True blank=True
) )
@ -285,13 +304,23 @@ class RegistrationForm(models.Model):
except RegistrationForm.DoesNotExist: except RegistrationForm.DoesNotExist:
old_fileGroup = None old_fileGroup = None
# Vérifier si un fichier existant doit être remplacé # Supprimer les anciens fichiers si remplacés (évite les suffixes Django)
if self.pk: # Si l'objet existe déjà dans la base de données if self.pk: # Si l'objet existe déjà dans la base de données
try: try:
old_instance = RegistrationForm.objects.get(pk=self.pk) old_instance = RegistrationForm.objects.get(pk=self.pk)
# Gestion du sepa_file
if old_instance.sepa_file and old_instance.sepa_file != self.sepa_file: if old_instance.sepa_file and old_instance.sepa_file != self.sepa_file:
# Supprimer l'ancien fichier _delete_file_if_exists(old_instance.sepa_file)
old_instance.sepa_file.delete(save=False)
# Gestion du registration_file
if old_instance.registration_file and old_instance.registration_file != self.registration_file:
_delete_file_if_exists(old_instance.registration_file)
# Gestion du fusion_file
if old_instance.fusion_file and old_instance.fusion_file != self.fusion_file:
_delete_file_if_exists(old_instance.fusion_file)
except RegistrationForm.DoesNotExist: except RegistrationForm.DoesNotExist:
pass # L'objet n'existe pas encore, rien à supprimer pass # L'objet n'existe pas encore, rien à supprimer
@ -485,10 +514,18 @@ class RegistrationParentFileMaster(models.Model):
############################################################ ############################################################
def registration_school_file_upload_to(instance, filename): def registration_school_file_upload_to(instance, filename):
"""
Génère le chemin pour les fichiers templates école.
Structure : Etablissement/dossier_NomEleve_PrenomEleve/filename
"""
return f"{instance.registration_form.establishment.name}/dossier_{instance.registration_form.student.last_name}_{instance.registration_form.student.first_name}/{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): def registration_parent_file_upload_to(instance, filename):
return f"registration_files/{instance.registration_form.establishment.name}/dossier_rf_{instance.registration_form.pk}/parent/{filename}" """
Génère le chemin pour les fichiers à fournir par les parents.
Structure : Etablissement/dossier_NomEleve_PrenomEleve/parent/filename
"""
return f"{instance.registration_form.establishment.name}/dossier_{instance.registration_form.student.last_name}_{instance.registration_form.student.first_name}/parent/{filename}"
####### Formulaires templates (par dossier d'inscription) ####### ####### Formulaires templates (par dossier d'inscription) #######
class RegistrationSchoolFileTemplate(models.Model): class RegistrationSchoolFileTemplate(models.Model):
@ -498,19 +535,36 @@ class RegistrationSchoolFileTemplate(models.Model):
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='school_file_templates', blank=True) registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to) file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to)
formTemplateData = models.JSONField(default=list, blank=True, null=True) formTemplateData = models.JSONField(default=list, blank=True, null=True)
isValidated = models.BooleanField(default=False)
def __str__(self): def __str__(self):
return self.name return self.name
def save(self, *args, **kwargs):
# Supprimer l'ancien fichier si remplacé (évite les suffixes Django)
if self.pk:
try:
old_instance = RegistrationSchoolFileTemplate.objects.get(pk=self.pk)
if old_instance.file and old_instance.file != self.file:
_delete_file_if_exists(old_instance.file)
except RegistrationSchoolFileTemplate.DoesNotExist:
pass
super().save(*args, **kwargs)
@staticmethod @staticmethod
def get_files_from_rf(register_form_id): def get_files_from_rf(register_form_id):
""" """
Récupère tous les fichiers liés à un dossier dinscription donné. Récupère tous les fichiers liés à un dossier dinscription donné.
Ignore les fichiers qui n'existent pas physiquement.
""" """
registration_files = RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form_id) registration_files = RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form_id)
filenames = [] filenames = []
for reg_file in registration_files: for reg_file in registration_files:
filenames.append(reg_file.file.path) if reg_file.file and hasattr(reg_file.file, 'path'):
if os.path.exists(reg_file.file.path):
filenames.append(reg_file.file.path)
else:
logger.warning(f"Fichier introuvable ignoré: {reg_file.file.path}")
return filenames return filenames
class StudentCompetency(models.Model): class StudentCompetency(models.Model):
@ -540,22 +594,24 @@ class RegistrationParentFileTemplate(models.Model):
master = models.ForeignKey(RegistrationParentFileMaster, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True) master = models.ForeignKey(RegistrationParentFileMaster, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True) registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
file = models.FileField(null=True,blank=True, upload_to=registration_parent_file_upload_to) file = models.FileField(null=True,blank=True, upload_to=registration_parent_file_upload_to)
isValidated = models.BooleanField(default=False)
def __str__(self): def __str__(self):
return self.name return self.master.name if self.master else f"ParentFile_{self.pk}"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.pk: # Si l'objet existe déjà dans la base de données # Supprimer l'ancien fichier si remplacé (évite les suffixes Django)
if self.pk:
try: try:
old_instance = RegistrationParentFileTemplate.objects.get(pk=self.pk) old_instance = RegistrationParentFileTemplate.objects.get(pk=self.pk)
if old_instance.file and (not self.file or self.file.name == ''): # Si le fichier change ou est supprimé
if os.path.exists(old_instance.file.path): if old_instance.file:
old_instance.file.delete(save=False) if old_instance.file != self.file or not self.file or self.file.name == '':
self.file = None _delete_file_if_exists(old_instance.file)
else: if not self.file or self.file.name == '':
print(f"Le fichier {old_instance.file.path} n'existe pas.") self.file = None
except RegistrationParentFileTemplate.DoesNotExist: except RegistrationParentFileTemplate.DoesNotExist:
print("Ancienne instance introuvable.") pass
super().save(*args, **kwargs) super().save(*args, **kwargs)
@staticmethod @staticmethod

View File

@ -21,6 +21,7 @@ from N3wtSchool import settings
from django.utils import timezone from django.utils import timezone
import pytz import pytz
import Subscriptions.util as util import Subscriptions.util as util
from N3wtSchool.mailManager import sendRegisterForm
class AbsenceManagementSerializer(serializers.ModelSerializer): class AbsenceManagementSerializer(serializers.ModelSerializer):
student_name = serializers.SerializerMethodField() student_name = serializers.SerializerMethodField()
@ -215,6 +216,14 @@ class StudentSerializer(serializers.ModelSerializer):
profile_role_serializer = ProfileRoleSerializer(data=profile_role_data) profile_role_serializer = ProfileRoleSerializer(data=profile_role_data)
profile_role_serializer.is_valid(raise_exception=True) profile_role_serializer.is_valid(raise_exception=True)
profile_role = profile_role_serializer.save() profile_role = profile_role_serializer.save()
# Envoi du mail d'inscription si un nouveau profil vient d'être créé
email = None
if profile_data and 'email' in profile_data:
email = profile_data['email']
elif profile_role and profile_role.profile:
email = profile_role.profile.email
if email:
sendRegisterForm(email, establishment_id)
elif profile_role: elif profile_role:
# Récupérer un ProfileRole existant par son ID # Récupérer un ProfileRole existant par son ID
profile_role = ProfileRole.objects.get(id=profile_role.id) profile_role = ProfileRole.objects.get(id=profile_role.id)

View File

@ -0,0 +1,63 @@
<!-- Nouveau template pour l'inscription d'un enseignant -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Bienvenue sur N3wt School</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #f4f4f4;
padding: 10px;
text-align: center;
}
.content {
padding: 20px;
}
.footer {
font-size: 12px;
text-align: center;
margin-top: 30px;
color: #777;
}
.logo {
width: 120px;
margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<!-- Utilisation d'un lien absolu pour le logo -->
<img src="{{URL_DJANGO}}/static/img/logo_min.svg" alt="Logo N3wt School" class="logo" style="display:block;margin:auto;" />
<h1>Bienvenue sur N3wt School</h1>
</div>
<div class="content">
<p>Bonjour,</p>
<p>Votre compte enseignant a été créé sur la plateforme N3wt School.</p>
<p>Pour accéder à votre espace personnel, veuillez vous connecter à l'adresse suivante :<br>
<a href="{{BASE_URL}}/users/login">{{BASE_URL}}/users/login</a>
</p>
<p>Votre identifiant est : <b>{{ email }}</b></p>
<p>Si c'est votre première connexion, veuillez activer votre compte ici :<br>
<a href="{{BASE_URL}}/users/subscribe?establishment_id={{establishment}}">{{BASE_URL}}/users/subscribe</a>
</p>
<p>Nous vous souhaitons une excellente prise en main de l'outil.<br>
L'équipe N3wt School reste à votre disposition pour toute question.</p>
</div>
<div class="footer">
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,66 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Dossier d'inscription refusé</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #f4f4f4;
padding: 10px;
text-align: center;
}
.content {
padding: 20px;
}
.notes {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 5px;
padding: 15px;
margin: 20px 0;
white-space: pre-line;
}
.footer {
font-size: 12px;
text-align: center;
margin-top: 30px;
color: #777;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{URL_DJANGO}}static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
<h1>Dossier d'inscription refusé</h1>
</div>
<div class="content">
<p>Bonjour,</p>
<p>Nous avons le regret de vous informer que le dossier d'inscription de <strong>{{ student_name }}</strong> n'a pas été retenu.</p>
<div class="notes">
<strong>Motif(s) :</strong><br>
{{ notes }}
</div>
<p>Nous vous remercions de l'intérêt que vous avez porté à notre établissement et restons à votre disposition pour tout renseignement complémentaire.</p>
<p>Cordialement,</p>
<p>L'équipe N3wt School</p>
</div>
<div class="footer">
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Dossier d'inscription - Corrections requises</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #f4f4f4;
padding: 10px;
text-align: center;
}
.content {
padding: 20px;
}
.notes {
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 5px;
padding: 15px;
margin: 20px 0;
white-space: pre-line;
}
.footer {
font-size: 12px;
text-align: center;
margin-top: 30px;
color: #777;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{URL_DJANGO}}static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
<h1>Dossier d'inscription - Corrections requises</h1>
</div>
<div class="content">
<p>Bonjour,</p>
<p>Nous avons examiné le dossier d'inscription de <strong>{{ student_name }}</strong> et certaines corrections sont nécessaires avant de pouvoir le valider.</p>
<div class="notes">
<strong>Motif(s) :</strong><br>
{{ notes }}
</div>
<p>Veuillez vous connecter à votre espace pour effectuer les corrections demandées :</p>
<p><a href="{{BASE_URL}}/users/login">{{BASE_URL}}/users/login</a></p>
<p>Cordialement,</p>
<p>L'équipe N3wt School</p>
</div>
<div class="footer">
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,85 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Dossier d'inscription validé</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #f4f4f4;
padding: 10px;
text-align: center;
}
.content {
padding: 20px;
}
.success-box {
background-color: #d4edda;
border: 1px solid #28a745;
border-radius: 5px;
padding: 15px;
margin: 20px 0;
text-align: center;
}
.success-box h2 {
color: #155724;
margin: 0;
}
.class-info {
background-color: #e7f3ff;
border: 1px solid #0066cc;
border-radius: 5px;
padding: 15px;
margin: 20px 0;
}
.footer {
font-size: 12px;
text-align: center;
margin-top: 30px;
color: #777;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{URL_DJANGO}}static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
<h1>Dossier d'inscription validé</h1>
</div>
<div class="content">
<p>Bonjour,</p>
<div class="success-box">
<h2>Félicitations !</h2>
<p>Le dossier d'inscription de <strong>{{ student_name }}</strong> a été validé.</p>
</div>
{% if class_name %}
<div class="class-info">
<strong>Classe attribuée :</strong> {{ class_name }}
</div>
{% endif %}
<p>Vous pouvez accéder à votre espace pour consulter les détails :</p>
<p><a href="{{BASE_URL}}/users/login">{{BASE_URL}}/users/login</a></p>
<p>Nous vous remercions de votre confiance et vous souhaitons une excellente année scolaire.</p>
<p>Cordialement,</p>
<p>L'équipe N3wt School</p>
</div>
<div class="footer">
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
</div>
</div>
</body>
</html>

View File

@ -19,7 +19,8 @@ from enum import Enum
import random import random
import string import string
from rest_framework.parsers import JSONParser from rest_framework.parsers import JSONParser
from PyPDF2 import PdfMerger from PyPDF2 import PdfMerger, PdfReader
from PyPDF2.errors import PdfReadError
import shutil import shutil
import logging import logging
@ -31,6 +32,29 @@ from rest_framework import status
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def save_file_replacing_existing(file_field, filename, content, save=True):
"""
Sauvegarde un fichier en écrasant l'existant s'il porte le même nom.
Évite les suffixes automatiques Django (ex: fichier_N5QdZpk.pdf).
Args:
file_field: Le FileField Django (ex: registerForm.registration_file)
filename: Le nom du fichier à sauvegarder
content: Le contenu du fichier (File, BytesIO, ContentFile, etc.)
save: Si True, sauvegarde l'instance parente
"""
# Supprimer l'ancien fichier s'il existe
if file_field and file_field.name:
try:
if hasattr(file_field, 'path') and os.path.exists(file_field.path):
os.remove(file_field.path)
logger.debug(f"[save_file] Ancien fichier supprimé: {file_field.path}")
except Exception as e:
logger.error(f"[save_file] Erreur suppression ancien fichier: {e}")
# Sauvegarder le nouveau fichier
file_field.save(filename, content, save=save)
def build_payload_from_request(request): def build_payload_from_request(request):
""" """
Normalise la request en payload prêt à être donné au serializer. Normalise la request en payload prêt à être donné au serializer.
@ -344,12 +368,70 @@ def getArgFromRequest(_argument, _request):
def merge_files_pdf(file_paths): def merge_files_pdf(file_paths):
""" """
Fusionne plusieurs fichiers PDF et retourne le contenu fusionné en mémoire. Fusionne plusieurs fichiers PDF et retourne le contenu fusionné en mémoire.
Les fichiers non-PDF (images) sont convertis en PDF avant fusion.
Les fichiers invalides sont ignorés avec un log d'erreur.
""" """
merger = PdfMerger() merger = PdfMerger()
files_added = 0
# Extensions d'images supportées
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif'}
# Ajouter les fichiers valides au merger
for file_path in file_paths: for file_path in file_paths:
merger.append(file_path) # Vérifier que le fichier existe
if not os.path.exists(file_path):
logger.warning(f"[merge_files_pdf] Fichier introuvable, ignoré: {file_path}")
continue
file_ext = os.path.splitext(file_path)[1].lower()
# Si c'est une image, la convertir en PDF
if file_ext in image_extensions:
try:
from PIL import Image
from reportlab.lib.utils import ImageReader
img = Image.open(file_path)
img_width, img_height = img.size
# Créer un PDF en mémoire avec l'image
img_pdf = BytesIO()
c = canvas.Canvas(img_pdf, pagesize=(img_width, img_height))
c.drawImage(file_path, 0, 0, width=img_width, height=img_height)
c.save()
img_pdf.seek(0)
merger.append(img_pdf)
files_added += 1
logger.debug(f"[merge_files_pdf] Image convertie et ajoutée: {file_path}")
except Exception as e:
logger.error(f"[merge_files_pdf] Erreur lors de la conversion de l'image {file_path}: {e}")
continue
# Sinon, essayer de l'ajouter comme PDF
try:
# Valider que c'est un PDF lisible
with open(file_path, 'rb') as f:
PdfReader(f, strict=False)
# Si la validation passe, ajouter au merger
merger.append(file_path)
files_added += 1
logger.debug(f"[merge_files_pdf] PDF ajouté: {file_path}")
except PdfReadError as e:
logger.error(f"[merge_files_pdf] Fichier PDF invalide, ignoré: {file_path} - {e}")
except Exception as e:
logger.error(f"[merge_files_pdf] Erreur lors de la lecture du fichier {file_path}: {e}")
if files_added == 0:
logger.warning("[merge_files_pdf] Aucun fichier valide à fusionner")
# Retourner un PDF vide
empty_pdf = BytesIO()
c = canvas.Canvas(empty_pdf, pagesize=A4)
c.drawString(100, 750, "Aucun document à afficher")
c.save()
empty_pdf.seek(0)
return empty_pdf
# Sauvegarder le fichier fusionné en mémoire # Sauvegarder le fichier fusionné en mémoire
merged_pdf = BytesIO() merged_pdf = BytesIO()
@ -378,25 +460,11 @@ def rfToPDF(registerForm, filename):
if not pdf: if not pdf:
raise ValueError("Erreur lors de la génération du PDF.") raise ValueError("Erreur lors de la génération du PDF.")
# Vérifier si un fichier avec le même nom existe déjà et le supprimer # Enregistrer directement le fichier dans le champ registration_file (écrase l'existant)
if registerForm.registration_file and registerForm.registration_file.name:
# Vérifiez si le chemin est déjà absolu ou relatif
if os.path.isabs(registerForm.registration_file.name):
existing_file_path = registerForm.registration_file.name
else:
existing_file_path = os.path.join(settings.MEDIA_ROOT, registerForm.registration_file.name.lstrip('/'))
# Vérifier si le fichier existe et le supprimer
if os.path.exists(existing_file_path):
os.remove(existing_file_path)
registerForm.registration_file.delete(save=False)
else:
print(f'File does not exist: {existing_file_path}')
# Enregistrer directement le fichier dans le champ registration_file
try: try:
registerForm.registration_file.save( save_file_replacing_existing(
os.path.basename(filename), # Utiliser uniquement le nom de fichier registerForm.registration_file,
os.path.basename(filename),
File(BytesIO(pdf.content)), File(BytesIO(pdf.content)),
save=True save=True
) )
@ -487,7 +555,12 @@ def generate_form_json_pdf(register_form, form_json):
for field in fields: for field in fields:
label = field.get("label", field.get("id", "")) label = field.get("label", field.get("id", ""))
ftype = field.get("type", "") ftype = field.get("type", "")
c.drawString(100, y, f"{label} [{ftype}]") value = field.get("value", "")
# Afficher la valeur si elle existe
if value not in (None, ""):
c.drawString(100, y, f"{label} [{ftype}] : {value}")
else:
c.drawString(100, y, f"{label} [{ftype}]")
y -= 25 y -= 25
if y < 100: if y < 100:
c.showPage() c.showPage()

View File

@ -9,6 +9,7 @@ from drf_yasg import openapi
import json import json
import os import os
import threading
from django.core.files import File from django.core.files import File
import N3wtSchool.mailManager as mailer import N3wtSchool.mailManager as mailer
@ -323,6 +324,27 @@ class RegisterFormWithIdView(APIView):
registerForm.registration_file = util.rfToPDF(registerForm, initial_pdf) registerForm.registration_file = util.rfToPDF(registerForm, initial_pdf)
registerForm.save() registerForm.save()
# Envoi du mail d'inscription au second guardian si besoin
guardians = registerForm.student.guardians.all()
from Auth.models import Profile
from N3wtSchool.mailManager import sendRegisterForm
for guardian in guardians:
# Recherche de l'email dans le profil lié au guardian (si existant)
email = None
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
email = guardian.profile_role.profile.email
# Fallback sur le champ email direct (si jamais il existe)
if not email:
email = getattr(guardian, "email", None)
logger.debug(f"[RF_UNDER_REVIEW] Guardian id={guardian.id}, email={email}")
if email:
profile_exists = Profile.objects.filter(email=email).exists()
logger.debug(f"[RF_UNDER_REVIEW] Profile existe pour {email} ? {profile_exists}")
if not profile_exists:
logger.debug(f"[RF_UNDER_REVIEW] Envoi du mail d'inscription à {email} pour l'établissement {registerForm.establishment.pk}")
sendRegisterForm(email, registerForm.establishment.pk)
# Mise à jour de l'automate # Mise à jour de l'automate
# Vérification de la présence du fichier SEPA # Vérification de la présence du fichier SEPA
if registerForm.sepa_file: if registerForm.sepa_file:
@ -332,9 +354,32 @@ class RegisterFormWithIdView(APIView):
# Mise à jour de l'automate pour une signature classique # Mise à jour de l'automate pour une signature classique
updateStateMachine(registerForm, 'EVENT_SIGNATURE') updateStateMachine(registerForm, 'EVENT_SIGNATURE')
except Exception as e: except Exception as e:
logger.error(f"[RF_UNDER_REVIEW] Exception: {e}")
import traceback
logger.error(traceback.format_exc())
return JsonResponse({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return JsonResponse({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
elif _status == RegistrationForm.RegistrationFormStatus.RF_SENT: elif _status == RegistrationForm.RegistrationFormStatus.RF_SENT:
if registerForm.status == RegistrationForm.RegistrationFormStatus.RF_UNDER_REVIEW: if registerForm.status == RegistrationForm.RegistrationFormStatus.RF_UNDER_REVIEW:
# Envoi de l'email de refus aux responsables légaux
try:
student = registerForm.student
student_name = f"{student.first_name} {student.last_name}"
notes = registerForm.notes or "Aucun motif spécifié"
guardians = student.guardians.all()
for guardian in guardians:
email = None
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
email = guardian.profile_role.profile.email
if not email:
email = getattr(guardian, "email", None)
if email:
logger.info(f"[RF_SENT] Envoi email de refus à {email} pour l'élève {student_name}")
mailer.sendRefusDossier(email, registerForm.establishment.pk, student_name, notes)
except Exception as e:
logger.error(f"[RF_SENT] Erreur lors de l'envoi de l'email de refus: {e}")
updateStateMachine(registerForm, 'EVENT_REFUSE') updateStateMachine(registerForm, 'EVENT_REFUSE')
util.delete_registration_files(registerForm) util.delete_registration_files(registerForm)
elif _status == RegistrationForm.RegistrationFormStatus.RF_SEPA_SENT: elif _status == RegistrationForm.RegistrationFormStatus.RF_SEPA_SENT:
@ -377,14 +422,23 @@ class RegisterFormWithIdView(APIView):
fileNames.extend(parent_file_templates) fileNames.extend(parent_file_templates)
# Création du fichier PDF fusionné # Création du fichier PDF fusionné
merged_pdf_content = util.merge_files_pdf(fileNames) merged_pdf_content = None
try:
merged_pdf_content = util.merge_files_pdf(fileNames)
# Mise à jour du champ registration_file avec le fichier fusionné # Mise à jour du champ fusion_file avec le fichier fusionné
registerForm.fusion_file.save( util.save_file_replacing_existing(
f"dossier_complet.pdf", registerForm.fusion_file,
File(merged_pdf_content), "dossier_complet.pdf",
save=True File(merged_pdf_content),
) save=True
)
except Exception as e:
logger.error(f"[RF_VALIDATED] Erreur lors de la fusion des fichiers: {e}")
finally:
# Libérer explicitement la mémoire du BytesIO
if merged_pdf_content is not None:
merged_pdf_content.close()
# Valorisation des StudentCompetency pour l'élève # Valorisation des StudentCompetency pour l'élève
try: try:
student = registerForm.student student = registerForm.student
@ -426,8 +480,65 @@ class RegisterFormWithIdView(APIView):
except Exception as e: except Exception as e:
logger.error(f"Erreur lors de la valorisation des StudentCompetency: {e}") logger.error(f"Erreur lors de la valorisation des StudentCompetency: {e}")
# Envoi de l'email de validation aux responsables légaux (en arrière-plan)
def send_validation_emails():
try:
student = registerForm.student
student_name = f"{student.first_name} {student.last_name}"
class_name = None
if student.associated_class:
class_name = student.associated_class.atmosphere_name
guardians = student.guardians.all()
for guardian in guardians:
email = None
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
email = guardian.profile_role.profile.email
if not email:
email = getattr(guardian, "email", None)
if email:
logger.info(f"[RF_VALIDATED] Envoi email de validation à {email} pour l'élève {student_name}")
mailer.sendValidationDossier(email, registerForm.establishment.pk, student_name, class_name)
except Exception as e:
logger.error(f"[RF_VALIDATED] Erreur lors de l'envoi de l'email de validation: {e}")
# Lancer l'envoi d'email dans un thread séparé pour ne pas bloquer la réponse
email_thread = threading.Thread(target=send_validation_emails)
email_thread.start()
updateStateMachine(registerForm, 'EVENT_VALIDATE') updateStateMachine(registerForm, 'EVENT_VALIDATE')
elif _status == RegistrationForm.RegistrationFormStatus.RF_ARCHIVED:
# Vérifier si on vient de l'état "À valider" (RF_UNDER_REVIEW) pour un refus définitif
if registerForm.status == RegistrationForm.RegistrationFormStatus.RF_UNDER_REVIEW:
# Envoi de l'email de refus définitif aux responsables légaux (en arrière-plan)
def send_refus_definitif_emails():
try:
student = registerForm.student
student_name = f"{student.first_name} {student.last_name}"
notes = data.get('notes', '') or "Aucun motif spécifié"
guardians = student.guardians.all()
for guardian in guardians:
email = None
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
email = guardian.profile_role.profile.email
if not email:
email = getattr(guardian, "email", None)
if email:
logger.info(f"[RF_ARCHIVED] Envoi email de refus définitif à {email} pour l'élève {student_name}")
mailer.sendRefusDefinitif(email, registerForm.establishment.pk, student_name, notes)
except Exception as e:
logger.error(f"[RF_ARCHIVED] Erreur lors de l'envoi de l'email de refus définitif: {e}")
# Lancer l'envoi d'email dans un thread séparé pour ne pas bloquer la réponse
email_thread = threading.Thread(target=send_refus_definitif_emails)
email_thread.start()
updateStateMachine(registerForm, 'EVENT_ARCHIVE')
# Retourner les données mises à jour # Retourner les données mises à jour
return JsonResponse(studentForm_serializer.data, safe=False) return JsonResponse(studentForm_serializer.data, safe=False)

View File

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

View File

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

Binary file not shown.

2
Back-End/runTests.sh Executable file
View File

@ -0,0 +1,2 @@
#!/bin/bash
docker exec -it n3wt-school-backend-1 python3 manage.py test --settings=N3wtSchool.test_settings Auth.tests

View File

@ -66,10 +66,10 @@ if __name__ == "__main__":
if run_command(command) != 0: if run_command(command) != 0:
exit(1) exit(1)
if migrate_data:
for command in migrate_commands: for command in migrate_commands:
if run_command(command) != 0: if run_command(command) != 0:
exit(1) exit(1)
for command in commands: for command in commands:
if run_command(command) != 0: if run_command(command) != 0:

View File

@ -1,4 +0,0 @@
{
"presets": ["next/babel"],
"plugins": []
}

View File

@ -11,7 +11,6 @@ import {
Award, Award,
Calendar, Calendar,
Settings, Settings,
LogOut,
MessageSquare, MessageSquare,
} from 'lucide-react'; } from 'lucide-react';
@ -29,16 +28,14 @@ import {
import { disconnect } from '@/app/actions/authAction'; import { disconnect } from '@/app/actions/authAction';
import ProtectedRoute from '@/components/ProtectedRoute'; import ProtectedRoute from '@/components/ProtectedRoute';
import { getGravatarUrl } from '@/utils/gravatar';
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
import { getRightStr, RIGHTS } from '@/utils/rights'; import { RIGHTS } from '@/utils/rights';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import ProfileSelector from '@/components/ProfileSelector';
export default function Layout({ children }) { export default function Layout({ children }) {
const t = useTranslations('sidebar'); const t = useTranslations('sidebar');
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const { profileRole, establishments, user, clearContext } = const { profileRole, establishments, clearContext } =
useEstablishment(); useEstablishment();
const sidebarItems = { const sidebarItems = {
@ -97,45 +94,15 @@ export default function Layout({ children }) {
const pathname = usePathname(); const pathname = usePathname();
const currentPage = pathname.split('/').pop(); const currentPage = pathname.split('/').pop();
const headerTitle = sidebarItems[currentPage]?.name || t('dashboard');
const softwareName = 'N3WT School'; const softwareName = 'N3WT School';
const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`; const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`;
const handleDisconnect = () => {
setIsPopupVisible(true);
};
const confirmDisconnect = () => { const confirmDisconnect = () => {
setIsPopupVisible(false); setIsPopupVisible(false);
disconnect(); disconnect();
clearContext(); clearContext();
}; };
const dropdownItems = [
{
type: 'info',
content: (
<div className="px-4 py-2">
<div className="font-medium">{user?.email || 'Utilisateur'}</div>
<div className="text-xs text-gray-400">
{getRightStr(profileRole) || ''}
</div>
</div>
),
},
{
type: 'separator',
content: <hr className="my-2 border-gray-200" />,
},
{
type: 'item',
label: 'Déconnexion',
onClick: handleDisconnect,
icon: LogOut,
},
];
const toggleSidebar = () => { const toggleSidebar = () => {
setIsSidebarOpen(!isSidebarOpen); setIsSidebarOpen(!isSidebarOpen);
}; };
@ -145,6 +112,15 @@ export default function Layout({ children }) {
setIsSidebarOpen(false); setIsSidebarOpen(false);
}, [pathname]); }, [pathname]);
// Filtrage dynamique des items de la sidebar selon le rôle
let sidebarItemsToDisplay = Object.values(sidebarItems);
if (profileRole === 0) {
// Si pas admin, on retire "directory" et "settings"
sidebarItemsToDisplay = sidebarItemsToDisplay.filter(
(item) => item.id !== 'directory' && item.id !== 'settings'
);
}
return ( return (
<ProtectedRoute requiredRight={[RIGHTS.ADMIN, RIGHTS.TEACHER]}> <ProtectedRoute requiredRight={[RIGHTS.ADMIN, RIGHTS.TEACHER]}>
{/* Sidebar */} {/* Sidebar */}
@ -156,7 +132,7 @@ export default function Layout({ children }) {
<Sidebar <Sidebar
establishments={establishments} establishments={establishments}
currentPage={currentPage} currentPage={currentPage}
items={Object.values(sidebarItems)} items={sidebarItemsToDisplay}
onCloseMobile={toggleSidebar} onCloseMobile={toggleSidebar}
/> />
</div> </div>

View File

@ -31,6 +31,7 @@ import { fetchRegistrationSchoolFileMasters } from '@/app/actions/registerFileGr
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { PlanningProvider, PlanningModes } from '@/context/PlanningContext'; import { PlanningProvider, PlanningModes } from '@/context/PlanningContext';
import { updatePlanning } from '@/app/actions/planningAction';
import CompetenciesList from '@/components/Structure/Competencies/CompetenciesList'; import CompetenciesList from '@/components/Structure/Competencies/CompetenciesList';
export default function Page() { export default function Page() {
@ -52,7 +53,7 @@ export default function Page() {
); );
const csrfToken = useCsrfToken(); const csrfToken = useCsrfToken();
const { selectedEstablishmentId } = useEstablishment(); const { selectedEstablishmentId, profileRole } = useEstablishment();
useEffect(() => { useEffect(() => {
if (selectedEstablishmentId) { if (selectedEstablishmentId) {
@ -259,20 +260,10 @@ export default function Page() {
}); });
}; };
const handleUpdatePlanning = (url, planningId, updatedData) => { const handleUpdatePlanning = (planningId, updatedData) => {
fetch(`${url}/${planningId}`, { updatePlanning(planningId, updatedData, csrfToken)
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(updatedData),
credentials: 'include',
})
.then((response) => response.json())
.then((data) => { .then((data) => {
logger.debug('Planning mis à jour avec succès :', data); logger.debug('Planning mis à jour avec succès :', data);
//setDatas(data);
}) })
.catch((error) => { .catch((error) => {
logger.error('Erreur :', error); logger.error('Erreur :', error);
@ -316,35 +307,39 @@ export default function Page() {
</div> </div>
), ),
}, },
{ ...(profileRole !== 0
id: 'Fees', ? [
label: 'Tarifs', {
content: ( id: 'Fees',
<div className="h-full overflow-y-auto p-4"> label: 'Tarifs',
<FeesManagement content: (
registrationDiscounts={registrationDiscounts} <div className="h-full overflow-y-auto p-4">
setRegistrationDiscounts={setRegistrationDiscounts} <FeesManagement
tuitionDiscounts={tuitionDiscounts} registrationDiscounts={registrationDiscounts}
setTuitionDiscounts={setTuitionDiscounts} setRegistrationDiscounts={setRegistrationDiscounts}
registrationFees={registrationFees} tuitionDiscounts={tuitionDiscounts}
setRegistrationFees={setRegistrationFees} setTuitionDiscounts={setTuitionDiscounts}
tuitionFees={tuitionFees} registrationFees={registrationFees}
setTuitionFees={setTuitionFees} setRegistrationFees={setRegistrationFees}
registrationPaymentPlans={registrationPaymentPlans} tuitionFees={tuitionFees}
setRegistrationPaymentPlans={setRegistrationPaymentPlans} setTuitionFees={setTuitionFees}
tuitionPaymentPlans={tuitionPaymentPlans} registrationPaymentPlans={registrationPaymentPlans}
setTuitionPaymentPlans={setTuitionPaymentPlans} setRegistrationPaymentPlans={setRegistrationPaymentPlans}
registrationPaymentModes={registrationPaymentModes} tuitionPaymentPlans={tuitionPaymentPlans}
setRegistrationPaymentModes={setRegistrationPaymentModes} setTuitionPaymentPlans={setTuitionPaymentPlans}
tuitionPaymentModes={tuitionPaymentModes} registrationPaymentModes={registrationPaymentModes}
setTuitionPaymentModes={setTuitionPaymentModes} setRegistrationPaymentModes={setRegistrationPaymentModes}
handleCreate={handleCreate} tuitionPaymentModes={tuitionPaymentModes}
handleEdit={handleEdit} setTuitionPaymentModes={setTuitionPaymentModes}
handleDelete={handleDelete} handleCreate={handleCreate}
/> handleEdit={handleEdit}
</div> handleDelete={handleDelete}
), />
}, </div>
),
},
]
: []),
{ {
id: 'Files', id: 'Files',
label: 'Documents', label: 'Documents',
@ -353,6 +348,7 @@ export default function Page() {
<FilesGroupsManagement <FilesGroupsManagement
csrfToken={csrfToken} csrfToken={csrfToken}
selectedEstablishmentId={selectedEstablishmentId} selectedEstablishmentId={selectedEstablishmentId}
profileRole={profileRole}
/> />
</div> </div>
), ),

View File

@ -2,6 +2,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Tab from '@/components/Tab'; import Tab from '@/components/Tab';
import Textarea from '@/components/Textarea';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import StatusLabel from '@/components/StatusLabel'; import StatusLabel from '@/components/StatusLabel';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
@ -17,6 +18,7 @@ import {
Plus, Plus,
Upload, Upload,
Eye, Eye,
XCircle,
} from 'lucide-react'; } from 'lucide-react';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
@ -83,12 +85,9 @@ export default function Page({ params: { locale } }) {
const [totalHistorical, setTotalHistorical] = useState(0); const [totalHistorical, setTotalHistorical] = useState(0);
const [itemsPerPage, setItemsPerPage] = useState(10); // Définir le nombre d'éléments par page const [itemsPerPage, setItemsPerPage] = useState(10); // Définir le nombre d'éléments par page
const [student, setStudent] = useState('');
const [classes, setClasses] = useState([]); const [classes, setClasses] = useState([]);
const [reloadFetch, setReloadFetch] = useState(false); const [reloadFetch, setReloadFetch] = useState(false);
const [isOpenAddGuardian, setIsOpenAddGuardian] = useState(false);
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
const [selectedRegisterForm, setSelectedRegisterForm] = useState([]); const [selectedRegisterForm, setSelectedRegisterForm] = useState([]);
@ -99,9 +98,40 @@ export default function Page({ params: { locale } }) {
const [isSepaUploadModalOpen, setIsSepaUploadModalOpen] = useState(false); const [isSepaUploadModalOpen, setIsSepaUploadModalOpen] = useState(false);
const [selectedRowForUpload, setSelectedRowForUpload] = useState(null); const [selectedRowForUpload, setSelectedRowForUpload] = useState(null);
// Refus popup state
const [isRefusePopupOpen, setIsRefusePopupOpen] = useState(false);
const [refuseReason, setRefuseReason] = useState('');
const [rowToRefuse, setRowToRefuse] = useState(null);
// Ouvre la popup de refus
const openRefusePopup = (row) => {
setRowToRefuse(row);
setRefuseReason('');
setIsRefusePopupOpen(true);
};
// Valide le refus
const handleRefuse = () => {
if (!refuseReason.trim()) {
showNotification('Merci de préciser la raison du refus.', 'error', 'Erreur');
return;
}
const formData = new FormData();
formData.append('data', JSON.stringify({ status: RegistrationFormStatus.STATUS_ARCHIVED, notes: refuseReason }));
editRegisterForm(rowToRefuse.student.id, formData, csrfToken)
.then(() => {
showNotification('Le dossier a été refusé et archivé.', 'success', 'Succès');
setReloadFetch(true);
setIsRefusePopupOpen(false);
})
.catch(() => {
showNotification('Erreur lors du refus du dossier.', 'error', 'Erreur');
});
};
const csrfToken = useCsrfToken(); const csrfToken = useCsrfToken();
const router = useRouter(); const router = useRouter();
const { selectedEstablishmentId } = useEstablishment(); const { selectedEstablishmentId, profileRole } = useEstablishment();
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const openSepaUploadModal = (row) => { const openSepaUploadModal = (row) => {
@ -490,10 +520,18 @@ export default function Page({ params: { locale } }) {
</span> </span>
), ),
onClick: () => { onClick: () => {
const url = `${FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL}?studentId=${row.student.id}&firstName=${row.student.first_name}&lastName=${row.student.last_name}&level=${row.student.level}&sepa_file=${row.sepa_file}&student_file=${row.registration_file}`; const url = `${FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL}?studentId=${row.student.id}&firstName=${row.student.first_name}&lastName=${row.student.last_name}&email=${row.student.guardians[0].associated_profile_email}&level=${row.student.level}&sepa_file=${row.sepa_file}&student_file=${row.registration_file}`;
router.push(`${url}`); router.push(`${url}`);
}, },
}, },
{
icon: (
<span title="Refuser le dossier">
<XCircle className="w-5 h-5 text-red-500 hover:text-red-700" />
</span>
),
onClick: () => openRefusePopup(row),
},
], ],
// Etat "A relancer" - NON TESTE // Etat "A relancer" - NON TESTE
[RegistrationFormStatus.STATUS_TO_FOLLOW_UP]: [ [RegistrationFormStatus.STATUS_TO_FOLLOW_UP]: [
@ -801,15 +839,17 @@ export default function Page({ params: { locale } }) {
onChange={handleSearchChange} onChange={handleSearchChange}
/> />
</div> </div>
<button {profileRole !== 0 && (
onClick={() => { <button
const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`; onClick={() => {
router.push(url); const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`;
}} router.push(url);
className="flex items-center bg-emerald-600 text-white p-2 rounded-full shadow hover:bg-emerald-900 transition duration-200 ml-4" }}
> className="flex items-center bg-emerald-600 text-white p-2 rounded-full shadow hover:bg-emerald-900 transition duration-200 ml-4"
<Plus className="w-5 h-5" /> >
</button> <Plus className="w-5 h-5" />
</button>
)}
</div> </div>
<div className="w-full"> <div className="w-full">
@ -853,6 +893,25 @@ export default function Page({ params: { locale } }) {
onCancel={() => setConfirmPopupVisible(false)} onCancel={() => setConfirmPopupVisible(false)}
/> />
{/* Popup de refus de dossier */}
<Popup
isOpen={isRefusePopupOpen}
message={
<div>
<div className="mb-2 font-semibold">Veuillez indiquer la raison du refus :</div>
<Textarea
value={refuseReason}
onChange={(e) => setRefuseReason(e.target.value)}
placeholder="Ex : Réception de dossier trop tardive"
rows={3}
className="w-full"
/>
</div>
}
onConfirm={handleRefuse}
onCancel={() => setIsRefusePopupOpen(false)}
/>
{isSepaUploadModalOpen && ( {isSepaUploadModalOpen && (
<Modal <Modal
isOpen={isSepaUploadModalOpen} isOpen={isSepaUploadModalOpen}

View File

@ -10,8 +10,10 @@ import Loader from '@/components/Loader';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url'; import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url';
import { useNotification } from '@/context/NotificationContext'; import { useNotification } from '@/context/NotificationContext';
import { editRegistrationSchoolFileTemplates, editRegistrationParentFileTemplates } from '@/app/actions/registerFileGroupAction';
export default function Page() { export default function Page() {
const [isLoadingRefuse, setIsLoadingRefuse] = useState(false);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -20,6 +22,7 @@ export default function Page() {
const studentId = searchParams.get('studentId'); const studentId = searchParams.get('studentId');
const firstName = searchParams.get('firstName'); const firstName = searchParams.get('firstName');
const lastName = searchParams.get('lastName'); const lastName = searchParams.get('lastName');
const email = searchParams.get('email');
const level = searchParams.get('level'); const level = searchParams.get('level');
const sepa_file = const sepa_file =
searchParams.get('sepa_file') === 'null' searchParams.get('sepa_file') === 'null'
@ -84,6 +87,45 @@ export default function Page() {
}); });
}; };
const handleRefuseRF = (data) => {
const formData = new FormData();
formData.append('data', JSON.stringify(data));
editRegisterForm(studentId, formData, csrfToken)
.then((response) => {
logger.debug('RF refusé et archivé:', response);
showNotification('Le dossier a été refusé et archivé.', 'success', 'Succès');
router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
setIsLoadingRefuse(false);
})
.catch((error) => {
showNotification('Erreur lors du refus du dossier.', 'error', 'Erreur');
setIsLoadingRefuse(false);
logger.error('Erreur lors du refus du RF:', error);
});
};
// Validation/refus d'un document individuel (hors fiche élève)
const handleValidateOrRefuseDoc = ({ templateId, type, validated, csrfToken }) => {
if (!templateId) return;
let editFn = null;
if (type === 'school') {
editFn = editRegistrationSchoolFileTemplates;
} else if (type === 'parent') {
editFn = editRegistrationParentFileTemplates;
}
if (!editFn) return;
const updateData = new FormData();
updateData.append('data', JSON.stringify({ isValidated: validated }));
editFn(templateId, updateData, csrfToken)
.then((response) => {
logger.debug(`Document ${validated ? 'validé' : 'refusé'} (type: ${type}, id: ${templateId})`, response);
})
.catch((error) => {
logger.error('Erreur lors de la validation/refus du document:', error);
});
};
if (isLoading) { if (isLoading) {
return <Loader />; return <Loader />;
} }
@ -93,10 +135,15 @@ export default function Page() {
studentId={studentId} studentId={studentId}
firstName={firstName} firstName={firstName}
lastName={lastName} lastName={lastName}
email={email}
sepa_file={sepa_file} sepa_file={sepa_file}
student_file={student_file} student_file={student_file}
onAccept={handleAcceptRF} onAccept={handleAcceptRF}
classes={classes} classes={classes}
onRefuse={handleRefuseRF}
isLoadingRefuse={isLoadingRefuse}
handleValidateOrRefuseDoc={handleValidateOrRefuseDoc}
csrfToken={csrfToken}
/> />
); );
} }

View File

@ -1,4 +1,15 @@
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { signOut } from 'next-auth/react';
let isSigningOut = false;
export const triggerSignOut = async () => {
if (isSigningOut || typeof window === 'undefined') return;
isSigningOut = true;
logger.warn('Session expirée, déconnexion en cours...');
await signOut({ callbackUrl: '/users/login' });
};
/** /**
* *
* @param {*} response * @param {*} response
@ -6,6 +17,18 @@ import logger from '@/utils/logger';
*/ */
export const requestResponseHandler = async (response) => { export const requestResponseHandler = async (response) => {
try { try {
if (response.status === 401) {
// On lève une erreur plutôt que de déclencher un signOut automatique.
// Plusieurs requêtes concurrent pourraient déclencher des signOut en cascade.
// Le signOut est géré proprement via RefreshTokenError dans getAuthToken.
const body = await response.json().catch(() => ({}));
const error = new Error(
body?.detail || body?.errorMessage || 'Session expirée'
);
error.status = 401;
throw error;
}
const body = await response?.json(); const body = await response?.json();
if (response.ok) { if (response.ok) {
return body; return body;

View File

@ -1,5 +1,6 @@
import { signOut, signIn } from 'next-auth/react'; import { signOut, signIn } from 'next-auth/react';
import { errorHandler, requestResponseHandler } from './actionsHandlers'; import { errorHandler, requestResponseHandler } from './actionsHandlers';
import { fetchWithAuth } from '@/utils/fetchWithAuth';
import { import {
BE_AUTH_LOGIN_URL, BE_AUTH_LOGIN_URL,
BE_AUTH_REFRESH_JWT_URL, BE_AUTH_REFRESH_JWT_URL,
@ -73,92 +74,49 @@ export const fetchProfileRoles = (
if (page !== '' && pageSize !== '') { if (page !== '' && pageSize !== '') {
url = `${BE_AUTH_PROFILES_ROLES_URL}?filter=${filter}&establishment_id=${establishment}&page=${page}`; url = `${BE_AUTH_PROFILES_ROLES_URL}?filter=${filter}&establishment_id=${establishment}&page=${page}`;
} }
return fetch(url, { return fetchWithAuth(url);
headers: {
'Content-Type': 'application/json',
},
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const updateProfileRoles = (id, data, csrfToken) => { export const updateProfileRoles = (id, data, csrfToken) => {
const request = new Request(`${BE_AUTH_PROFILES_ROLES_URL}/${id}`, { return fetchWithAuth(`${BE_AUTH_PROFILES_ROLES_URL}/${id}`, {
method: 'PUT', method: 'PUT',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
credentials: 'include',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
return fetch(request).then(requestResponseHandler).catch(errorHandler);
}; };
export const deleteProfileRoles = async (id, csrfToken) => { export const deleteProfileRoles = (id, csrfToken) => {
const response = await fetch(`${BE_AUTH_PROFILES_ROLES_URL}/${id}`, { return fetchWithAuth(`${BE_AUTH_PROFILES_ROLES_URL}/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: { 'X-CSRFToken': csrfToken },
'X-CSRFToken': csrfToken,
},
credentials: 'include',
}); });
if (!response.ok) {
// Extraire le message d'erreur du backend
const errorData = await response.json();
const errorMessage =
errorData?.error ||
'Une erreur est survenue lors de la suppression du profil.';
// Jeter une erreur avec le message spécifique
throw new Error(errorMessage);
}
return response.json();
}; };
export const fetchProfiles = () => { export const fetchProfiles = () => {
return fetch(`${BE_AUTH_PROFILES_URL}`) return fetchWithAuth(`${BE_AUTH_PROFILES_URL}`);
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const createProfile = (data, csrfToken) => { export const createProfile = (data, csrfToken) => {
const request = new Request(`${BE_AUTH_PROFILES_URL}`, { return fetchWithAuth(`${BE_AUTH_PROFILES_URL}`, {
method: 'POST', method: 'POST',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
credentials: 'include',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
return fetch(request).then(requestResponseHandler).catch(errorHandler);
}; };
export const deleteProfile = (id, csrfToken) => { export const deleteProfile = (id, csrfToken) => {
const request = new Request(`${BE_AUTH_PROFILES_URL}/${id}`, { return fetchWithAuth(`${BE_AUTH_PROFILES_URL}/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: { 'X-CSRFToken': csrfToken },
'X-CSRFToken': csrfToken,
},
credentials: 'include',
}); });
return fetch(request).then(requestResponseHandler).catch(errorHandler);
}; };
export const updateProfile = (id, data, csrfToken) => { export const updateProfile = (id, data, csrfToken) => {
const request = new Request(`${BE_AUTH_PROFILES_URL}/${id}`, { return fetchWithAuth(`${BE_AUTH_PROFILES_URL}/${id}`, {
method: 'PUT', method: 'PUT',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
credentials: 'include',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
return fetch(request).then(requestResponseHandler).catch(errorHandler);
}; };
export const sendNewPassword = (data, csrfToken) => { export const sendNewPassword = (data, csrfToken) => {

View File

@ -2,33 +2,20 @@ import {
BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL, BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL,
BE_GESTIONEMAIL_SEND_EMAIL_URL, BE_GESTIONEMAIL_SEND_EMAIL_URL,
} from '@/utils/Url'; } from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers'; import { fetchWithAuth } from '@/utils/fetchWithAuth';
import { getCsrfToken } from '@/utils/getCsrfToken'; import { getCsrfToken } from '@/utils/getCsrfToken';
// Recherche de destinataires pour email // Recherche de destinataires pour email
export const searchRecipients = (establishmentId, query) => { export const searchRecipients = (establishmentId, query) => {
const url = `${BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL}/?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`; const url = `${BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL}/?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`;
return fetch(url, { return fetchWithAuth(url);
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
// Envoyer un email // Envoyer un email
export const sendEmail = async (messageData) => { export const sendEmail = async (messageData) => {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
return fetch(BE_GESTIONEMAIL_SEND_EMAIL_URL, { return fetchWithAuth(BE_GESTIONEMAIL_SEND_EMAIL_URL, {
method: 'POST', method: 'POST',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
credentials: 'include',
body: JSON.stringify(messageData), body: JSON.stringify(messageData),
}) });
.then(requestResponseHandler)
.catch(errorHandler);
}; };

View File

@ -7,23 +7,9 @@ import {
BE_GESTIONMESSAGERIE_UPLOAD_FILE_URL, BE_GESTIONMESSAGERIE_UPLOAD_FILE_URL,
BE_GESTIONMESSAGERIE_DELETE_CONVERSATION_URL, BE_GESTIONMESSAGERIE_DELETE_CONVERSATION_URL,
} from '@/utils/Url'; } from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers'; import { fetchWithAuth, getAuthToken } from '@/utils/fetchWithAuth';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
// Helper pour construire les en-têtes avec CSRF
const buildHeaders = (csrfToken) => {
const headers = {
'Content-Type': 'application/json',
};
// Ajouter le token CSRF
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
return headers;
};
/** /**
* Récupère les conversations d'un utilisateur * Récupère les conversations d'un utilisateur
*/ */
@ -31,15 +17,12 @@ export const fetchConversations = async (userId, csrfToken) => {
try { try {
// Utiliser la nouvelle route avec user_id en paramètre d'URL // Utiliser la nouvelle route avec user_id en paramètre d'URL
const url = `${BE_GESTIONMESSAGERIE_CONVERSATIONS_URL}/user/${userId}/`; const url = `${BE_GESTIONMESSAGERIE_CONVERSATIONS_URL}/user/${userId}/`;
const response = await fetch(url, { return await fetchWithAuth(url, {
method: 'GET', headers: { 'X-CSRFToken': csrfToken },
headers: buildHeaders(csrfToken),
credentials: 'include',
}); });
return await requestResponseHandler(response);
} catch (error) { } catch (error) {
logger.error('Erreur lors de la récupération des conversations:', error); logger.error('Erreur lors de la récupération des conversations:', error);
return errorHandler(error); throw error;
} }
}; };
@ -62,15 +45,12 @@ export const fetchMessages = async (
url += `&user_id=${userId}`; url += `&user_id=${userId}`;
} }
const response = await fetch(url, { return await fetchWithAuth(url, {
method: 'GET', headers: { 'X-CSRFToken': csrfToken },
headers: buildHeaders(csrfToken),
credentials: 'include',
}); });
return await requestResponseHandler(response);
} catch (error) { } catch (error) {
logger.error('Erreur lors de la récupération des messages:', error); logger.error('Erreur lors de la récupération des messages:', error);
return errorHandler(error); throw error;
} }
}; };
@ -79,16 +59,14 @@ export const fetchMessages = async (
*/ */
export const sendMessage = async (messageData, csrfToken) => { export const sendMessage = async (messageData, csrfToken) => {
try { try {
const response = await fetch(BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL, { return await fetchWithAuth(BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL, {
method: 'POST', method: 'POST',
headers: buildHeaders(csrfToken), headers: { 'X-CSRFToken': csrfToken },
credentials: 'include',
body: JSON.stringify(messageData), body: JSON.stringify(messageData),
}); });
return await requestResponseHandler(response);
} catch (error) { } catch (error) {
logger.error("Erreur lors de l'envoi du message:", error); logger.error("Erreur lors de l'envoi du message:", error);
return errorHandler(error); throw error;
} }
}; };
@ -103,17 +81,14 @@ export const createConversation = async (participantIds, csrfToken) => {
name: '', // Le nom sera généré côté backend name: '', // Le nom sera généré côté backend
}; };
const response = await fetch(BE_GESTIONMESSAGERIE_CREATE_CONVERSATION_URL, { return await fetchWithAuth(BE_GESTIONMESSAGERIE_CREATE_CONVERSATION_URL, {
method: 'POST', method: 'POST',
headers: buildHeaders(csrfToken), headers: { 'X-CSRFToken': csrfToken },
credentials: 'include',
body: JSON.stringify(requestBody), body: JSON.stringify(requestBody),
}); });
return await requestResponseHandler(response);
} catch (error) { } catch (error) {
logger.error('Erreur lors de la création de la conversation:', error); logger.error('Erreur lors de la création de la conversation:', error);
return errorHandler(error); throw error;
} }
}; };
@ -132,16 +107,12 @@ export const searchMessagerieRecipients = async (
const url = `${baseUrl}?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`; const url = `${baseUrl}?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`;
const response = await fetch(url, { return await fetchWithAuth(url, {
method: 'GET', headers: { 'X-CSRFToken': csrfToken },
headers: buildHeaders(csrfToken),
credentials: 'include',
}); });
return await requestResponseHandler(response);
} catch (error) { } catch (error) {
logger.error('Erreur lors de la recherche des destinataires:', error); logger.error('Erreur lors de la recherche des destinataires:', error);
return errorHandler(error); throw error;
} }
}; };
@ -150,19 +121,17 @@ export const searchMessagerieRecipients = async (
*/ */
export const markAsRead = async (conversationId, userId, csrfToken) => { export const markAsRead = async (conversationId, userId, csrfToken) => {
try { try {
const response = await fetch(BE_GESTIONMESSAGERIE_MARK_AS_READ_URL, { return await fetchWithAuth(BE_GESTIONMESSAGERIE_MARK_AS_READ_URL, {
method: 'POST', method: 'POST',
headers: buildHeaders(csrfToken), headers: { 'X-CSRFToken': csrfToken },
credentials: 'include',
body: JSON.stringify({ body: JSON.stringify({
conversation_id: conversationId, conversation_id: conversationId,
user_id: userId, user_id: userId,
}), }),
}); });
return await requestResponseHandler(response);
} catch (error) { } catch (error) {
logger.error('Erreur lors du marquage des messages comme lus:', error); logger.error('Erreur lors du marquage des messages comme lus:', error);
return errorHandler(error); throw error;
} }
}; };
@ -181,6 +150,7 @@ export const uploadFile = async (
formData.append('conversation_id', conversationId); formData.append('conversation_id', conversationId);
formData.append('sender_id', senderId); formData.append('sender_id', senderId);
const token = await getAuthToken();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
@ -223,7 +193,10 @@ export const uploadFile = async (
xhr.withCredentials = true; xhr.withCredentials = true;
xhr.timeout = 30000; xhr.timeout = 30000;
// Ajouter le header CSRF pour XMLHttpRequest // Ajouter les headers d'authentification pour XMLHttpRequest
if (token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
}
if (csrfToken) { if (csrfToken) {
xhr.setRequestHeader('X-CSRFToken', csrfToken); xhr.setRequestHeader('X-CSRFToken', csrfToken);
} }
@ -238,14 +211,12 @@ export const uploadFile = async (
export const deleteConversation = async (conversationId, csrfToken) => { export const deleteConversation = async (conversationId, csrfToken) => {
try { try {
const url = `${BE_GESTIONMESSAGERIE_DELETE_CONVERSATION_URL}/${conversationId}/`; const url = `${BE_GESTIONMESSAGERIE_DELETE_CONVERSATION_URL}/${conversationId}/`;
const response = await fetch(url, { return await fetchWithAuth(url, {
method: 'DELETE', method: 'DELETE',
headers: buildHeaders(csrfToken), headers: { 'X-CSRFToken': csrfToken },
credentials: 'include',
}); });
return await requestResponseHandler(response);
} catch (error) { } catch (error) {
logger.error('Erreur lors de la suppression de la conversation:', error); logger.error('Erreur lors de la suppression de la conversation:', error);
return errorHandler(error); throw error;
} }
}; };

View File

@ -1,49 +1,31 @@
import { BE_PLANNING_PLANNINGS_URL, BE_PLANNING_EVENTS_URL } from '@/utils/Url'; import { BE_PLANNING_PLANNINGS_URL, BE_PLANNING_EVENTS_URL } from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers'; import { fetchWithAuth } from '@/utils/fetchWithAuth';
const getData = (url) => { const getData = (url) => {
return fetch(`${url}`).then(requestResponseHandler).catch(errorHandler); return fetchWithAuth(url);
}; };
const createDatas = (url, newData, csrfToken) => { const createDatas = (url, newData, csrfToken) => {
return fetch(url, { return fetchWithAuth(url, {
method: 'POST', method: 'POST',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(newData), body: JSON.stringify(newData),
credentials: 'include', });
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
const updateDatas = (url, updatedData, csrfToken) => { const updateDatas = (url, updatedData, csrfToken) => {
return fetch(`${url}`, { return fetchWithAuth(url, {
method: 'PUT', method: 'PUT',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(updatedData), body: JSON.stringify(updatedData),
credentials: 'include', });
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
const removeDatas = (url, csrfToken) => { const removeDatas = (url, csrfToken) => {
return fetch(`${url}`, { return fetchWithAuth(url, {
method: 'DELETE', method: 'DELETE',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json', });
'X-CSRFToken': csrfToken,
},
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchPlannings = ( export const fetchPlannings = (

View File

@ -5,213 +5,113 @@ import {
BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL, BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL,
BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL
} from '@/utils/Url'; } from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers'; import { fetchWithAuth, fetchWithAuthRaw } from '@/utils/fetchWithAuth';
// FETCH requests // FETCH requests
export async function fetchRegistrationFileGroups(establishment) { export async function fetchRegistrationFileGroups(establishment) {
const response = await fetch( return fetchWithAuth(
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}?establishment_id=${establishment}`, `${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}?establishment_id=${establishment}`
{
credentials: 'include',
headers: {
Accept: 'application/json',
},
}
); );
if (!response.ok) {
throw new Error('Failed to fetch file groups');
}
return response.json();
} }
export const fetchRegistrationFileFromGroup = async (groupId) => { export const fetchRegistrationFileFromGroup = (groupId) => {
const response = await fetch( return fetchWithAuth(
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}/${groupId}/school_file_templates`, `${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}/${groupId}/school_file_templates`
{
credentials: 'include',
headers: {
Accept: 'application/json',
},
}
); );
if (!response.ok) {
throw new Error(
'Erreur lors de la récupération des fichiers associés au groupe'
);
}
return response.json();
}; };
export const fetchRegistrationSchoolFileMasters = (establishment) => { export const fetchRegistrationSchoolFileMasters = (establishment) => {
let url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}?establishment_id=${establishment}`; const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}?establishment_id=${establishment}`;
const request = new Request(`${url}`, { return fetchWithAuth(url);
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
}; };
export const fetchRegistrationParentFileMasters = (establishment) => { export const fetchRegistrationParentFileMasters = (establishment) => {
let url = `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}?establishment_id=${establishment}`; const url = `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}?establishment_id=${establishment}`;
const request = new Request(`${url}`, { return fetchWithAuth(url);
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
}; };
export const fetchRegistrationSchoolFileTemplates = (establishment) => { export const fetchRegistrationSchoolFileTemplates = (establishment) => {
let url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}?establishment_id=${establishment}`; const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}?establishment_id=${establishment}`;
const request = new Request(`${url}`, { return fetchWithAuth(url);
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
}; };
// CREATE requests // CREATE requests
export async function createRegistrationFileGroup(groupData, csrfToken) { export async function createRegistrationFileGroup(groupData, csrfToken) {
const response = await fetch( return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}`, {
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}`, method: 'POST',
{ headers: { 'X-CSRFToken': csrfToken },
method: 'POST', body: JSON.stringify(groupData),
headers: { });
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(groupData),
credentials: 'include',
}
);
if (!response.ok) {
throw new Error('Failed to create file group');
}
return response.json();
} }
export const createRegistrationSchoolFileMaster = (data, csrfToken) => { export const createRegistrationSchoolFileMaster = (data, csrfToken) => {
// Toujours FormData, jamais JSON // Toujours FormData, jamais JSON
return fetch(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}`, { return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}`, {
method: 'POST', method: 'POST',
headers: { 'X-CSRFToken': csrfToken },
body: data, body: data,
headers: { });
'X-CSRFToken': csrfToken,
},
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const createRegistrationParentFileMaster = (data, csrfToken) => { export const createRegistrationParentFileMaster = (data, csrfToken) => {
return fetch(`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}`, { return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}`, {
method: 'POST', method: 'POST',
headers: { 'X-CSRFToken': csrfToken },
body: JSON.stringify(data), body: JSON.stringify(data),
headers: { });
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const createRegistrationSchoolFileTemplate = (data, csrfToken) => { export const createRegistrationSchoolFileTemplate = (data, csrfToken) => {
return fetch(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}`, { return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}`, {
method: 'POST', method: 'POST',
headers: { 'X-CSRFToken': csrfToken },
body: JSON.stringify(data), body: JSON.stringify(data),
headers: { });
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const createRegistrationParentFileTemplate = (data, csrfToken) => { export const createRegistrationParentFileTemplate = (data, csrfToken) => {
return fetch(`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL}`, { return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL}`, {
method: 'POST', method: 'POST',
headers: { 'X-CSRFToken': csrfToken },
body: JSON.stringify(data), body: JSON.stringify(data),
headers: { });
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
// EDIT requests // EDIT requests
export const editRegistrationFileGroup = async ( export const editRegistrationFileGroup = (groupId, groupData, csrfToken) => {
groupId, return fetchWithAuth(
groupData,
csrfToken
) => {
const response = await fetch(
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}/${groupId}`, `${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}/${groupId}`,
{ {
method: 'PUT', method: 'PUT',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(groupData), body: JSON.stringify(groupData),
} }
); );
if (!response.ok) {
throw new Error('Erreur lors de la modification du groupe');
}
return response.json();
}; };
export const editRegistrationSchoolFileMaster = (fileId, data, csrfToken) => { export const editRegistrationSchoolFileMaster = (fileId, data, csrfToken) => {
return fetch( return fetchWithAuth(
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${fileId}`, `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${fileId}`,
{ {
method: 'PUT', method: 'PUT',
headers: { 'X-CSRFToken': csrfToken },
body: data, body: data,
headers: {
'X-CSRFToken': csrfToken,
},
credentials: 'include',
} }
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const editRegistrationParentFileMaster = (id, data, csrfToken) => { export const editRegistrationParentFileMaster = (id, data, csrfToken) => {
return fetch( return fetchWithAuth(
`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}/${id}`, `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}/${id}`,
{ {
method: 'PUT', method: 'PUT',
headers: { 'X-CSRFToken': csrfToken },
body: JSON.stringify(data), body: JSON.stringify(data),
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
credentials: 'include',
} }
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const editRegistrationSchoolFileTemplates = ( export const editRegistrationSchoolFileTemplates = (
@ -219,19 +119,14 @@ export const editRegistrationSchoolFileTemplates = (
data, data,
csrfToken csrfToken
) => { ) => {
return fetch( return fetchWithAuth(
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${fileId}`, `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${fileId}`,
{ {
method: 'PUT', method: 'PUT',
headers: { 'X-CSRFToken': csrfToken },
body: data, body: data,
headers: {
'X-CSRFToken': csrfToken,
},
credentials: 'include',
} }
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const editRegistrationParentFileTemplates = ( export const editRegistrationParentFileTemplates = (
@ -239,86 +134,64 @@ export const editRegistrationParentFileTemplates = (
data, data,
csrfToken csrfToken
) => { ) => {
return fetch( return fetchWithAuth(
`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL}/${fileId}`, `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL}/${fileId}`,
{ {
method: 'PUT', method: 'PUT',
headers: { 'X-CSRFToken': csrfToken },
body: data, body: data,
headers: {
'X-CSRFToken': csrfToken,
},
credentials: 'include',
} }
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
// DELETE requests // DELETE requests
export async function deleteRegistrationFileGroup(groupId, csrfToken) { export async function deleteRegistrationFileGroup(groupId, csrfToken) {
const response = await fetch( return fetchWithAuthRaw(
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}/${groupId}`, `${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}/${groupId}`,
{ {
method: 'DELETE', method: 'DELETE',
headers: { headers: { 'X-CSRFToken': csrfToken },
'X-CSRFToken': csrfToken,
},
credentials: 'include',
} }
); );
return response;
} }
export const deleteRegistrationSchoolFileMaster = (fileId, csrfToken) => { export const deleteRegistrationSchoolFileMaster = (fileId, csrfToken) => {
return fetch( return fetchWithAuthRaw(
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${fileId}`, `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${fileId}`,
{ {
method: 'DELETE', method: 'DELETE',
headers: { headers: { 'X-CSRFToken': csrfToken },
'X-CSRFToken': csrfToken,
},
credentials: 'include',
} }
); );
}; };
export const deleteRegistrationParentFileMaster = (id, csrfToken) => { export const deleteRegistrationParentFileMaster = (id, csrfToken) => {
return fetch( return fetchWithAuthRaw(
`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}/${id}`, `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}/${id}`,
{ {
method: 'DELETE', method: 'DELETE',
headers: { headers: { 'X-CSRFToken': csrfToken },
'X-CSRFToken': csrfToken,
},
credentials: 'include',
} }
); );
}; };
export const deleteRegistrationSchoolFileTemplates = (fileId, csrfToken) => { export const deleteRegistrationSchoolFileTemplates = (fileId, csrfToken) => {
return fetch( return fetchWithAuthRaw(
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${fileId}`, `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${fileId}`,
{ {
method: 'DELETE', method: 'DELETE',
headers: { headers: { 'X-CSRFToken': csrfToken },
'X-CSRFToken': csrfToken,
},
credentials: 'include',
} }
); );
}; };
export const deleteRegistrationParentFileTemplate = (id, csrfToken) => { export const deleteRegistrationParentFileTemplate = (id, csrfToken) => {
return fetch( return fetchWithAuthRaw(
`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL}/${id}`, `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL}/${id}`,
{ {
method: 'DELETE', method: 'DELETE',
headers: { headers: { 'X-CSRFToken': csrfToken },
'X-CSRFToken': csrfToken,
},
credentials: 'include',
} }
); );
}; };

View File

@ -10,185 +10,125 @@ import {
BE_SCHOOL_ESTABLISHMENT_URL, BE_SCHOOL_ESTABLISHMENT_URL,
BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL,
} from '@/utils/Url'; } from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers'; import { fetchWithAuth } from '@/utils/fetchWithAuth';
export const deleteEstablishmentCompetencies = (ids, csrfToken) => { export const deleteEstablishmentCompetencies = (ids, csrfToken) => {
return fetch(BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, { return fetchWithAuth(BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, {
method: 'DELETE', method: 'DELETE',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({ ids }), body: JSON.stringify({ ids }),
credentials: 'include', });
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const createEstablishmentCompetencies = (newData, csrfToken) => { export const createEstablishmentCompetencies = (newData, csrfToken) => {
return fetch(BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, { return fetchWithAuth(BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, {
method: 'POST', method: 'POST',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(newData), body: JSON.stringify(newData),
credentials: 'include', });
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchEstablishmentCompetencies = (establishment, cycle = 1) => { export const fetchEstablishmentCompetencies = (establishment, cycle = 1) => {
return fetch( return fetchWithAuth(
`${BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL}?establishment_id=${establishment}&cycle=${cycle}` `${BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL}?establishment_id=${establishment}&cycle=${cycle}`
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchSpecialities = (establishment) => {
return fetch(
`${BE_SCHOOL_SPECIALITIES_URL}?establishment_id=${establishment}`
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchTeachers = (establishment) => {
return fetch(`${BE_SCHOOL_TEACHERS_URL}?establishment_id=${establishment}`)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchClasses = (establishment) => {
return fetch(
`${BE_SCHOOL_SCHOOLCLASSES_URL}?establishment_id=${establishment}`
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchClasse = (id) => {
return fetch(`${BE_SCHOOL_SCHOOLCLASSES_URL}/${id}`).then(
requestResponseHandler
); );
}; };
export const fetchSpecialities = (establishment) => {
return fetchWithAuth(
`${BE_SCHOOL_SPECIALITIES_URL}?establishment_id=${establishment}`
);
};
export const fetchTeachers = (establishment) => {
return fetchWithAuth(`${BE_SCHOOL_TEACHERS_URL}?establishment_id=${establishment}`);
};
export const fetchClasses = (establishment) => {
return fetchWithAuth(
`${BE_SCHOOL_SCHOOLCLASSES_URL}?establishment_id=${establishment}`
);
};
export const fetchClasse = (id) => {
return fetchWithAuth(`${BE_SCHOOL_SCHOOLCLASSES_URL}/${id}`);
};
export const fetchSchedules = () => { export const fetchSchedules = () => {
return fetch(`${BE_SCHOOL_PLANNINGS_URL}`) return fetchWithAuth(`${BE_SCHOOL_PLANNINGS_URL}`);
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchRegistrationDiscounts = (establishment) => { export const fetchRegistrationDiscounts = (establishment) => {
return fetch( return fetchWithAuth(
`${BE_SCHOOL_DISCOUNTS_URL}?filter=registration&establishment_id=${establishment}` `${BE_SCHOOL_DISCOUNTS_URL}?filter=registration&establishment_id=${establishment}`
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchTuitionDiscounts = (establishment) => { export const fetchTuitionDiscounts = (establishment) => {
return fetch( return fetchWithAuth(
`${BE_SCHOOL_DISCOUNTS_URL}?filter=tuition&establishment_id=${establishment}` `${BE_SCHOOL_DISCOUNTS_URL}?filter=tuition&establishment_id=${establishment}`
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchRegistrationFees = (establishment) => { export const fetchRegistrationFees = (establishment) => {
return fetch( return fetchWithAuth(
`${BE_SCHOOL_FEES_URL}?filter=registration&establishment_id=${establishment}` `${BE_SCHOOL_FEES_URL}?filter=registration&establishment_id=${establishment}`
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchTuitionFees = (establishment) => { export const fetchTuitionFees = (establishment) => {
return fetch( return fetchWithAuth(
`${BE_SCHOOL_FEES_URL}?filter=tuition&establishment_id=${establishment}` `${BE_SCHOOL_FEES_URL}?filter=tuition&establishment_id=${establishment}`
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchRegistrationPaymentPlans = (establishment) => { export const fetchRegistrationPaymentPlans = (establishment) => {
return fetch( return fetchWithAuth(
`${BE_SCHOOL_PAYMENT_PLANS_URL}?filter=registration&establishment_id=${establishment}` `${BE_SCHOOL_PAYMENT_PLANS_URL}?filter=registration&establishment_id=${establishment}`
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchTuitionPaymentPlans = (establishment) => { export const fetchTuitionPaymentPlans = (establishment) => {
return fetch( return fetchWithAuth(
`${BE_SCHOOL_PAYMENT_PLANS_URL}?filter=tuition&establishment_id=${establishment}` `${BE_SCHOOL_PAYMENT_PLANS_URL}?filter=tuition&establishment_id=${establishment}`
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchRegistrationPaymentModes = (establishment) => { export const fetchRegistrationPaymentModes = (establishment) => {
return fetch( return fetchWithAuth(
`${BE_SCHOOL_PAYMENT_MODES_URL}?filter=registration&establishment_id=${establishment}` `${BE_SCHOOL_PAYMENT_MODES_URL}?filter=registration&establishment_id=${establishment}`
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchTuitionPaymentModes = (establishment) => { export const fetchTuitionPaymentModes = (establishment) => {
return fetch( return fetchWithAuth(
`${BE_SCHOOL_PAYMENT_MODES_URL}?filter=tuition&establishment_id=${establishment}` `${BE_SCHOOL_PAYMENT_MODES_URL}?filter=tuition&establishment_id=${establishment}`
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchEstablishment = (establishment) => { export const fetchEstablishment = (establishment) => {
return fetch(`${BE_SCHOOL_ESTABLISHMENT_URL}/${establishment}`) return fetchWithAuth(`${BE_SCHOOL_ESTABLISHMENT_URL}/${establishment}`);
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const createDatas = (url, newData, csrfToken) => { export const createDatas = (url, newData, csrfToken) => {
return fetch(url, { return fetchWithAuth(url, {
method: 'POST', method: 'POST',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(newData), body: JSON.stringify(newData),
credentials: 'include', });
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const updateDatas = (url, id, updatedData, csrfToken) => { export const updateDatas = (url, id, updatedData, csrfToken) => {
return fetch(`${url}/${id}`, { return fetchWithAuth(`${url}/${id}`, {
method: 'PUT', method: 'PUT',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(updatedData), body: JSON.stringify(updatedData),
credentials: 'include', });
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const removeDatas = (url, id, csrfToken) => { export const removeDatas = (url, id, csrfToken) => {
return fetch(`${url}/${id}`, { return fetchWithAuth(`${url}/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json', });
'X-CSRFToken': csrfToken,
},
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };

View File

@ -1,5 +1,5 @@
import { BE_SETTINGS_SMTP_URL } from '@/utils/Url'; import { BE_SETTINGS_SMTP_URL } from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers'; import { fetchWithAuth } from '@/utils/fetchWithAuth';
export const PENDING = 'pending'; export const PENDING = 'pending';
export const SUBSCRIBED = 'subscribed'; export const SUBSCRIBED = 'subscribed';
@ -10,26 +10,15 @@ export const fetchSmtpSettings = (csrfToken, establishment_id = null) => {
if (establishment_id) { if (establishment_id) {
url += `?establishment_id=${establishment_id}`; url += `?establishment_id=${establishment_id}`;
} }
return fetch(`${url}`, { return fetchWithAuth(url, {
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json', });
'X-CSRFToken': csrfToken,
},
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const editSmtpSettings = (data, csrfToken) => { export const editSmtpSettings = (data, csrfToken) => {
return fetch(`${BE_SETTINGS_SMTP_URL}/`, { return fetchWithAuth(`${BE_SETTINGS_SMTP_URL}/`, {
method: 'POST', method: 'POST',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(data), body: JSON.stringify(data),
credentials: 'include', });
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };

View File

@ -11,20 +11,15 @@ import {
} from '@/utils/Url'; } from '@/utils/Url';
import { CURRENT_YEAR_FILTER } from '@/utils/constants'; import { CURRENT_YEAR_FILTER } from '@/utils/constants';
import { errorHandler, requestResponseHandler } from './actionsHandlers'; import { fetchWithAuth, fetchWithAuthRaw } from '@/utils/fetchWithAuth';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
export const editStudentCompetencies = (data, csrfToken) => { export const editStudentCompetencies = (data, csrfToken) => {
const request = new Request(`${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}`, { return fetchWithAuth(`${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}`, {
method: 'PUT', method: 'PUT',
headers: { 'X-CSRFToken': csrfToken },
body: JSON.stringify(data), body: JSON.stringify(data),
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
credentials: 'include',
}); });
return fetch(request).then(requestResponseHandler).catch(errorHandler);
}; };
export const fetchStudentCompetencies = (id, period) => { export const fetchStudentCompetencies = (id, period) => {
@ -33,13 +28,7 @@ export const fetchStudentCompetencies = (id, period) => {
? `${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}?student_id=${id}&period=${period}` ? `${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}?student_id=${id}&period=${period}`
: `${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}?student_id=${id}`; : `${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}?student_id=${id}`;
const request = new Request(url, { return fetchWithAuth(url);
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
}; };
export const fetchRegisterForms = ( export const fetchRegisterForms = (
@ -53,37 +42,22 @@ export const fetchRegisterForms = (
if (page !== '' && pageSize !== '') { if (page !== '' && pageSize !== '') {
url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}?filter=${filter}&establishment_id=${establishment}&page=${page}&search=${search}`; url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}?filter=${filter}&establishment_id=${establishment}&page=${page}&search=${search}`;
} }
return fetch(url, { return fetchWithAuth(url);
headers: {
'Content-Type': 'application/json',
},
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchRegisterForm = (id) => { export const fetchRegisterForm = (id) => {
return fetch(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`) // Utilisation de studentId au lieu de codeDI return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`); // Utilisation de studentId au lieu de codeDI
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchLastGuardian = () => { export const fetchLastGuardian = () => {
return fetch(`${BE_SUBSCRIPTION_LAST_GUARDIAN_ID_URL}`) return fetchWithAuth(`${BE_SUBSCRIPTION_LAST_GUARDIAN_ID_URL}`);
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const editRegisterForm = (id, data, csrfToken) => { export const editRegisterForm = (id, data, csrfToken) => {
return fetch(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`, { return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`, {
method: 'PUT', method: 'PUT',
headers: { headers: { 'X-CSRFToken': csrfToken },
'X-CSRFToken': csrfToken,
},
body: data, body: data,
credentials: 'include', });
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const autoSaveRegisterForm = async (id, data, csrfToken) => { export const autoSaveRegisterForm = async (id, data, csrfToken) => {
@ -106,15 +80,12 @@ export const autoSaveRegisterForm = async (id, data, csrfToken) => {
} }
autoSaveData.append('auto_save', 'true'); autoSaveData.append('auto_save', 'true');
return fetch(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`, { return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`, {
method: 'PATCH', // Utiliser PATCH pour les mises à jour partielles method: 'PATCH', // Utiliser PATCH pour les mises à jour partielles
headers: { headers: { 'X-CSRFToken': csrfToken },
'X-CSRFToken': csrfToken,
},
body: autoSaveData, body: autoSaveData,
credentials: 'include',
}) })
.then(requestResponseHandler) .then(() => {})
.catch(() => { .catch(() => {
// Silent fail pour l'auto-save // Silent fail pour l'auto-save
logger.debug('Auto-save failed silently'); logger.debug('Auto-save failed silently');
@ -127,62 +98,30 @@ export const autoSaveRegisterForm = async (id, data, csrfToken) => {
export const createRegisterForm = (data, csrfToken) => { export const createRegisterForm = (data, csrfToken) => {
const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}`; const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}`;
return fetch(url, { return fetchWithAuth(url, {
method: 'POST', method: 'POST',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(data), body: JSON.stringify(data),
credentials: 'include', });
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const sendRegisterForm = (id) => { export const sendRegisterForm = (id) => {
const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/send`; const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/send`;
return fetch(url, { return fetchWithAuth(url);
headers: {
'Content-Type': 'application/json',
},
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const resendRegisterForm = (id) => { export const resendRegisterForm = (id) => {
const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/resend`; const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/resend`;
return fetch(url, { return fetchWithAuth(url);
headers: {
'Content-Type': 'application/json',
},
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const archiveRegisterForm = (id) => { export const archiveRegisterForm = (id) => {
const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/archive`; const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/archive`;
return fetch(url, { return fetchWithAuth(url);
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const searchStudents = (establishmentId, query) => { export const searchStudents = (establishmentId, query) => {
const url = `${BE_SUBSCRIPTION_SEARCH_STUDENTS_URL}/?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`; const url = `${BE_SUBSCRIPTION_SEARCH_STUDENTS_URL}/?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`;
return fetch(url, { return fetchWithAuth(url);
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchStudents = (establishment, id = null, status = null) => { export const fetchStudents = (establishment, id = null, status = null) => {
@ -195,153 +134,68 @@ export const fetchStudents = (establishment, id = null, status = null) => {
url += `&status=${status}`; url += `&status=${status}`;
} }
} }
const request = new Request(url, { return fetchWithAuth(url);
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
}; };
export const fetchChildren = (id, establishment) => { export const fetchChildren = (id, establishment) => {
const request = new Request( return fetchWithAuth(
`${BE_SUBSCRIPTION_CHILDRENS_URL}/${id}?establishment_id=${establishment}`, `${BE_SUBSCRIPTION_CHILDRENS_URL}/${id}?establishment_id=${establishment}`
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
); );
return fetch(request).then(requestResponseHandler).catch(errorHandler);
}; };
export async function getRegisterFormFileTemplate(fileId) { export async function getRegisterFormFileTemplate(fileId) {
const response = await fetch( return fetchWithAuth(
`${BE_SUBSCRIPTION_REGISTERFORM_FILE_TEMPLATE_URL}/${fileId}`, `${BE_SUBSCRIPTION_REGISTERFORM_FILE_TEMPLATE_URL}/${fileId}`
{
credentials: 'include',
headers: {
Accept: 'application/json',
},
}
); );
if (!response.ok) {
throw new Error('Failed to fetch file template');
}
return response.json();
} }
export const fetchSchoolFileTemplatesFromRegistrationFiles = async (id) => { export const fetchSchoolFileTemplatesFromRegistrationFiles = (id) => {
const response = await fetch( return fetchWithAuth(
`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/school_file_templates`, `${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/school_file_templates`
{
credentials: 'include',
headers: {
Accept: 'application/json',
},
}
); );
if (!response.ok) {
throw new Error(
'Erreur lors de la récupération des fichiers associés au groupe'
);
}
return response.json();
}; };
export const fetchParentFileTemplatesFromRegistrationFiles = async (id) => { export const fetchParentFileTemplatesFromRegistrationFiles = (id) => {
const response = await fetch( return fetchWithAuth(
`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/parent_file_templates`, `${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/parent_file_templates`
{
credentials: 'include',
headers: {
Accept: 'application/json',
},
}
); );
if (!response.ok) {
throw new Error(
'Erreur lors de la récupération des fichiers associés au groupe'
);
}
return response.json();
}; };
export const dissociateGuardian = async (studentId, guardianId) => { export const dissociateGuardian = (studentId, guardianId) => {
const response = await fetch( return fetchWithAuth(
`${BE_SUBSCRIPTION_STUDENTS_URL}/${studentId}/guardians/${guardianId}/dissociate`, `${BE_SUBSCRIPTION_STUDENTS_URL}/${studentId}/guardians/${guardianId}/dissociate`,
{ {
credentials: 'include',
method: 'PUT', method: 'PUT',
headers: {
Accept: 'application/json',
},
} }
); );
if (!response.ok) {
// Extraire le message d'erreur du backend
const errorData = await response.json();
const errorMessage =
errorData?.error || 'Une erreur est survenue lors de la dissociation.';
// Jeter une erreur avec le message spécifique
throw new Error(errorMessage);
}
return response.json();
}; };
export const fetchAbsences = (establishment) => { export const fetchAbsences = (establishment) => {
return fetch( return fetchWithAuth(
`${BE_SUBSCRIPTION_ABSENCES_URL}?establishment_id=${establishment}` `${BE_SUBSCRIPTION_ABSENCES_URL}?establishment_id=${establishment}`
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const createAbsences = (data, csrfToken) => { export const createAbsences = (data, csrfToken) => {
return fetch(`${BE_SUBSCRIPTION_ABSENCES_URL}`, { return fetchWithAuth(`${BE_SUBSCRIPTION_ABSENCES_URL}`, {
method: 'POST', method: 'POST',
headers: { 'X-CSRFToken': csrfToken },
body: JSON.stringify(data), body: JSON.stringify(data),
headers: { });
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
},
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const editAbsences = (absenceId, payload, csrfToken) => { export const editAbsences = (absenceId, payload, csrfToken) => {
return fetch(`${BE_SUBSCRIPTION_ABSENCES_URL}/${absenceId}`, { return fetchWithAuth(`${BE_SUBSCRIPTION_ABSENCES_URL}/${absenceId}`, {
method: 'PUT', method: 'PUT',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json', body: JSON.stringify(payload),
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(payload), // Sérialisez les données en JSON
credentials: 'include',
}).then((response) => {
if (!response.ok) {
return response.json().then((error) => {
throw new Error(error);
});
}
return response.json();
}); });
}; };
export const deleteAbsences = (id, csrfToken) => { export const deleteAbsences = (id, csrfToken) => {
return fetch(`${BE_SUBSCRIPTION_ABSENCES_URL}/${id}`, { return fetchWithAuthRaw(`${BE_SUBSCRIPTION_ABSENCES_URL}/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: { 'X-CSRFToken': csrfToken },
'X-CSRFToken': csrfToken,
},
credentials: 'include',
}); });
}; };
@ -352,16 +206,7 @@ export const deleteAbsences = (id, csrfToken) => {
*/ */
export const fetchRegistrationSchoolFileMasters = (establishmentId) => { export const fetchRegistrationSchoolFileMasters = (establishmentId) => {
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}?establishment_id=${establishmentId}`; const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}?establishment_id=${establishmentId}`;
return fetchWithAuth(url);
return fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
/** /**
@ -373,22 +218,14 @@ export const fetchRegistrationSchoolFileMasters = (establishmentId) => {
*/ */
export const saveFormResponses = (templateId, formTemplateData, csrfToken) => { export const saveFormResponses = (templateId, formTemplateData, csrfToken) => {
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${templateId}`; const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${templateId}`;
const payload = { const payload = {
formTemplateData: formTemplateData, formTemplateData: formTemplateData,
}; };
return fetchWithAuth(url, {
return fetch(url, {
method: 'PUT', method: 'PUT',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(payload), body: JSON.stringify(payload),
credentials: 'include', });
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
/** /**
@ -398,14 +235,5 @@ export const saveFormResponses = (templateId, formTemplateData, csrfToken) => {
*/ */
export const fetchFormResponses = (templateId) => { export const fetchFormResponses = (templateId) => {
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${templateId}`; const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${templateId}`;
return fetchWithAuth(url);
return fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };

View File

@ -10,10 +10,16 @@ export default function FileUpload({
required, required,
errorMsg, errorMsg,
enable = true, // Nouvelle prop pour activer/désactiver le champ enable = true, // Nouvelle prop pour activer/désactiver le champ
key,
}) { }) {
const [localFileName, setLocalFileName] = useState(uploadedFileName || ''); const [localFileName, setLocalFileName] = useState(uploadedFileName || '');
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
// Réinitialise localFileName à chaque changement de key (id template)
React.useEffect(() => {
setLocalFileName(uploadedFileName || '');
}, [key, uploadedFileName]);
const handleFileChange = (e) => { const handleFileChange = (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
if (file) { if (file) {

View File

@ -1,5 +1,6 @@
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { useEffect } from 'react';
import SelectChoice from './SelectChoice'; import SelectChoice from './SelectChoice';
import InputTextIcon from './InputTextIcon'; import InputTextIcon from './InputTextIcon';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
@ -28,6 +29,7 @@ export function getIcon(name) {
export default function FormRenderer({ export default function FormRenderer({
formConfig, formConfig,
csrfToken, csrfToken,
initialValues = {},
onFormSubmit = (data) => { onFormSubmit = (data) => {
alert(JSON.stringify(data, null, 2)); alert(JSON.stringify(data, null, 2));
}, // Callback de soumission personnalisé (optionnel) }, // Callback de soumission personnalisé (optionnel)
@ -37,31 +39,14 @@ export default function FormRenderer({
control, control,
formState: { errors }, formState: { errors },
reset, reset,
} = useForm(); } = useForm({ defaultValues: initialValues });
// Fonction utilitaire pour envoyer les données au backend // Réinitialiser le formulaire quand les valeurs initiales changent
const sendFormDataToBackend = async (formData) => { useEffect(() => {
try { if (initialValues && Object.keys(initialValues).length > 0) {
// Cette fonction peut être remplacée par votre propre implémentation reset(initialValues);
// Exemple avec fetch:
const response = await fetch('/api/submit-form', {
method: 'POST',
body: formData,
// Les en-têtes sont automatiquement définis pour FormData
});
if (!response.ok) {
throw new Error(`Erreur HTTP ${response.status}`);
}
const result = await response.json();
logger.debug('Envoi réussi:', result);
return result;
} catch (error) {
logger.error("Erreur lors de l'envoi:", error);
throw error;
} }
}; }, [initialValues, reset]);
const onSubmit = async (data) => { const onSubmit = async (data) => {
logger.debug('=== DÉBUT onSubmit ==='); logger.debug('=== DÉBUT onSubmit ===');

View File

@ -1,64 +1,88 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import FormRenderer from '@/components/Form/FormRenderer'; import FormRenderer from '@/components/Form/FormRenderer';
import { CheckCircle, Hourglass, FileText } from 'lucide-react'; import FileUpload from '@/components/Form/FileUpload';
import { CheckCircle, Hourglass, FileText, Download, Upload, XCircle } from 'lucide-react';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { BASE_URL } from '@/utils/Url';
/** /**
* Composant pour afficher et gérer les formulaires dynamiques d'inscription * Composant pour afficher et gérer les formulaires dynamiques d'inscription
* @param {Array} schoolFileMasters - Liste des formulaires maîtres * @param {Array} schoolFileTemplates - Liste des templates de formulaires
* @param {Object} existingResponses - Réponses déjà sauvegardées * @param {Object} existingResponses - Réponses déjà sauvegardées
* @param {Function} onFormSubmit - Callback appelé quand un formulaire est soumis * @param {Function} onFormSubmit - Callback appelé quand un formulaire est soumis
* @param {Boolean} enable - Si les formulaires sont modifiables * @param {Boolean} enable - Si les formulaires sont modifiables
* @param {Function} onFileUpload - Callback appelé quand un fichier est sélectionné
*/ */
export default function DynamicFormsList({ export default function DynamicFormsList({
schoolFileMasters, schoolFileTemplates,
existingResponses = {}, existingResponses = {},
onFormSubmit, onFormSubmit,
enable = true, enable = true,
onValidationChange, onValidationChange,
onFileUpload, // nouvelle prop pour gérer l'upload (à passer depuis le parent)
}) { }) {
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0); const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
const [formsData, setFormsData] = useState({}); const [formsData, setFormsData] = useState({});
const [formsValidation, setFormsValidation] = useState({}); const [formsValidation, setFormsValidation] = useState({});
const fileInputRefs = React.useRef({});
// Initialiser les données avec les réponses existantes // Initialiser les données avec les réponses existantes
useEffect(() => { useEffect(() => {
if (existingResponses && Object.keys(existingResponses).length > 0) { // Initialisation complète de formsValidation et formsData pour chaque template
setFormsData(existingResponses); if (schoolFileTemplates && schoolFileTemplates.length > 0) {
// Fusionner avec l'état existant pour préserver les données locales
// Marquer les formulaires avec réponses comme valides setFormsData((prevData) => {
const validationState = {}; const dataState = { ...prevData };
Object.keys(existingResponses).forEach((formId) => { schoolFileTemplates.forEach((tpl) => {
if ( // Ne mettre à jour que si on n'a pas encore de données locales ou si les données du serveur ont changé
existingResponses[formId] && const hasLocalData = prevData[tpl.id] && Object.keys(prevData[tpl.id]).length > 0;
Object.keys(existingResponses[formId]).length > 0 const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
) {
validationState[formId] = true; if (!hasLocalData && hasServerData) {
} // Pas de données locales mais données serveur : utiliser les données serveur
dataState[tpl.id] = existingResponses[tpl.id];
} else if (!hasLocalData && !hasServerData) {
// Pas de données du tout : initialiser à vide
dataState[tpl.id] = {};
}
// Si hasLocalData : on garde les données locales existantes
});
return dataState;
}); });
setFormsValidation(validationState);
}
}, [existingResponses]);
// Debug: Log des formulaires maîtres reçus // Fusionner avec l'état de validation existant
useEffect(() => { setFormsValidation((prevValidation) => {
logger.debug( const validationState = { ...prevValidation };
'DynamicFormsList - Formulaires maîtres reçus:', schoolFileTemplates.forEach((tpl) => {
schoolFileMasters const hasLocalValidation = prevValidation[tpl.id] === true;
); const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
}, [schoolFileMasters]);
if (!hasLocalValidation && hasServerData) {
// Pas validé localement mais données serveur : marquer comme validé
validationState[tpl.id] = true;
} else if (validationState[tpl.id] === undefined) {
// Pas encore initialisé : initialiser à false
validationState[tpl.id] = false;
}
// Si hasLocalValidation : on garde l'état local existant
});
return validationState;
});
}
}, [existingResponses, schoolFileTemplates]);
// Mettre à jour la validation globale quand la validation des formulaires change // Mettre à jour la validation globale quand la validation des formulaires change
useEffect(() => { useEffect(() => {
const allFormsValid = schoolFileMasters.every( // Un document est considéré comme "validé" s'il est validé par l'école OU complété localement OU déjà existant
(master, index) => formsValidation[master.id] === true const allFormsValid = schoolFileTemplates.every(
tpl => tpl.isValidated === true ||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
); );
if (onValidationChange) { onValidationChange(allFormsValid);
onValidationChange(allFormsValid); }, [formsData, formsValidation, existingResponses, schoolFileTemplates, onValidationChange]);
}
}, [formsValidation, schoolFileMasters, onValidationChange]);
/** /**
* Gère la soumission d'un formulaire individuel * Gère la soumission d'un formulaire individuel
@ -85,7 +109,7 @@ export default function DynamicFormsList({
} }
// Passer au formulaire suivant si disponible // Passer au formulaire suivant si disponible
if (currentTemplateIndex < schoolFileMasters.length - 1) { if (currentTemplateIndex < schoolFileTemplates.length - 1) {
setCurrentTemplateIndex(currentTemplateIndex + 1); setCurrentTemplateIndex(currentTemplateIndex + 1);
} }
@ -95,16 +119,6 @@ export default function DynamicFormsList({
} }
}; };
/**
* Gère les changements de validation d'un formulaire
*/
const handleFormValidationChange = (isValid, templateId) => {
setFormsValidation((prev) => ({
...prev,
[templateId]: isValid,
}));
};
/** /**
* Vérifie si un formulaire est complété * Vérifie si un formulaire est complété
*/ */
@ -135,10 +149,44 @@ export default function DynamicFormsList({
* Obtient le formulaire actuel à afficher * Obtient le formulaire actuel à afficher
*/ */
const getCurrentTemplate = () => { const getCurrentTemplate = () => {
return schoolFileMasters[currentTemplateIndex]; return schoolFileTemplates[currentTemplateIndex];
}; };
if (!schoolFileMasters || schoolFileMasters.length === 0) { const currentTemplate = getCurrentTemplate();
// Handler d'upload pour formulaire existant
const handleUpload = async (file, selectedFile) => {
if (!file || !selectedFile) return;
try {
const templateId = currentTemplate.id;
if (onFileUpload) {
await onFileUpload(file, selectedFile);
setFormsData((prev) => {
const newData = {
...prev,
[templateId]: { uploaded: true, fileName: file.name },
};
return newData;
});
setFormsValidation((prev) => {
const newValidation = {
...prev,
[templateId]: true,
};
return newValidation;
});
}
} catch (error) {
logger.error('Erreur lors de l\'upload du fichier :', error);
}
};
const isDynamicForm = (template) =>
template.formTemplateData &&
Array.isArray(template.formTemplateData.fields) &&
template.formTemplateData.fields.length > 0;
if (!schoolFileTemplates || schoolFileTemplates.length === 0) {
return ( return (
<div className="text-center py-8"> <div className="text-center py-8">
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" /> <FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
@ -147,8 +195,6 @@ export default function DynamicFormsList({
); );
} }
const currentTemplate = getCurrentTemplate();
return ( return (
<div className="mt-8 mb-4 w-full mx-auto flex gap-8"> <div className="mt-8 mb-4 w-full mx-auto flex gap-8">
{/* Liste des formulaires */} {/* Liste des formulaires */}
@ -157,128 +203,253 @@ export default function DynamicFormsList({
Formulaires à compléter Formulaires à compléter
</h3> </h3>
<div className="text-sm text-gray-600 mb-4"> <div className="text-sm text-gray-600 mb-4">
{/* Compteur x/y : inclut les documents validés */}
{ {
Object.keys(formsValidation).filter((id) => formsValidation[id]) schoolFileTemplates.filter(tpl => {
.length // Validé ou complété localement
}{' '} return tpl.isValidated === true ||
/ {schoolFileMasters.length} complétés (formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0);
}).length
}
{' / '}
{schoolFileTemplates.length} complétés
</div> </div>
<ul className="space-y-2"> {/* Tri des templates par état */}
{schoolFileMasters.map((master, index) => { {(() => {
const isActive = index === currentTemplateIndex; // Helper pour état
const isCompleted = isFormCompleted(master.id); const getState = tpl => {
if (tpl.isValidated === true) return 0; // validé
return ( const isCompletedLocally = !!(
<li (formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
key={master.id} (existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
className={`flex items-center cursor-pointer p-2 rounded-md transition-colors ${
isActive
? 'bg-blue-100 text-blue-700 font-semibold'
: isCompleted
? 'text-green-600 hover:bg-green-50'
: 'text-gray-600 hover:bg-gray-100'
}`}
onClick={() => setCurrentTemplateIndex(index)}
>
<span className="mr-3">
{getFormStatusIcon(master.id, isActive)}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm truncate">
{master.formMasterData?.title ||
master.title ||
master.name ||
'Formulaire sans nom'}
</div>
{isCompleted ? (
<div className="text-xs text-green-600">
Complété -{' '}
{
Object.keys(
formsData[master.id] ||
existingResponses[master.id] ||
{}
).length
}{' '}
réponse(s)
</div>
) : (
<div className="text-xs text-gray-500">
{master.formMasterData?.fields || master.fields
? `${(master.formMasterData?.fields || master.fields).length} champ(s)`
: 'À compléter'}
</div>
)}
</div>
</li>
); );
})} if (isCompletedLocally) return 1; // complété/en attente
</ul> return 2; // à compléter/refusé
};
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => {
return getState(a) - getState(b);
});
return (
<ul className="space-y-2">
{sortedTemplates.map((tpl, index) => {
const isActive = schoolFileTemplates[currentTemplateIndex]?.id === tpl.id;
const isValidated = typeof tpl.isValidated === 'boolean' ? tpl.isValidated : undefined;
const isCompletedLocally = !!(
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
);
// Statut d'affichage
let statusLabel = '';
let statusColor = '';
let icon = null;
let bgClass = '';
let borderClass = '';
let textClass = '';
let canEdit = true;
if (isValidated === true) {
statusLabel = 'Validé';
statusColor = 'emerald';
icon = <CheckCircle className="w-5 h-5 text-emerald-600" />;
bgClass = 'bg-emerald-50';
borderClass = 'border border-emerald-200';
textClass = 'text-emerald-700';
bgClass = isActive ? 'bg-emerald-200' : bgClass;
borderClass = isActive ? 'border border-emerald-300' : borderClass;
textClass = isActive ? 'text-emerald-900 font-semibold' : textClass;
canEdit = false;
} else if (isValidated === false) {
if (isCompletedLocally) {
statusLabel = 'Complété';
statusColor = 'orange';
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
borderClass = isActive ? 'border border-orange-300' : 'border border-orange-200';
textClass = isActive ? 'text-orange-900 font-semibold' : 'text-orange-700';
canEdit = true;
} else {
statusLabel = 'Refusé';
statusColor = 'red';
icon = <XCircle className="w-5 h-5 text-red-500" />;
bgClass = isActive ? 'bg-red-200' : 'bg-red-50';
borderClass = isActive ? 'border border-red-300' : 'border border-red-200';
textClass = isActive ? 'text-red-900 font-semibold' : 'text-red-700';
canEdit = true;
}
} else {
if (isCompletedLocally) {
statusLabel = 'Complété';
statusColor = 'orange';
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
borderClass = isActive ? 'border border-orange-300' : 'border border-orange-200';
textClass = isActive ? 'text-orange-900 font-semibold' : 'text-orange-700';
canEdit = true;
} else {
statusLabel = 'À compléter';
statusColor = 'gray';
icon = <Hourglass className="w-5 h-5 text-gray-400" />;
bgClass = isActive ? 'bg-gray-200' : '';
borderClass = isActive ? 'border border-gray-300' : '';
textClass = isActive ? 'text-gray-900 font-semibold' : 'text-gray-600';
canEdit = true;
}
}
return (
<li
key={tpl.id}
className={`flex items-center cursor-pointer p-2 rounded-md transition-colors ${
isActive
? `${bgClass.replace('50', '200')} ${borderClass.replace('200', '300')} ${textClass.replace('700', '900')} font-semibold`
: `${bgClass} ${borderClass} ${textClass}`
}`}
onClick={() => setCurrentTemplateIndex(schoolFileTemplates.findIndex(t => t.id === tpl.id))}
>
<span className="mr-3">{icon}</span>
<div className="flex-1 min-w-0">
<div className="text-sm truncate flex items-center gap-2">
{tpl.formMasterData?.title || tpl.title || tpl.name || 'Formulaire sans nom'}
<span className={`ml-2 px-2 py-0.5 rounded bg-${statusColor}-100 text-${statusColor}-700 text-xs font-semibold`}>
{statusLabel}
</span>
</div>
<div className="text-xs text-gray-500">
{tpl.formMasterData?.fields || tpl.fields
? `${(tpl.formMasterData?.fields || tpl.fields).length} champ(s)`
: 'À compléter'}
</div>
</div>
</li>
);
})}
</ul>
);
})()}
</div> </div>
{/* Affichage du formulaire actuel */}
<div className="w-3/4"> <div className="w-3/4">
{currentTemplate && ( {currentTemplate && (
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200"> <div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<div className="mb-6"> <div className="mb-6">
<h3 className="text-xl font-semibold text-gray-800 mb-2"> <div className="flex items-center gap-3 mb-2">
{currentTemplate.formMasterData?.title || <h3 className="text-xl font-semibold text-gray-800">
currentTemplate.title || {currentTemplate.name}
currentTemplate.name || </h3>
'Formulaire sans nom'} {/* Label d'état */}
</h3> {currentTemplate.isValidated === true ? (
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-sm font-semibold">Validé</span>
) : ((formsData[currentTemplate.id] && Object.keys(formsData[currentTemplate.id]).length > 0) ||
(existingResponses[currentTemplate.id] && Object.keys(existingResponses[currentTemplate.id]).length > 0)) ? (
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-sm font-semibold">Complété</span>
) : (
<span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">Refusé</span>
)}
</div>
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
{currentTemplate.formMasterData?.description || {currentTemplate.formTemplateData?.description ||
currentTemplate.description || currentTemplate.description || ''}
'Veuillez compléter ce formulaire pour continuer votre inscription.'}
</p> </p>
<div className="text-xs text-gray-500 mt-1"> <div className="text-xs text-gray-500 mt-1">
Formulaire {currentTemplateIndex + 1} sur{' '} Formulaire {(() => {
{schoolFileMasters.length} // Trouver l'index du template courant dans la liste triée
const getState = tpl => {
if (tpl.isValidated === true) return 0;
const isCompletedLocally = !!(
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
);
if (isCompletedLocally) return 1;
return 2;
};
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => getState(a) - getState(b));
const idx = sortedTemplates.findIndex(tpl => tpl.id === currentTemplate.id);
return idx + 1;
})()} sur {schoolFileTemplates.length}
</div> </div>
</div> </div>
{/* Vérifier si le formulaire maître a des données de configuration */} {/* Affichage dynamique ou existant */}
{(currentTemplate.formMasterData?.fields && {isDynamicForm(currentTemplate) ? (
currentTemplate.formMasterData.fields.length > 0) ||
(currentTemplate.fields && currentTemplate.fields.length > 0) ? (
<FormRenderer <FormRenderer
key={currentTemplate.id} key={currentTemplate.id}
formConfig={{ formConfig={{
id: currentTemplate.id, id: currentTemplate.id,
title: title:
currentTemplate.formMasterData?.title || currentTemplate.formTemplateData?.title ||
currentTemplate.title || currentTemplate.title ||
currentTemplate.name || currentTemplate.name ||
'Formulaire', 'Formulaire',
fields: fields:
currentTemplate.formMasterData?.fields || currentTemplate.formTemplateData?.fields ||
currentTemplate.fields || currentTemplate.fields ||
[], [],
submitLabel: submitLabel:
currentTemplate.formMasterData?.submitLabel || 'Valider', currentTemplate.formTemplateData?.submitLabel || 'Valider',
}} }}
initialValues={
formsData[currentTemplate.id] ||
existingResponses[currentTemplate.id] ||
{}
}
onFormSubmit={(formData) => onFormSubmit={(formData) =>
handleFormSubmit(formData, currentTemplate.id) handleFormSubmit(formData, currentTemplate.id)
} }
// Désactive le bouton suivant si le template est validé
enable={currentTemplate.isValidated !== true}
/> />
) : ( ) : (
<div className="text-center py-8"> // Formulaire existant (PDF, image, etc.)
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" /> <div className="flex flex-col items-center gap-6">
<p className="text-gray-600"> {/* Cas validé : affichage en iframe */}
Ce formulaire n&apos;est pas encore configuré. {currentTemplate.isValidated === true && currentTemplate.file && (
</p> <iframe
<p className="text-sm text-gray-500 mt-2"> src={`${BASE_URL}${currentTemplate.file}`}
Contactez l&apos;administration pour plus d&apos;informations. title={currentTemplate.name}
</p> className="w-full"
style={{ height: '600px', border: 'none' }}
/>
)}
{/* Cas non validé : bouton télécharger + upload */}
{currentTemplate.isValidated !== true && (
<div className="flex flex-col items-center gap-4 w-full">
{/* Bouton télécharger le document source */}
{currentTemplate.file && (
<a
href={`${BASE_URL}${currentTemplate.file}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
download
>
<Download className="w-5 h-5" />
Télécharger le document
</a>
)}
{/* Composant d'upload */}
{enable && (
<FileUpload
key={currentTemplate.id}
selectionMessage={'Sélectionnez le fichier du document'}
onFileSelect={(file) => handleUpload(file, currentTemplate)}
required
enable={true}
/>
)}
</div>
)}
</div> </div>
)} )}
</div> </div>
)} )}
{/* Message de fin */} {/* Message de fin */}
{currentTemplateIndex >= schoolFileMasters.length && ( {currentTemplateIndex >= schoolFileTemplates.length && (
<div className="text-center py-8"> <div className="text-center py-8">
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" /> <CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-green-600 mb-2"> <h3 className="text-lg font-semibold text-green-600 mb-2">

View File

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

View File

@ -100,7 +100,7 @@ export default function ResponsableInputFields({
profile_role_data: { profile_role_data: {
establishment: selectedEstablishmentId, establishment: selectedEstablishmentId,
role_type: 2, role_type: 2,
is_active: true, is_active: false,
profile_data: { profile_data: {
email: '', email: '',
password: 'Provisoire01!', password: 'Provisoire01!',

View File

@ -1,5 +1,6 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Popup from '@/components/Popup';
import ToggleSwitch from '@/components/Form/ToggleSwitch'; import ToggleSwitch from '@/components/Form/ToggleSwitch';
import SelectChoice from '@/components/Form/SelectChoice'; import SelectChoice from '@/components/Form/SelectChoice';
import { BASE_URL } from '@/utils/Url'; import { BASE_URL } from '@/utils/Url';
@ -8,24 +9,55 @@ import {
fetchParentFileTemplatesFromRegistrationFiles, fetchParentFileTemplatesFromRegistrationFiles,
} from '@/app/actions/subscriptionAction'; } from '@/app/actions/subscriptionAction';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { School, CheckCircle, Hourglass, FileText } from 'lucide-react'; import { School, FileText } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader'; import SectionHeader from '@/components/SectionHeader';
import Button from '@/components/Form/Button'; import Button from '@/components/Form/Button';
export default function ValidateSubscription({ export default function ValidateSubscription({
studentId, studentId,
firstName, firstName,
email,
lastName, lastName,
sepa_file, sepa_file,
student_file, student_file,
onAccept, onAccept,
onRefuse,
classes, classes,
handleValidateOrRefuseDoc,
csrfToken,
}) { }) {
const [schoolFileTemplates, setSchoolFileTemplates] = useState([]); const [schoolFileTemplates, setSchoolFileTemplates] = useState([]);
const [parentFileTemplates, setParentFileTemplates] = useState([]); const [parentFileTemplates, setParentFileTemplates] = useState([]);
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0); const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
const [mergeDocuments, setMergeDocuments] = useState(false); const [mergeDocuments, setMergeDocuments] = useState(false);
const [isPageValid, setIsPageValid] = useState(false); const [isPageValid, setIsPageValid] = useState(false);
// Pour la validation/refus des documents
const [docStatuses, setDocStatuses] = useState({}); // {index: 'accepted'|'refused'}
// Met à jour docStatuses selon isValidated des templates récupérés
useEffect(() => {
// On construit la map index -> status à partir des templates
const newStatuses = {};
// Fiche élève (pas de validation individuelle)
newStatuses[0] = undefined;
// School templates
schoolFileTemplates.forEach((tpl, i) => {
if (typeof tpl.isValidated === 'boolean') {
newStatuses[1 + i] = tpl.isValidated ? 'accepted' : 'refused';
}
});
// Parent templates
parentFileTemplates.forEach((tpl, i) => {
if (typeof tpl.isValidated === 'boolean') {
newStatuses[1 + schoolFileTemplates.length + i] = tpl.isValidated ? 'accepted' : 'refused';
}
});
setDocStatuses(s => ({ ...s, ...newStatuses }));
}, [schoolFileTemplates, parentFileTemplates]);
const [showRefusedPopup, setShowRefusedPopup] = useState(false);
// Affiche la popup de confirmation finale (tous docs validés et classe sélectionnée)
const [showFinalValidationPopup, setShowFinalValidationPopup] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
associated_class: null, associated_class: null,
@ -88,14 +120,27 @@ export default function ValidateSubscription({
}, },
status: 5, status: 5,
fusionParam: mergeDocuments, fusionParam: mergeDocuments,
notes: 'Dossier validé',
}; };
onAccept(data); onAccept(data);
} else { } else {
logger.warn('Aucune classe sélectionnée.'); logger.warn('Aucune classe sélectionnée.');
} }
}; };
const handleRefuseDossier = () => {
// Message clair avec la liste des documents refusés
let notes = 'Dossier non validé pour les raisons suivantes :\n';
notes += refusedDocs.map(doc => `- ${doc.name}`).join('\n');
const data = {
status: 2,
notes,
};
if (onRefuse) {
onRefuse(data);
}
};
const onChange = (field, value) => { const onChange = (field, value) => {
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
@ -125,6 +170,17 @@ export default function ValidateSubscription({
] ]
: []), : []),
]; ];
// Récupère la liste des documents refusés (inclut la fiche élève si refusée)
const refusedDocs = allTemplates
.map((doc, idx) => ({ ...doc, idx }))
.filter((doc, idx) => docStatuses[idx] === 'refused');
// Récupère la liste des documents à cocher (hors fiche élève)
const docIndexes = allTemplates.map((_, idx) => idx).filter(idx => idx !== 0);
const allChecked = docIndexes.length > 0 && docIndexes.every(idx => docStatuses[idx] === 'accepted' || docStatuses[idx] === 'refused');
const allValidated = docIndexes.length > 0 && docIndexes.every(idx => docStatuses[idx] === 'accepted');
const hasRefused = docIndexes.some(idx => docStatuses[idx] === 'refused');
logger.debug(allTemplates); logger.debug(allTemplates);
return ( return (
@ -162,8 +218,8 @@ export default function ValidateSubscription({
)} )}
</div> </div>
{/* Colonne droite : Liste des documents, Option de fusion et Affectation */} {/* Colonne droite : Liste des documents, Option de fusion, Affectation, Refus */}
<div className="w-1/4 flex flex-col gap-4"> <div className="w-1/4 flex flex-col flex-1 gap-4 h-full">
{/* Liste des documents */} {/* Liste des documents */}
<div className="flex-1 bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200 overflow-y-auto"> <div className="flex-1 bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200 overflow-y-auto">
<h3 className="text-lg font-semibold text-gray-800 mb-4"> <h3 className="text-lg font-semibold text-gray-800 mb-4">
@ -187,60 +243,176 @@ export default function ValidateSubscription({
<FileText className="w-5 h-5 text-green-600" /> <FileText className="w-5 h-5 text-green-600" />
)} )}
</span> </span>
{template.name} <span className="flex-1">{template.name}</span>
{/* 2 boutons : Validé / Refusé (sauf fiche élève) */}
{index !== 0 && (
<span className="ml-2 flex gap-1">
<button
type="button"
className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center gap-1
${docStatuses[index] === 'accepted' ? 'bg-emerald-500 text-white border-emerald-500' : 'bg-white text-emerald-600 border-emerald-300'}`}
aria-pressed={docStatuses[index] === 'accepted'}
onClick={e => {
e.stopPropagation();
setDocStatuses(s => ({ ...s, [index]: 'accepted' }));
// Appel API pour valider le document
if (handleValidateOrRefuseDoc) {
let template = null;
let type = null;
if (index > 0 && index <= schoolFileTemplates.length) {
template = schoolFileTemplates[index - 1];
type = 'school';
} else if (index > schoolFileTemplates.length && index <= schoolFileTemplates.length + parentFileTemplates.length) {
template = parentFileTemplates[index - 1 - schoolFileTemplates.length];
type = 'parent';
}
if (template && template.id) {
handleValidateOrRefuseDoc({
templateId: template.id,
type,
validated: true,
csrfToken,
});
}
}
}}
>
<span className="text-lg"></span> Validé
</button>
<button
type="button"
className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center gap-1
${docStatuses[index] === 'refused' ? 'bg-red-500 text-white border-red-500' : 'bg-white text-red-600 border-red-300'}`}
aria-pressed={docStatuses[index] === 'refused'}
onClick={e => {
e.stopPropagation();
setDocStatuses(s => ({ ...s, [index]: 'refused' }));
// Appel API pour refuser le document
if (handleValidateOrRefuseDoc) {
let template = null;
let type = null;
if (index > 0 && index <= schoolFileTemplates.length) {
template = schoolFileTemplates[index - 1];
type = 'school';
} else if (index > schoolFileTemplates.length && index <= schoolFileTemplates.length + parentFileTemplates.length) {
template = parentFileTemplates[index - 1 - schoolFileTemplates.length];
type = 'parent';
}
if (template && template.id) {
handleValidateOrRefuseDoc({
templateId: template.id,
type,
validated: false,
csrfToken,
});
}
}
}}
>
<span className="text-lg"></span> Refusé
</button>
</span>
)}
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
{/* Option de fusion */} {/* Nouvelle section Options de validation : carte unique, sélecteur de classe (ligne 1), toggle fusion (ligne 2 aligné à droite) */}
<div className="bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200"> {allChecked && allValidated && (
<h3 className="text-lg font-semibold text-gray-800 mb-4"> <div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 flex flex-col gap-4">
Option de fusion <div>
</h3> <SelectChoice
<div className="flex items-center justify-between"> name="associated_class"
<ToggleSwitch label="Liste des classes"
label="Fusionner les documents" placeHolder="Sélectionner une classe"
checked={mergeDocuments} selected={formData.associated_class || ''}
onChange={handleToggleMergeDocuments} callback={(e) => onChange('associated_class', e.target.value)}
/> choices={classes.map((classe) => ({
value: classe.id,
label: classe.atmosphere_name,
}))}
required
className="w-full"
/>
</div>
<div className="flex justify-end items-center mt-2">
<ToggleSwitch
label="Fusionner les documents"
checked={mergeDocuments}
onChange={handleToggleMergeDocuments}
className="ml-0"
/>
</div>
</div> </div>
)}
{/* Boutons Valider/Refuser en bas, centrés */}
<div className="mt-auto py-4">
<Button
text="Soumettre"
onClick={e => {
e.preventDefault();
// 1. Si tous les documents ne sont pas cochés, rien ne se passe (bouton désactivé)
// 2. Si tous cochés et au moins un refusé : popup refus
if (allChecked && hasRefused) {
setShowRefusedPopup(true);
return;
}
// 3. Si tous cochés et tous validés mais pas de classe sélectionnée : bouton désactivé
// 4. Si tous cochés, tous validés et classe sélectionnée : popup de validation finale
if (allChecked && allValidated && formData.associated_class) {
setShowFinalValidationPopup(true);
}
}}
primary
className={`w-full h-12 rounded-md shadow-sm focus:outline-none ${
!allChecked || (allChecked && allValidated && !formData.associated_class)
? 'bg-gray-300 text-gray-700 cursor-not-allowed'
: 'bg-emerald-500 text-white hover:bg-emerald-600'
}`}
disabled={
!allChecked || (allChecked && allValidated && !formData.associated_class)
}
/>
</div> </div>
{/* Section Affectation */} {/* Popup de confirmation si refus */}
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200"> <Popup
<h3 className="text-lg font-semibold text-gray-800 mb-4"> isOpen={showRefusedPopup}
Affectation à une classe onCancel={() => setShowRefusedPopup(false)}
</h3> onConfirm={() => {
<div className="flex flex-col gap-4"> setShowRefusedPopup(false);
<SelectChoice handleRefuseDossier();
name="associated_class" }}
label="Classe" message={
placeHolder="Sélectionner une classe" <span>
selected={formData.associated_class || ''} // La valeur actuelle de la classe associée {`Le dossier d'inscription de ${firstName} ${lastName} va être refusé. Un email sera envoyé au responsable à l'adresse : `}
callback={(e) => onChange('associated_class', e.target.value)} // Met à jour formData <span className="font-semibold text-blue-700">{email}</span>
choices={classes.map((classe) => ({ {' avec la liste des documents non validés :'}
value: classe.id, <ul className="list-disc ml-6 mt-2">
label: classe.atmosphere_name, {refusedDocs.map(doc => (
}))} // Liste des classes disponibles <li key={doc.idx}>{doc.name}</li>
required ))}
/> </ul>
<Button </span>
text="Valider le dossier d'inscription" }
onClick={(e) => { />
e.preventDefault();
handleAssignClass(); {/* Popup de confirmation finale si tous validés et classe sélectionnée */}
}} <Popup
primary isOpen={showFinalValidationPopup}
className={`px-4 py-2 rounded-md shadow-sm focus:outline-none ${ onCancel={() => setShowFinalValidationPopup(false)}
!isPageValid onConfirm={() => {
? 'bg-gray-300 text-gray-700 cursor-not-allowed' setShowFinalValidationPopup(false);
: 'bg-emerald-500 text-white hover:bg-emerald-600' handleAssignClass();
}`} }}
disabled={!isPageValid} message={
/> <span>
</div> {`Le dossier d'inscription de ${lastName} ${firstName} va être validé et l'élève affecté à la classe sélectionnée.`}
</div> </span>
}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { DollarSign } from 'lucide-react'; import { DollarSign } from 'lucide-react';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import logger from '@/utils/logger';
const paymentModesOptions = [ const paymentModesOptions = [
{ id: 1, name: 'Prélèvement SEPA' }, { id: 1, name: 'Prélèvement SEPA' },
@ -9,8 +10,14 @@ const paymentModesOptions = [
{ id: 4, name: 'Espèce' }, { id: 4, name: 'Espèce' },
]; ];
/**
* Affiche les modes de paiement communs aux deux types de frais.
* Quand `allPaymentModes` est fourni (mode unifié), un mode activé est créé
* pour les deux types (inscription 0 ET scolarité 1).
*/
const PaymentModeSelector = ({ const PaymentModeSelector = ({
paymentModes, paymentModes,
allPaymentModes,
setPaymentModes, setPaymentModes,
handleCreate, handleCreate,
handleDelete, handleDelete,
@ -19,23 +26,45 @@ const PaymentModeSelector = ({
const [activePaymentModes, setActivePaymentModes] = useState([]); const [activePaymentModes, setActivePaymentModes] = useState([]);
const { selectedEstablishmentId } = useEstablishment(); const { selectedEstablishmentId } = useEstablishment();
const modes = useMemo(
() =>
Array.isArray(allPaymentModes)
? allPaymentModes
: Array.isArray(paymentModes)
? paymentModes
: [],
[allPaymentModes, paymentModes]
);
const unified = !!allPaymentModes;
useEffect(() => { useEffect(() => {
const activeModes = paymentModes.map((mode) => mode.mode); const activeModes = [...new Set(modes.map((mode) => mode.mode))];
setActivePaymentModes(activeModes); setActivePaymentModes(activeModes);
}, [paymentModes]); }, [modes]);
const handleModeToggle = (modeId) => { const handleModeToggle = (modeId) => {
const updatedMode = paymentModes.find((mode) => mode.mode === modeId); const isActive = activePaymentModes.includes(modeId);
const isActive = !!updatedMode;
if (!isActive) { if (!isActive) {
handleCreate({ if (unified) {
mode: modeId, [0, 1].forEach((t) =>
type, handleCreate({
establishment: selectedEstablishmentId, mode: modeId,
}); type: t,
establishment: selectedEstablishmentId,
}).catch((e) => logger.error(e))
);
} else {
handleCreate({
mode: modeId,
type,
establishment: selectedEstablishmentId,
}).catch((e) => logger.error(e));
}
} else { } else {
handleDelete(updatedMode.id, null); const toDelete = modes.filter((m) => m.mode === modeId);
toDelete.forEach((m) =>
handleDelete(m.id, null).catch((e) => logger.error(e))
);
} }
}; };

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { Calendar } from 'lucide-react'; import { Calendar } from 'lucide-react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
@ -13,8 +13,22 @@ const paymentPlansOptions = [
{ id: 4, name: '12 fois', frequency: 12 }, { id: 4, name: '12 fois', frequency: 12 },
]; ];
/**
* Affiche les plans de paiement communs aux deux types de frais.
* Quand `allPaymentPlans` est fourni (mode unifié), un plan coché est créé pour
* les deux types (inscription 0 ET scolarité 1) en même temps.
*
* Props (mode unifié) :
* allPaymentPlans : [{plan_type, type, ...}, ...] - liste combinée des deux types
* handleCreate : (data) => Promise - avec type et establishment déjà présent dans data
* handleDelete : (id) => Promise
*
* Props (mode legacy) :
* paymentPlans, handleCreate, handleDelete, type
*/
const PaymentPlanSelector = ({ const PaymentPlanSelector = ({
paymentPlans, paymentPlans,
allPaymentPlans,
handleCreate, handleCreate,
handleDelete, handleDelete,
type, type,
@ -24,38 +38,63 @@ const PaymentPlanSelector = ({
const { selectedEstablishmentId } = useEstablishment(); const { selectedEstablishmentId } = useEstablishment();
const [checkedPlans, setCheckedPlans] = useState([]); const [checkedPlans, setCheckedPlans] = useState([]);
// Vérifie si un plan existe pour ce type (par id) const plans = useMemo(
() =>
Array.isArray(allPaymentPlans)
? allPaymentPlans
: Array.isArray(paymentPlans)
? paymentPlans
: [],
[allPaymentPlans, paymentPlans]
);
const unified = !!allPaymentPlans;
// Un plan est coché si au moins un enregistrement existe pour cette option
const isChecked = (planOption) => checkedPlans.includes(planOption.id); const isChecked = (planOption) => checkedPlans.includes(planOption.id);
// Création ou suppression du plan
const handlePlanToggle = (planOption) => { const handlePlanToggle = (planOption) => {
const updatedPlan = paymentPlans.find(
(plan) => plan.plan_type === planOption.id
);
if (isChecked(planOption)) { if (isChecked(planOption)) {
// Supprimer tous les enregistrements correspondant à cette option (les deux types en mode unifié)
const toDelete = plans.filter(
(p) =>
(typeof p.plan_type === 'object' ? p.plan_type.id : p.plan_type) ===
planOption.id
);
setCheckedPlans((prev) => prev.filter((id) => id !== planOption.id)); setCheckedPlans((prev) => prev.filter((id) => id !== planOption.id));
handleDelete(updatedPlan.id, null); toDelete.forEach((p) =>
handleDelete(p.id, null).catch((e) => logger.error(e))
);
} else { } else {
setCheckedPlans((prev) => [...prev, planOption.id]); setCheckedPlans((prev) => [...prev, planOption.id]);
handleCreate({ if (unified) {
plan_type: planOption.id, // Créer pour inscription (0) et scolarité (1)
type, [0, 1].forEach((t) =>
establishment: selectedEstablishmentId, handleCreate({
}); plan_type: planOption.id,
type: t,
establishment: selectedEstablishmentId,
}).catch((e) => logger.error(e))
);
} else {
handleCreate({
plan_type: planOption.id,
type,
establishment: selectedEstablishmentId,
}).catch((e) => logger.error(e));
}
} }
}; };
useEffect(() => { useEffect(() => {
if (paymentPlans && paymentPlans.length > 0) { if (plans.length > 0) {
setCheckedPlans( const ids = plans.map((plan) =>
paymentPlans.map((plan) => typeof plan.plan_type === 'object' ? plan.plan_type.id : plan.plan_type
typeof plan.plan_type === 'object'
? plan.plan_type.id
: plan.plan_type
)
); );
setCheckedPlans([...new Set(ids)]);
} else {
setCheckedPlans([]);
} }
}, [paymentPlans]); }, [plans]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">

View File

@ -1,27 +1,32 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { FE_USERS_LOGIN_URL, getRedirectUrlFromRole } from '@/utils/Url'; import { FE_USERS_LOGIN_URL, getRedirectUrlFromRole } from '@/utils/Url';
import Loader from '@/components/Loader';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
const ProtectedRoute = ({ children, requiredRight }) => { const ProtectedRoute = ({ children, requiredRight }) => {
const { user, profileRole } = useEstablishment(); const { data: session, status } = useSession();
const { profileRole } = useEstablishment();
const router = useRouter(); const router = useRouter();
const [hasRequiredRight, setHasRequiredRight] = useState(false); const [hasRequiredRight, setHasRequiredRight] = useState(false);
// Vérifier si l'utilisateur a au moins un rôle correspondant au requiredRight
useEffect(() => { useEffect(() => {
logger.debug({ // Ne pas agir tant que NextAuth charge la session
user, if (status === 'loading') return;
profileRole,
requiredRight,
hasRequiredRight,
});
if (user && profileRole !== null) { logger.debug({ status, profileRole, requiredRight });
if (status === 'unauthenticated') {
router.push(FE_USERS_LOGIN_URL);
return;
}
// status === 'authenticated' — vérifier les droits
if (profileRole !== null && profileRole !== undefined) {
let requiredRightChecked = false; let requiredRightChecked = false;
if (requiredRight && Array.isArray(requiredRight)) { if (requiredRight && Array.isArray(requiredRight)) {
// Vérifier si l'utilisateur a le droit requis
requiredRightChecked = requiredRight.some( requiredRightChecked = requiredRight.some(
(right) => profileRole === right (right) => profileRole === right
); );
@ -30,21 +35,18 @@ const ProtectedRoute = ({ children, requiredRight }) => {
} }
setHasRequiredRight(requiredRightChecked); setHasRequiredRight(requiredRightChecked);
// Vérifier si l'utilisateur a le droit requis mais pas le bon role on le redirige la page d'accueil associé au role
if (!requiredRightChecked) { if (!requiredRightChecked) {
const redirectUrl = getRedirectUrlFromRole(profileRole); const redirectUrl = getRedirectUrlFromRole(profileRole);
if (redirectUrl !== null) { if (redirectUrl) {
router.push(`${redirectUrl}`); router.push(redirectUrl);
} }
} }
} else {
// User non authentifié
router.push(`${FE_USERS_LOGIN_URL}`);
} }
}, [user, profileRole]); }, [status, profileRole, requiredRight]);
// Autoriser l'affichage si authentifié et rôle correct if (status === 'loading' || !hasRequiredRight) return <Loader />;
return hasRequiredRight ? children : null;
return children;
}; };
export default ProtectedRoute; export default ProtectedRoute;

View File

@ -7,7 +7,6 @@ import CheckBox from '@/components/Form/CheckBox';
import Button from '@/components/Form/Button'; import Button from '@/components/Form/Button';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { import {
fetchEstablishmentCompetencies,
createEstablishmentCompetencies, createEstablishmentCompetencies,
deleteEstablishmentCompetencies, deleteEstablishmentCompetencies,
} from '@/app/actions/schoolAction'; } from '@/app/actions/schoolAction';
@ -44,7 +43,7 @@ export default function CompetenciesList({
3: false, 3: false,
4: false, 4: false,
}); });
const { selectedEstablishmentId } = useEstablishment(); const { selectedEstablishmentId, profileRole } = useEstablishment();
const csrfToken = useCsrfToken(); const csrfToken = useCsrfToken();
const { showNotification } = useNotification(); const { showNotification } = useNotification();
@ -280,17 +279,19 @@ export default function CompetenciesList({
</div> </div>
{/* Bouton submit centré en bas */} {/* Bouton submit centré en bas */}
<div className="flex justify-center mb-2 mt-6"> <div className="flex justify-center mb-2 mt-6">
<Button {profileRole !== 0 && (
text="Sauvegarder" <Button
className={`px-6 py-2 rounded-md shadow ${ text="Sauvegarder"
!hasSelection className={`px-6 py-2 rounded-md shadow ${
? 'bg-gray-300 text-gray-500 cursor-not-allowed' !hasSelection
: 'bg-emerald-500 text-white hover:bg-emerald-600' ? 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`} : 'bg-emerald-500 text-white hover:bg-emerald-600'
onClick={handleSubmit} }`}
primary onClick={handleSubmit}
disabled={!hasSelection} primary
/> disabled={!hasSelection}
/>
)}
</div> </div>
{/* Légende en dessous du bouton, alignée à gauche */} {/* Légende en dessous du bouton, alignée à gauche */}
<div className="flex flex-row items-center gap-4 mb-4"> <div className="flex flex-row items-center gap-4 mb-4">

View File

@ -5,6 +5,7 @@ import React, {
useImperativeHandle, useImperativeHandle,
} from 'react'; } from 'react';
import { CheckCircle, Circle } from 'lucide-react'; import { CheckCircle, Circle } from 'lucide-react';
import { useEstablishment } from '@/context/EstablishmentContext';
const TreeView = forwardRef(function TreeView( const TreeView = forwardRef(function TreeView(
{ data, expandAll, onSelectionChange }, { data, expandAll, onSelectionChange },
@ -72,6 +73,8 @@ const TreeView = forwardRef(function TreeView(
clearSelection: () => setSelectedCompetencies({}), clearSelection: () => setSelectedCompetencies({}),
})); }));
const { profileRole } = useEstablishment();
return ( return (
<div> <div>
{data.map((domaine) => ( {data.map((domaine) => (
@ -112,12 +115,18 @@ const TreeView = forwardRef(function TreeView(
? 'text-emerald-600 font-semibold cursor-pointer' ? 'text-emerald-600 font-semibold cursor-pointer'
: 'text-gray-500 cursor-pointer hover:text-emerald-600' : 'text-gray-500 cursor-pointer hover:text-emerald-600'
}`} }`}
onClick={() => handleCompetenceClick(competence)} onClick={
profileRole !== 0
? () => handleCompetenceClick(competence)
: undefined
}
style={{ style={{
cursor: cursor:
competence.state === 'required' competence.state === 'required'
? 'default' ? 'default'
: 'pointer', : profileRole !== 0
? 'pointer'
: 'default',
userSelect: 'none', userSelect: 'none',
}} }}
> >

View File

@ -130,9 +130,7 @@ const ClassesSection = ({
const [removePopupVisible, setRemovePopupVisible] = useState(false); const [removePopupVisible, setRemovePopupVisible] = useState(false);
const [removePopupMessage, setRemovePopupMessage] = useState(''); const [removePopupMessage, setRemovePopupMessage] = useState('');
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
const [detailsModalVisible, setDetailsModalVisible] = useState(false); const { selectedEstablishmentId, profileRole } = useEstablishment();
const [selectedClass, setSelectedClass] = useState(null);
const { selectedEstablishmentId } = useEstablishment();
const { addSchedule, reloadPlanning, reloadEvents } = usePlanning(); const { addSchedule, reloadPlanning, reloadEvents } = usePlanning();
const { getNiveauxLabels, allNiveaux } = useClasses(); const { getNiveauxLabels, allNiveaux } = useClasses();
const router = useRouter(); const router = useRouter();
@ -449,6 +447,25 @@ const ClassesSection = ({
case 'MISE A JOUR': case 'MISE A JOUR':
return classe.updated_date_formatted; return classe.updated_date_formatted;
case 'ACTIONS': case 'ACTIONS':
// Affichage des actions en mode affichage (hors édition/création)
if (profileRole === 0) {
// Si professeur, uniquement le bouton ZoomIn
return (
<div className="flex justify-center space-x-2">
<button
type="button"
onClick={() => {
const url = `${FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL}?schoolClassId=${classe.id}`;
router.push(`${url}`);
}}
className="text-gray-500 hover:text-gray-700"
>
<ZoomIn className="w-5 h-5" />
</button>
</div>
);
}
// Sinon, toutes les actions (admin)
return ( return (
<div className="flex justify-center space-x-2"> <div className="flex justify-center space-x-2">
<button <button
@ -534,7 +551,7 @@ const ClassesSection = ({
icon={Users} icon={Users}
title="Liste des classes" title="Liste des classes"
description="Gérez les classes de votre école" description="Gérez les classes de votre école"
button={true} button={profileRole !== 0}
onClick={handleAddClass} onClick={handleAddClass}
/> />
<Table <Table

View File

@ -29,7 +29,7 @@ const SpecialitiesSection = ({
const [removePopupMessage, setRemovePopupMessage] = useState(''); const [removePopupMessage, setRemovePopupMessage] = useState('');
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
const { selectedEstablishmentId } = useEstablishment(); const { selectedEstablishmentId, profileRole } = useEstablishment();
// Récupération des messages d'erreur // Récupération des messages d'erreur
const getError = (field) => { const getError = (field) => {
@ -239,7 +239,7 @@ const SpecialitiesSection = ({
const columns = [ const columns = [
{ name: 'LIBELLE', label: 'Libellé' }, { name: 'LIBELLE', label: 'Libellé' },
{ name: 'MISE A JOUR', label: 'Date mise à jour' }, { name: 'MISE A JOUR', label: 'Date mise à jour' },
{ name: 'ACTIONS', label: 'Actions' }, ...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []),
]; ];
return ( return (
@ -249,7 +249,7 @@ const SpecialitiesSection = ({
icon={BookOpen} icon={BookOpen}
title="Liste des spécialités" title="Liste des spécialités"
description="Gérez les spécialités de votre école" description="Gérez les spécialités de votre école"
button={true} button={profileRole !== 0}
onClick={handleAddSpeciality} onClick={handleAddSpeciality}
/> />
<Table <Table

View File

@ -3,8 +3,7 @@ import { Edit3, Trash2, GraduationCap, Check, X, Hand } from 'lucide-react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
import ToggleSwitch from '@/components/Form/ToggleSwitch'; import ToggleSwitch from '@/components/Form/ToggleSwitch';
import { useCsrfToken } from '@/context/CsrfContext'; import { DndProvider, useDrop } from 'react-dnd';
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';
import InputText from '@/components/Form/InputText'; import InputText from '@/components/Form/InputText';
import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem'; import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem';
@ -128,7 +127,6 @@ const TeachersSection = ({
handleEdit, handleEdit,
handleDelete, handleDelete,
}) => { }) => {
const csrfToken = useCsrfToken();
const [editingTeacher, setEditingTeacher] = useState(null); const [editingTeacher, setEditingTeacher] = useState(null);
const [newTeacher, setNewTeacher] = useState(null); const [newTeacher, setNewTeacher] = useState(null);
const [formData, setFormData] = useState({}); const [formData, setFormData] = useState({});
@ -140,40 +138,49 @@ const TeachersSection = ({
const [removePopupMessage, setRemovePopupMessage] = useState(''); const [removePopupMessage, setRemovePopupMessage] = useState('');
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
const [confirmPopupVisible, setConfirmPopupVisible] = useState(false); const { selectedEstablishmentId, profileRole } = useEstablishment();
const [confirmPopupMessage, setConfirmPopupMessage] = useState('');
const [confirmPopupOnConfirm, setConfirmPopupOnConfirm] = useState(() => {});
const { selectedEstablishmentId } = useEstablishment(); // --- UTILS ---
// Retourne le profil existant pour un email
const getUsedProfileForEmail = (email) => {
// On cherche tous les profils dont l'email correspond
const matchingProfiles = profiles.filter((p) => p.email === email);
// On retourne le premier profil correspondant (ou undefined)
const result =
matchingProfiles.length > 0 ? matchingProfiles[0] : undefined;
return result;
};
// Met à jour le formData et newTeacher si besoin
const updateFormData = (data) => {
setFormData((prev) => ({ ...prev, ...data }));
if (newTeacher) setNewTeacher((prev) => ({ ...prev, ...data }));
};
// Récupération des messages d'erreur pour un champ donné
const getError = (field) => {
return localErrors?.[field]?.[0];
};
// --- HANDLERS ---
const handleEmailChange = (e) => { const handleEmailChange = (e) => {
const email = e.target.value; const email = e.target.value;
const existingProfile = getUsedProfileForEmail(email);
// Vérifier si l'email correspond à un profil existant if (existingProfile) {
const existingProfile = profiles.find((profile) => profile.email === email); logger.info(
`Adresse email déjà utilisée pour le profil ${existingProfile.id}`
);
}
setFormData((prevData) => ({ updateFormData({
...prevData,
associated_profile_email: email, associated_profile_email: email,
existingProfileId: existingProfile ? existingProfile.id : null, existingProfileId: existingProfile ? existingProfile.id : null,
})); });
if (newTeacher) {
setNewTeacher((prevData) => ({
...prevData,
associated_profile_email: email,
existingProfileId: existingProfile ? existingProfile.id : null,
}));
}
};
const handleCancelConfirmation = () => {
setConfirmPopupVisible(false);
};
// Récupération des messages d'erreur
const getError = (field) => {
return localErrors?.[field]?.[0];
}; };
const handleAddTeacher = () => { const handleAddTeacher = () => {
@ -195,15 +202,15 @@ const TeachersSection = ({
}; };
const handleRemoveTeacher = (id) => { const handleRemoveTeacher = (id) => {
logger.debug('[DELETE] Suppression teacher id:', id);
return handleDelete(id) return handleDelete(id)
.then(() => { .then(() => {
setTeachers((prevTeachers) => setTeachers((prevTeachers) =>
prevTeachers.filter((teacher) => teacher.id !== id) prevTeachers.filter((teacher) => teacher.id !== id)
); );
logger.debug('[DELETE] Teacher supprimé:', id);
}) })
.catch((error) => { .catch(logger.error);
logger.error(error);
});
}; };
const handleSaveNewTeacher = () => { const handleSaveNewTeacher = () => {
@ -234,16 +241,29 @@ const TeachersSection = ({
handleCreate(data) handleCreate(data)
.then((createdTeacher) => { .then((createdTeacher) => {
// Recherche du profile associé dans profiles
let newProfileId = undefined;
let foundProfile = undefined;
if (
createdTeacher &&
createdTeacher.profile_role &&
createdTeacher.profile
) {
newProfileId = createdTeacher.profile;
foundProfile = profiles.find((p) => p.id === newProfileId);
}
setTeachers([createdTeacher, ...teachers]); setTeachers([createdTeacher, ...teachers]);
setNewTeacher(null); setNewTeacher(null);
setLocalErrors({}); setLocalErrors({});
setFormData((prev) => ({
...prev,
existingProfileId: newProfileId,
}));
}) })
.catch((error) => { .catch((error) => {
logger.error('Error:', error.message); logger.error('Error:', error.message);
if (error.details) { if (error.details) setLocalErrors(error.details);
logger.error('Form errors:', error.details);
setLocalErrors(error.details);
}
}); });
} else { } else {
setPopupMessage('Tous les champs doivent être remplis et valides'); setPopupMessage('Tous les champs doivent être remplis et valides');
@ -252,51 +272,24 @@ const TeachersSection = ({
}; };
const handleUpdateTeacher = (id, updatedData) => { const handleUpdateTeacher = (id, updatedData) => {
// Récupérer l'enseignant actuel à partir de la liste des enseignants
const currentTeacher = teachers.find((teacher) => teacher.id === id);
// Vérifier si l'email correspond à un profil existant
const existingProfile = profiles.find(
(profile) => profile.email === currentTeacher.associated_profile_email
);
// Vérifier si l'email a été modifié
const isEmailModified = currentTeacher
? currentTeacher.associated_profile_email !==
updatedData.associated_profile_email
: true;
// Mettre à jour existingProfileId en fonction de l'email
updatedData.existingProfileId = existingProfile ? existingProfile.id : null;
if ( if (
updatedData.last_name && updatedData.last_name &&
updatedData.first_name && updatedData.first_name &&
updatedData.associated_profile_email updatedData.associated_profile_email
) { ) {
const data = { const profileRoleData = {
last_name: updatedData.last_name, id: updatedData.profile_role,
first_name: updatedData.first_name, establishment: selectedEstablishmentId,
profile_role_data: { role_type: updatedData.role_type || 0,
id: updatedData.profile_role, profile: updatedData.existingProfileId,
establishment: selectedEstablishmentId,
role_type: updatedData.role_type || 0,
is_active: true,
...(isEmailModified
? {
profile_data: {
id: updatedData.existingProfileId,
email: updatedData.associated_profile_email,
username: updatedData.associated_profile_email,
password: 'Provisoire01!',
},
}
: { profile: updatedData.existingProfileId }),
},
specialities: updatedData.specialities || [],
}; };
handleEdit(id, data) handleEdit(id, {
last_name: updatedData.last_name,
first_name: updatedData.first_name,
profile_role_data: profileRoleData,
specialities: updatedData.specialities || [],
})
.then((updatedTeacher) => { .then((updatedTeacher) => {
setTeachers((prevTeachers) => setTeachers((prevTeachers) =>
prevTeachers.map((teacher) => prevTeachers.map((teacher) =>
@ -308,10 +301,7 @@ const TeachersSection = ({
}) })
.catch((error) => { .catch((error) => {
logger.error('Error:', error.message); logger.error('Error:', error.message);
if (error.details) { if (error.details) setLocalErrors(error.details);
logger.error('Form errors:', error.details);
setLocalErrors(error.details);
}
}); });
} else { } else {
setPopupMessage('Tous les champs doivent être remplis et valides'); setPopupMessage('Tous les champs doivent être remplis et valides');
@ -321,45 +311,12 @@ const TeachersSection = ({
const handleChange = (e) => { const handleChange = (e) => {
const { name, value, type, checked } = e.target; const { name, value, type, checked } = e.target;
let parsedValue = value; let parsedValue = type === 'checkbox' ? (checked ? 1 : 0) : value;
updateFormData({ [name]: parsedValue });
if (type === 'checkbox') {
parsedValue = checked ? 1 : 0;
}
if (editingTeacher) {
setFormData((prevData) => ({
...prevData,
[name]: parsedValue,
}));
} else if (newTeacher) {
setNewTeacher((prevData) => ({
...prevData,
[name]: parsedValue,
}));
setFormData((prevData) => ({
...prevData,
[name]: parsedValue,
}));
}
}; };
const handleSpecialitiesChange = (selectedSpecialities) => { const handleSpecialitiesChange = (selectedSpecialities) => {
if (editingTeacher) { updateFormData({ specialities: selectedSpecialities });
setFormData((prevData) => ({
...prevData,
specialities: selectedSpecialities,
}));
} else if (newTeacher) {
setNewTeacher((prevData) => ({
...prevData,
specialities: selectedSpecialities,
}));
setFormData((prevData) => ({
...prevData,
specialities: selectedSpecialities,
}));
}
}; };
const handleEditTeacher = (teacher) => { const handleEditTeacher = (teacher) => {
@ -406,6 +363,7 @@ const TeachersSection = ({
onChange={handleEmailChange} onChange={handleEmailChange}
placeholder="Adresse email de l'enseignant" placeholder="Adresse email de l'enseignant"
errorMsg={getError('email')} errorMsg={getError('email')}
enable={!isEditing}
/> />
); );
case 'SPECIALITES': case 'SPECIALITES':
@ -464,7 +422,7 @@ const TeachersSection = ({
case 'SPECIALITES': case 'SPECIALITES':
return ( return (
<div className="flex justify-center space-x-2 flex-wrap"> <div className="flex justify-center space-x-2 flex-wrap">
{teacher.specialities_details.map((speciality) => ( {(teacher.specialities_details ?? []).map((speciality) => (
<SpecialityItem <SpecialityItem
key={speciality.id} key={speciality.id}
speciality={speciality} speciality={speciality}
@ -563,7 +521,7 @@ const TeachersSection = ({
{ name: 'SPECIALITES', label: 'Spécialités' }, { name: 'SPECIALITES', label: 'Spécialités' },
{ name: 'ADMINISTRATEUR', label: 'Profil' }, { name: 'ADMINISTRATEUR', label: 'Profil' },
{ name: 'MISE A JOUR', label: 'Mise à jour' }, { name: 'MISE A JOUR', label: 'Mise à jour' },
{ name: 'ACTIONS', label: 'Actions' }, ...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []),
]; ];
return ( return (
@ -573,7 +531,7 @@ const TeachersSection = ({
icon={GraduationCap} icon={GraduationCap}
title="Liste des enseignants.es" title="Liste des enseignants.es"
description="Gérez les enseignants.es de votre école" description="Gérez les enseignants.es de votre école"
button={true} button={profileRole !== 0}
onClick={handleAddTeacher} onClick={handleAddTeacher}
/> />
<Table <Table

View File

@ -1,209 +0,0 @@
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

@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Edit, Edit,
Trash2, Trash2,
@ -6,8 +6,6 @@ import {
Star, Star,
ChevronDown, ChevronDown,
Plus, Plus,
Archive,
Eye
} from 'lucide-react'; } from 'lucide-react';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder'; import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
@ -31,11 +29,9 @@ import {
} from '@/app/actions/registerFileGroupAction'; } from '@/app/actions/registerFileGroupAction';
import RegistrationFileGroupForm from '@/components/Structure/Files/RegistrationFileGroupForm'; import RegistrationFileGroupForm from '@/components/Structure/Files/RegistrationFileGroupForm';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import ParentFiles from './ParentFiles';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
import Loader from '@/components/Loader'; import Loader from '@/components/Loader';
import { useNotification } from '@/context/NotificationContext'; import { useNotification } from '@/context/NotificationContext';
import CreateDocumentModal from '@/components/Structure/Files/CreateDocumentModal';
import FileUpload from '@/components/Form/FileUpload'; import FileUpload from '@/components/Form/FileUpload';
import SectionTitle from '@/components/SectionTitle'; import SectionTitle from '@/components/SectionTitle';
import DropdownMenu from '@/components/DropdownMenu'; import DropdownMenu from '@/components/DropdownMenu';
@ -196,8 +192,8 @@ function SimpleList({
export default function FilesGroupsManagement({ export default function FilesGroupsManagement({
csrfToken, csrfToken,
selectedEstablishmentId, selectedEstablishmentId,
profileRole
}) { }) {
const [showFilePreview, setShowFilePreview] = useState(false);
const [schoolFileMasters, setSchoolFileMasters] = useState([]); const [schoolFileMasters, setSchoolFileMasters] = useState([]);
const [parentFiles, setParentFileMasters] = useState([]); const [parentFiles, setParentFileMasters] = useState([]);
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
@ -215,7 +211,6 @@ export default function FilesGroupsManagement({
const [selectedGroupId, setSelectedGroupId] = useState(null); const [selectedGroupId, setSelectedGroupId] = useState(null);
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const [isFileUploadOpen, setIsFileUploadOpen] = useState(false);
const [isParentFileModalOpen, setIsParentFileModalOpen] = useState(false); const [isParentFileModalOpen, setIsParentFileModalOpen] = useState(false);
const [editingParentFile, setEditingParentFile] = useState(null); const [editingParentFile, setEditingParentFile] = useState(null);
@ -823,13 +818,15 @@ export default function FilesGroupsManagement({
<div className="flex items-center mb-4"> <div className="flex items-center mb-4">
<SectionTitle title="Liste des dossiers d'inscriptions" /> <SectionTitle title="Liste des dossiers d'inscriptions" />
<div className="flex-1" /> <div className="flex-1" />
<button {profileRole !== 0 && (
className="flex items-center justify-center bg-emerald-500 hover:bg-emerald-600 text-white px-3 py-2 rounded-lg shadow transition text-base font-semibold" <button
onClick={() => setIsGroupModalOpen(true)} className="flex items-center justify-center bg-emerald-500 hover:bg-emerald-600 text-white px-3 py-2 rounded-lg shadow transition text-base font-semibold"
title="Créer un nouveau dossier" onClick={() => setIsGroupModalOpen(true)}
> title="Créer un nouveau dossier"
<Plus className="w-5 h-5" /> >
</button> <Plus className="w-5 h-5" />
</button>
)}
</div> </div>
<SimpleList <SimpleList
items={groups} items={groups}
@ -869,52 +866,54 @@ export default function FilesGroupsManagement({
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
<div className="flex items-center mb-4"> <div className="flex items-center mb-4">
<SectionTitle title="Liste des documents" /> <SectionTitle title="Liste des documents" />
<div className="flex-1" /> <div className="flex-1" />
<DropdownMenu {profileRole !== 0 && (
buttonContent={ <DropdownMenu
<span className="flex items-center"> buttonContent={
<Plus className="w-5 h-5" />
<ChevronDown className="w-4 h-4 ml-1" />
</span>
}
items={[
{
type: 'item',
label: (
<span className="flex items-center"> <span className="flex items-center">
<Star className="w-5 h-5 mr-2 text-yellow-600" /> <Plus className="w-5 h-5" />
Formulaire personnalisé <ChevronDown className="w-4 h-4 ml-1" />
</span> </span>
), }
onClick: () => handleDocDropdownSelect('formulaire'), items={[
}, {
{ type: 'item',
type: 'item', label: (
label: ( <span className="flex items-center">
<span className="flex items-center"> <Star className="w-5 h-5 mr-2 text-yellow-600" />
<FileText className="w-5 h-5 mr-2 text-gray-600" /> Formulaire personnalisé
Formulaire existant </span>
</span> ),
), onClick: () => handleDocDropdownSelect('formulaire'),
onClick: () => handleDocDropdownSelect('formulaire_existant'), },
}, {
{ type: 'item',
type: 'item', label: (
label: ( <span className="flex items-center">
<span className="flex items-center"> <FileText className="w-5 h-5 mr-2 text-gray-600" />
<Plus className="w-5 h-5 mr-2 text-orange-500" /> Formulaire existant
Pièce à fournir </span>
</span> ),
), onClick: () => handleDocDropdownSelect('formulaire_existant'),
onClick: () => handleDocDropdownSelect('parent'), },
}, {
]} type: 'item',
buttonClassName="flex items-center justify-center bg-emerald-500 hover:bg-emerald-600 text-white px-3 py-2 rounded-lg shadow transition text-base font-semibold" label: (
menuClassName="absolute right-0 mt-2 w-56 bg-white border border-gray-200 rounded shadow-lg z-20" <span className="flex items-center">
dropdownOpen={isDocDropdownOpen} <Plus className="w-5 h-5 mr-2 text-orange-500" />
setDropdownOpen={setIsDocDropdownOpen} Pièce à fournir
/> </span>
</div> ),
onClick: () => handleDocDropdownSelect('parent'),
},
]}
buttonClassName="flex items-center justify-center bg-emerald-500 hover:bg-emerald-600 text-white px-3 py-2 rounded-lg shadow transition text-base font-semibold"
menuClassName="absolute right-0 mt-2 w-56 bg-white border border-gray-200 rounded shadow-lg z-20"
dropdownOpen={isDocDropdownOpen}
setDropdownOpen={setIsDocDropdownOpen}
/>
)}
</div>
{!selectedGroupId ? ( {!selectedGroupId ? (
<div className="flex items-center justify-center h-40 text-gray-400 text-lg italic border border-gray-200 rounded bg-white"> <div className="flex items-center justify-center h-40 text-gray-400 text-lg italic border border-gray-200 rounded bg-white">
Sélectionner un dossier d&apos;inscription Sélectionner un dossier d&apos;inscription

View File

@ -9,6 +9,8 @@ import SectionHeader from '@/components/SectionHeader';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import AlertMessage from '@/components/AlertMessage'; import AlertMessage from '@/components/AlertMessage';
const DISCOUNT_TYPE_LABELS = { 0: 'Inscription', 1: 'Scolarité' };
const DiscountsSection = ({ const DiscountsSection = ({
discounts, discounts,
setDiscounts, setDiscounts,
@ -16,6 +18,7 @@ const DiscountsSection = ({
handleEdit, handleEdit,
handleDelete, handleDelete,
type, type,
unified = false,
subscriptionMode = false, subscriptionMode = false,
selectedDiscounts, selectedDiscounts,
handleDiscountSelection, handleDiscountSelection,
@ -39,7 +42,7 @@ const DiscountsSection = ({
amount: '', amount: '',
description: '', description: '',
discount_type: 0, discount_type: 0,
type: type, type: unified ? 0 : type,
establishment: selectedEstablishmentId, establishment: selectedEstablishmentId,
}); });
}; };
@ -219,6 +222,21 @@ const DiscountsSection = ({
handleChange, handleChange,
'Description' 'Description'
); );
case 'TYPE':
return (
<select
className="border rounded px-2 py-1 text-sm"
value={currentData.type}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (editingDiscount) setFormData((p) => ({ ...p, type: val }));
else setNewDiscount((p) => ({ ...p, type: val }));
}}
>
<option value={0}>Inscription</option>
<option value={1}>Scolarité</option>
</select>
);
case 'ACTIONS': case 'ACTIONS':
return ( return (
<div className="flex justify-center space-x-2"> <div className="flex justify-center space-x-2">
@ -259,6 +277,18 @@ const DiscountsSection = ({
return discount.description; return discount.description;
case 'MISE A JOUR': case 'MISE A JOUR':
return discount.updated_at_formatted; return discount.updated_at_formatted;
case 'TYPE':
return (
<span
className={`text-xs font-semibold px-2 py-1 rounded-full ${
discount.type === 0
? 'bg-blue-100 text-blue-700'
: 'bg-purple-100 text-purple-700'
}`}
>
{DISCOUNT_TYPE_LABELS[discount.type]}
</span>
);
case 'ACTIONS': case 'ACTIONS':
return ( return (
<div className="flex justify-center space-x-2"> <div className="flex justify-center space-x-2">
@ -335,34 +365,25 @@ const DiscountsSection = ({
{ name: 'LIBELLE', label: 'Libellé' }, { name: 'LIBELLE', label: 'Libellé' },
{ name: 'DESCRIPTION', label: 'Description' }, { name: 'DESCRIPTION', label: 'Description' },
{ name: 'REMISE', label: 'Remise' }, { name: 'REMISE', label: 'Remise' },
...(unified ? [{ name: 'TYPE', label: 'Type' }] : []),
{ name: '', label: 'Sélection' }, { name: '', label: 'Sélection' },
] ]
: [ : [
{ name: 'LIBELLE', label: 'Libellé' }, { name: 'LIBELLE', label: 'Libellé' },
{ name: 'REMISE', label: 'Remise' }, { name: 'REMISE', label: 'Remise' },
{ name: 'DESCRIPTION', label: 'Description' }, { name: 'DESCRIPTION', label: 'Description' },
...(unified ? [{ name: 'TYPE', label: 'Type' }] : []),
{ name: 'MISE A JOUR', label: 'Date mise à jour' }, { name: 'MISE A JOUR', label: 'Date mise à jour' },
{ name: 'ACTIONS', label: 'Actions' }, { name: 'ACTIONS', label: 'Actions' },
]; ];
let emptyMessage; const emptyMessage = (
if (type === 0) { <AlertMessage
emptyMessage = ( type="info"
<AlertMessage title="Aucune réduction enregistrée"
type="info" message="Aucune réduction n'a encore été enregistrée"
title="Aucune réduction enregistrée" />
message="Aucune réduction sur les frais d'inscription n'a été enregistrée" );
/>
);
} else {
emptyMessage = (
<AlertMessage
type="info"
title="Aucune réduction enregistrée"
message="Aucune réduction sur les frais de scolarité n'a été enregistrée"
/>
);
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -370,8 +391,8 @@ const DiscountsSection = ({
<SectionHeader <SectionHeader
icon={Tag} icon={Tag}
discountStyle={true} discountStyle={true}
title={`${type == 0 ? "Liste des réductions sur les frais d'inscription" : 'Liste des réductions sur les frais de scolarité'}`} title="Liste des réductions"
description={`Gérez ${type == 0 ? " vos réductions sur les frais d'inscription" : ' vos réductions sur les frais de scolarité'}`} description="Gérez vos réductions sur les frais d'inscription et de scolarité"
button={!subscriptionMode} button={!subscriptionMode}
onClick={handleAddDiscount} onClick={handleAddDiscount}
/> />

View File

@ -0,0 +1,145 @@
import React from 'react';
import FeesSection from '@/components/Structure/Tarification/FeesSection';
import DiscountsSection from '@/components/Structure/Tarification/DiscountsSection';
import PaymentPlanSelector from '@/components/PaymentPlanSelector';
import PaymentModeSelector from '@/components/PaymentModeSelector';
import {
BE_SCHOOL_FEES_URL,
BE_SCHOOL_DISCOUNTS_URL,
BE_SCHOOL_PAYMENT_PLANS_URL,
BE_SCHOOL_PAYMENT_MODES_URL,
} from '@/utils/Url';
/**
* Bloc complet de gestion des frais pour un type donné (inscription ou scolarité).
* Regroupe : liste des frais, réductions, plans et modes de paiement.
*
* @param {string} title - Titre affiché dans le séparateur de section
* @param {Array} fees - Liste des frais du type
* @param {Function} setFees - Setter des frais
* @param {Array} discounts - Liste des réductions du type
* @param {Function} setDiscounts - Setter des réductions
* @param {Array} paymentPlans - Plans de paiement du type
* @param {Function} setPaymentPlans - Setter des plans de paiement
* @param {Array} paymentModes - Modes de paiement du type
* @param {Function} setPaymentModes - Setter des modes de paiement
* @param {number} type - 0 = inscription, 1 = scolarité
* @param {Function} handleCreate - (url, newData, setter) => Promise
* @param {Function} handleEdit - (url, id, updatedData, setter) => Promise
* @param {Function} handleDelete - (url, id, setter) => Promise
* @param {Function} onDiscountDelete - Callback invoqué après suppression d'une réduction
*/
const FeeTypeSection = ({
title,
fees,
setFees,
discounts,
setDiscounts,
paymentPlans,
setPaymentPlans,
paymentModes,
setPaymentModes,
type,
handleCreate,
handleEdit,
handleDelete,
onDiscountDelete,
}) => {
return (
<>
<div className="w-4/5 mx-auto flex items-center mt-8">
<hr className="flex-grow border-t-2 border-gray-300" />
<span className="mx-4 text-gray-600 font-semibold">{title}</span>
<hr className="flex-grow border-t-2 border-gray-300" />
</div>
<div className="mt-8 w-4/5">
<FeesSection
fees={fees}
setFees={setFees}
discounts={discounts}
handleCreate={(newData) =>
handleCreate(`${BE_SCHOOL_FEES_URL}`, newData, setFees)
}
handleEdit={(id, updatedData) =>
handleEdit(`${BE_SCHOOL_FEES_URL}`, id, updatedData, setFees)
}
handleDelete={(id) =>
handleDelete(`${BE_SCHOOL_FEES_URL}`, id, setFees)
}
type={type}
/>
</div>
<div className="mt-12 w-4/5">
<DiscountsSection
discounts={discounts}
setDiscounts={setDiscounts}
handleCreate={(newData) =>
handleCreate(`${BE_SCHOOL_DISCOUNTS_URL}`, newData, setDiscounts)
}
handleEdit={(id, updatedData) =>
handleEdit(
`${BE_SCHOOL_DISCOUNTS_URL}`,
id,
updatedData,
setDiscounts
)
}
handleDelete={(id) =>
handleDelete(`${BE_SCHOOL_DISCOUNTS_URL}`, id, setDiscounts)
}
onDiscountDelete={onDiscountDelete}
type={type}
/>
</div>
<div className="mt-12 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="col-span-1 mt-4">
<PaymentPlanSelector
paymentPlans={paymentPlans}
setPaymentPlans={setPaymentPlans}
handleCreate={(newData) =>
handleCreate(
`${BE_SCHOOL_PAYMENT_PLANS_URL}`,
newData,
setPaymentPlans
)
}
handleDelete={(id) =>
handleDelete(
`${BE_SCHOOL_PAYMENT_PLANS_URL}`,
id,
setPaymentPlans
)
}
type={type}
/>
</div>
<div className="col-span-1 mt-4">
<PaymentModeSelector
paymentModes={paymentModes}
setPaymentModes={setPaymentModes}
handleCreate={(newData) =>
handleCreate(
`${BE_SCHOOL_PAYMENT_MODES_URL}`,
newData,
setPaymentModes
)
}
handleDelete={(id) =>
handleDelete(
`${BE_SCHOOL_PAYMENT_MODES_URL}`,
id,
setPaymentModes
)
}
type={type}
/>
</div>
</div>
</>
);
};
export default FeeTypeSection;

View File

@ -1,4 +1,4 @@
import React from 'react'; import React from 'react';
import FeesSection from '@/components/Structure/Tarification/FeesSection'; import FeesSection from '@/components/Structure/Tarification/FeesSection';
import DiscountsSection from '@/components/Structure/Tarification/DiscountsSection'; import DiscountsSection from '@/components/Structure/Tarification/DiscountsSection';
import PaymentPlanSelector from '@/components/PaymentPlanSelector'; import PaymentPlanSelector from '@/components/PaymentPlanSelector';
@ -31,223 +31,141 @@ const FeesManagement = ({
handleEdit, handleEdit,
handleDelete, handleDelete,
}) => { }) => {
const handleDiscountDelete = (id, type) => { // Liste unique triée par type puis par nom
if (type === 0) { const allFees = [...(registrationFees ?? []), ...(tuitionFees ?? [])].sort(
setRegistrationFees((prevFees) => (a, b) => a.type - b.type || (a.name ?? '').localeCompare(b.name ?? '')
prevFees.map((fee) => ({ );
...fee,
discounts: fee.discounts.filter((discountId) => discountId !== id), const setAllFees = (updater) => {
})) const next = typeof updater === 'function' ? updater(allFees) : updater;
); setRegistrationFees(next.filter((f) => f.type === 0));
} else { setTuitionFees(next.filter((f) => f.type === 1));
setTuitionFees((prevFees) =>
prevFees.map((fee) => ({
...fee,
discounts: fee.discounts.filter((discountId) => discountId !== id),
}))
);
}
}; };
const allDiscounts = [
...(registrationDiscounts ?? []),
...(tuitionDiscounts ?? []),
].sort(
(a, b) => a.type - b.type || (a.name ?? '').localeCompare(b.name ?? '')
);
const setAllDiscounts = (updater) => {
const next =
typeof updater === 'function' ? updater(allDiscounts) : updater;
setRegistrationDiscounts(next.filter((d) => d.type === 0));
setTuitionDiscounts(next.filter((d) => d.type === 1));
};
const allPaymentPlans = [
...(registrationPaymentPlans ?? []),
...(tuitionPaymentPlans ?? []),
];
const allPaymentModes = [
...(registrationPaymentModes ?? []),
...(tuitionPaymentModes ?? []),
];
return ( return (
<div className="w-full"> <div className="w-full space-y-12">
<div className="w-4/5 mx-auto flex items-center mt-8"> {/* Tableau unique des frais */}
<hr className="flex-grow border-t-2 border-gray-300" /> <FeesSection
<span className="mx-4 text-gray-600 font-semibold"> fees={allFees}
Frais d&apos;inscription setFees={setAllFees}
</span> unified={true}
<hr className="flex-grow border-t-2 border-gray-300" /> handleCreate={(feeData) => {
</div> const setter =
feeData.type === 0 ? setRegistrationFees : setTuitionFees;
return handleCreate(BE_SCHOOL_FEES_URL, feeData, setter);
}}
handleEdit={(id, data) => {
const fee = allFees.find((f) => f.id === id);
const feeType = data.type ?? fee?.type;
const setter = feeType === 0 ? setRegistrationFees : setTuitionFees;
return handleEdit(BE_SCHOOL_FEES_URL, id, data, setter);
}}
handleDelete={(id) => {
const fee = allFees.find((f) => f.id === id);
const setter = fee?.type === 0 ? setRegistrationFees : setTuitionFees;
return handleDelete(BE_SCHOOL_FEES_URL, id, setter);
}}
/>
<div className="mt-8 w-4/5"> {/* Tableau unique des réductions */}
<FeesSection <DiscountsSection
fees={registrationFees} discounts={allDiscounts}
setFees={setRegistrationFees} setDiscounts={setAllDiscounts}
discounts={registrationDiscounts} unified={true}
handleCreate={(newData) => handleCreate={(data) => {
handleCreate(`${BE_SCHOOL_FEES_URL}`, newData, setRegistrationFees) const setter =
} data.type === 0 ? setRegistrationDiscounts : setTuitionDiscounts;
handleEdit={(id, updatedData) => return handleCreate(BE_SCHOOL_DISCOUNTS_URL, data, setter);
handleEdit( }}
`${BE_SCHOOL_FEES_URL}`, handleEdit={(id, data) => {
id, const discount = allDiscounts.find((d) => d.id === id);
updatedData, const discountType = data.type ?? discount?.type;
setRegistrationFees const setter =
) discountType === 0 ? setRegistrationDiscounts : setTuitionDiscounts;
} return handleEdit(BE_SCHOOL_DISCOUNTS_URL, id, data, setter);
handleDelete={(id) => }}
handleDelete(`${BE_SCHOOL_FEES_URL}`, id, setRegistrationFees) handleDelete={(id) => {
} const discount = allDiscounts.find((d) => d.id === id);
type={0} const setter =
/> discount?.type === 0
</div> ? setRegistrationDiscounts
<div className="mt-12 w-4/5"> : setTuitionDiscounts;
<DiscountsSection return handleDelete(BE_SCHOOL_DISCOUNTS_URL, id, setter);
discounts={registrationDiscounts} }}
setDiscounts={setRegistrationDiscounts} onDiscountDelete={(id) => {
handleCreate={(newData) => // Retire la réduction des frais concernés
handleCreate( setAllFees((prevFees) =>
`${BE_SCHOOL_DISCOUNTS_URL}`, prevFees.map((fee) => ({
newData, ...fee,
setRegistrationDiscounts discounts: fee.discounts.filter((dId) => dId !== id),
) }))
} );
handleEdit={(id, updatedData) => }}
handleEdit( />
`${BE_SCHOOL_DISCOUNTS_URL}`,
id, {/* Plans et modes de paiement communs */}
updatedData,
setRegistrationDiscounts
)
}
handleDelete={(id) =>
handleDelete(
`${BE_SCHOOL_DISCOUNTS_URL}`,
id,
setRegistrationDiscounts
)
}
onDiscountDelete={(id) => handleDiscountDelete(id, 0)}
type={0}
/>
</div>
<div className="mt-12 grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="mt-12 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="col-span-1 mt-4"> <div className="col-span-1 mt-4">
<PaymentPlanSelector <PaymentPlanSelector
paymentPlans={registrationPaymentPlans} allPaymentPlans={allPaymentPlans}
setPaymentPlans={setRegistrationPaymentPlans} handleCreate={(data) => {
handleCreate={(newData) => const setter =
handleCreate( data.type === 0
`${BE_SCHOOL_PAYMENT_PLANS_URL}`, ? setRegistrationPaymentPlans
newData, : setTuitionPaymentPlans;
setRegistrationPaymentPlans return handleCreate(BE_SCHOOL_PAYMENT_PLANS_URL, data, setter);
) }}
} handleDelete={(id) => {
handleDelete={(id) => const plan = allPaymentPlans.find((p) => p.id === id);
handleDelete( const setter =
`${BE_SCHOOL_PAYMENT_PLANS_URL}`, plan?.type === 0
id, ? setRegistrationPaymentPlans
setRegistrationPaymentPlans : setTuitionPaymentPlans;
) return handleDelete(BE_SCHOOL_PAYMENT_PLANS_URL, id, setter);
} }}
type={0}
/> />
</div> </div>
<div className="col-span-1 mt-4"> <div className="col-span-1 mt-4">
<PaymentModeSelector <PaymentModeSelector
paymentModes={registrationPaymentModes} allPaymentModes={allPaymentModes}
setPaymentModes={setRegistrationPaymentModes} handleCreate={(data) => {
handleCreate={(newData) => const setter =
handleCreate( data.type === 0
`${BE_SCHOOL_PAYMENT_MODES_URL}`, ? setRegistrationPaymentModes
newData, : setTuitionPaymentModes;
setRegistrationPaymentModes return handleCreate(BE_SCHOOL_PAYMENT_MODES_URL, data, setter);
) }}
} handleDelete={(id) => {
handleDelete={(id) => const mode = allPaymentModes.find((m) => m.id === id);
handleDelete( const setter =
`${BE_SCHOOL_PAYMENT_MODES_URL}`, mode?.type === 0
id, ? setRegistrationPaymentModes
setRegistrationPaymentModes : setTuitionPaymentModes;
) return handleDelete(BE_SCHOOL_PAYMENT_MODES_URL, id, setter);
} }}
type={0}
/>
</div>
</div>
<div className="w-4/5 mx-auto flex items-center mt-16">
<hr className="flex-grow border-t-2 border-gray-300" />
<span className="mx-4 text-gray-600 font-semibold">
Frais de scolarité
</span>
<hr className="flex-grow border-t-2 border-gray-300" />
</div>
<div className="mt-8 w-4/5">
<FeesSection
fees={tuitionFees}
setFees={setTuitionFees}
discounts={tuitionDiscounts}
handleCreate={(newData) =>
handleCreate(`${BE_SCHOOL_FEES_URL}`, newData, setTuitionFees)
}
handleEdit={(id, updatedData) =>
handleEdit(`${BE_SCHOOL_FEES_URL}`, id, updatedData, setTuitionFees)
}
handleDelete={(id) =>
handleDelete(`${BE_SCHOOL_FEES_URL}`, id, setTuitionFees)
}
type={1}
/>
</div>
<div className="mt-12 w-4/5">
<DiscountsSection
discounts={tuitionDiscounts}
setDiscounts={setTuitionDiscounts}
handleCreate={(newData) =>
handleCreate(
`${BE_SCHOOL_DISCOUNTS_URL}`,
newData,
setTuitionDiscounts
)
}
handleEdit={(id, updatedData) =>
handleEdit(
`${BE_SCHOOL_DISCOUNTS_URL}`,
id,
updatedData,
setTuitionDiscounts
)
}
handleDelete={(id) =>
handleDelete(`${BE_SCHOOL_DISCOUNTS_URL}`, id, setTuitionDiscounts)
}
onDiscountDelete={(id) => handleDiscountDelete(id, 1)}
type={1}
/>
</div>
<div className="mt-12 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="col-span-1 mt-4">
<PaymentPlanSelector
paymentPlans={tuitionPaymentPlans}
setPaymentPlans={setTuitionPaymentPlans}
handleCreate={(newData) =>
handleCreate(
`${BE_SCHOOL_PAYMENT_PLANS_URL}`,
newData,
setTuitionPaymentPlans
)
}
handleDelete={(id) =>
handleDelete(
`${BE_SCHOOL_PAYMENT_PLANS_URL}`,
id,
setTuitionPaymentPlans
)
}
type={1}
/>
</div>
<div className="col-span-1 mt-4">
<PaymentModeSelector
paymentModes={tuitionPaymentModes}
setPaymentModes={setTuitionPaymentModes}
handleCreate={(newData) =>
handleCreate(
`${BE_SCHOOL_PAYMENT_MODES_URL}`,
newData,
setTuitionPaymentModes
)
}
handleDelete={(id) =>
handleDelete(
`${BE_SCHOOL_PAYMENT_MODES_URL}`,
id,
setTuitionPaymentModes
)
}
type={1}
/> />
</div> </div>
</div> </div>

View File

@ -9,6 +9,13 @@ import SectionHeader from '@/components/SectionHeader';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import AlertMessage from '@/components/AlertMessage'; import AlertMessage from '@/components/AlertMessage';
const FEE_TYPE_LABELS = { 0: 'Inscription', 1: 'Scolarité' };
/**
* @param {boolean} [unified=false] - true : tableau mixte inscription+scolarité avec colonne TYPE.
* Dans ce cas, `fees` contient les frais des deux types et `handleCreate`/`handleEdit`/`handleDelete`
* sont des fonctions (url, data, setter) déjà partiellement appliquées par le parent.
*/
const FeesSection = ({ const FeesSection = ({
fees, fees,
setFees, setFees,
@ -16,6 +23,7 @@ const FeesSection = ({
handleEdit, handleEdit,
handleDelete, handleDelete,
type, type,
unified = false,
subscriptionMode = false, subscriptionMode = false,
selectedFees, selectedFees,
handleFeeSelection, handleFeeSelection,
@ -29,8 +37,9 @@ const FeesSection = ({
const [removePopupVisible, setRemovePopupVisible] = useState(false); const [removePopupVisible, setRemovePopupVisible] = useState(false);
const [removePopupMessage, setRemovePopupMessage] = useState(''); const [removePopupMessage, setRemovePopupMessage] = useState('');
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
const labelTypeFrais = // En mode unifié, le type effectif est celui du frais ou celui du formulaire de création
type === 0 ? "Frais d'inscription" : 'Frais de scolarité'; const labelTypeFrais = (feeType) =>
feeType === 0 ? "Frais d'inscription" : 'Frais de scolarité';
const { selectedEstablishmentId } = useEstablishment(); const { selectedEstablishmentId } = useEstablishment();
// Récupération des messages d'erreur // Récupération des messages d'erreur
@ -44,10 +53,8 @@ const FeesSection = ({
name: '', name: '',
base_amount: '', base_amount: '',
description: '', description: '',
validity_start_date: '',
validity_end_date: '',
discounts: [], discounts: [],
type: type, type: unified ? 0 : type,
establishment: selectedEstablishmentId, establishment: selectedEstablishmentId,
}); });
}; };
@ -91,8 +98,8 @@ const FeesSection = ({
const handleUpdateFee = (id, updatedFee) => { const handleUpdateFee = (id, updatedFee) => {
if (updatedFee.name && updatedFee.base_amount) { if (updatedFee.name && updatedFee.base_amount) {
handleEdit(id, updatedFee) handleEdit(id, updatedFee)
.then((updatedFee) => { .then((updated) => {
setFees(fees.map((fee) => (fee.id === id ? updatedFee : fee))); setFees(fees.map((fee) => (fee.id === id ? updated : fee)));
setEditingFee(null); setEditingFee(null);
setLocalErrors({}); setLocalErrors({});
}) })
@ -193,6 +200,21 @@ const FeesSection = ({
handleChange, handleChange,
'Description' 'Description'
); );
case 'TYPE':
return (
<select
className="border rounded px-2 py-1 text-sm"
value={currentData.type}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (isEditing) setFormData((p) => ({ ...p, type: val }));
else setNewFee((p) => ({ ...p, type: val }));
}}
>
<option value={0}>Inscription</option>
<option value={1}>Scolarité</option>
</select>
);
case 'ACTIONS': case 'ACTIONS':
return ( return (
<div className="flex justify-center space-x-2"> <div className="flex justify-center space-x-2">
@ -222,6 +244,7 @@ const FeesSection = ({
return null; return null;
} }
} else { } else {
const feeLabel = labelTypeFrais(fee.type);
switch (column) { switch (column) {
case 'NOM': case 'NOM':
return fee.name; return fee.name;
@ -231,6 +254,18 @@ const FeesSection = ({
return fee.updated_at_formatted; return fee.updated_at_formatted;
case 'DESCRIPTION': case 'DESCRIPTION':
return fee.description; return fee.description;
case 'TYPE':
return (
<span
className={`text-xs font-semibold px-2 py-1 rounded-full ${
fee.type === 0
? 'bg-blue-100 text-blue-700'
: 'bg-purple-100 text-purple-700'
}`}
>
{FEE_TYPE_LABELS[fee.type]}
</span>
);
case 'ACTIONS': case 'ACTIONS':
return ( return (
<div className="flex justify-center space-x-2"> <div className="flex justify-center space-x-2">
@ -257,22 +292,20 @@ const FeesSection = ({
onClick={() => { onClick={() => {
setRemovePopupVisible(true); setRemovePopupVisible(true);
setRemovePopupMessage( setRemovePopupMessage(
`Attentions ! \nVous êtes sur le point de supprimer un ${labelTypeFrais} .\nÊtes-vous sûr(e) de vouloir poursuivre l'opération ?` `Attentions ! \nVous êtes sur le point de supprimer un ${feeLabel}.\nÊtes-vous sûr(e) de vouloir poursuivre l'opération ?`
); );
setRemovePopupOnConfirm(() => () => { setRemovePopupOnConfirm(() => () => {
handleRemoveFee(fee.id) handleRemoveFee(fee.id)
.then((data) => { .then((data) => {
logger.debug('Success:', data); logger.debug('Success:', data);
setPopupMessage( setPopupMessage(feeLabel + ' correctement supprimé');
labelTypeFrais + ' correctement supprimé'
);
setPopupVisible(true); setPopupVisible(true);
setRemovePopupVisible(false); setRemovePopupVisible(false);
}) })
.catch((error) => { .catch((error) => {
logger.error('Error archiving data:', error); logger.error('Error archiving data:', error);
setPopupMessage( setPopupMessage(
'Erreur lors de la suppression du ' + labelTypeFrais 'Erreur lors de la suppression du ' + feeLabel
); );
setPopupVisible(true); setPopupVisible(true);
setRemovePopupVisible(false); setRemovePopupVisible(false);
@ -307,42 +340,33 @@ const FeesSection = ({
{ name: 'NOM', label: 'Nom' }, { name: 'NOM', label: 'Nom' },
{ name: 'DESCRIPTION', label: 'Description' }, { name: 'DESCRIPTION', label: 'Description' },
{ name: 'MONTANT', label: 'Montant de base' }, { name: 'MONTANT', label: 'Montant de base' },
...(unified ? [{ name: 'TYPE', label: 'Type' }] : []),
{ name: '', label: 'Sélection' }, { name: '', label: 'Sélection' },
] ]
: [ : [
{ name: 'NOM', label: 'Nom' }, { name: 'NOM', label: 'Nom' },
{ name: 'MONTANT', label: 'Montant de base' }, { name: 'MONTANT', label: 'Montant de base' },
{ name: 'DESCRIPTION', label: 'Description' }, { name: 'DESCRIPTION', label: 'Description' },
...(unified ? [{ name: 'TYPE', label: 'Type' }] : []),
{ name: 'MISE A JOUR', label: 'Date mise à jour' }, { name: 'MISE A JOUR', label: 'Date mise à jour' },
{ name: 'ACTIONS', label: 'Actions' }, { name: 'ACTIONS', label: 'Actions' },
]; ];
let emptyMessage; const emptyMessage = (
if (type === 0) { <AlertMessage
emptyMessage = ( type="warning"
<AlertMessage title="Aucun frais enregistré"
type="warning" message="Veuillez procéder à la création de nouveaux frais"
title="Aucun frais d'inscription enregistré" />
message="Veuillez procéder à la création de nouveaux frais d'inscription" );
/>
);
} else {
emptyMessage = (
<AlertMessage
type="warning"
title="Aucun frais de scolarité enregistré"
message="Veuillez procéder à la création de nouveaux frais de scolarité"
/>
);
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{!subscriptionMode && ( {!subscriptionMode && (
<SectionHeader <SectionHeader
icon={CreditCard} icon={CreditCard}
title={`${type == 0 ? "Liste des frais d'inscription" : 'Liste des frais de scolarité'}`} title="Liste des frais"
description={`Gérez${type == 0 ? " vos frais d'inscription" : ' vos frais de scolarité'}`} description="Gérez vos frais d'inscription et de scolarité"
button={!subscriptionMode} button={!subscriptionMode}
onClick={handleAddFee} onClick={handleAddFee}
/> />

View File

@ -0,0 +1,23 @@
import React from 'react';
/**
* Textarea composant réutilisable
* @param {string} value - Valeur du textarea
* @param {function} onChange - Fonction appelée lors d'un changement
* @param {string} placeholder - Texte d'exemple
* @param {number} rows - Nombre de lignes
* @param {string} className - Classes CSS additionnelles
* @param {object} props - Props additionnels
*/
const Textarea = ({ value, onChange, placeholder = '', rows = 3, className = '', ...props }) => (
<textarea
value={value}
onChange={onChange}
placeholder={placeholder}
rows={rows}
className={`border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500 resize-y ${className}`}
{...props}
/>
);
export default Textarea;

View File

@ -1,4 +1,6 @@
'use client';
import React, { createContext, useContext, useState, useEffect } from 'react'; import React, { createContext, useContext, useState, useEffect } from 'react';
import { flushSync } from 'react-dom';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
const EstablishmentContext = createContext(); const EstablishmentContext = createContext();
@ -46,10 +48,11 @@ export const EstablishmentProvider = ({ children }) => {
const storedUser = sessionStorage.getItem('user'); const storedUser = sessionStorage.getItem('user');
return storedUser ? JSON.parse(storedUser) : null; return storedUser ? JSON.parse(storedUser) : null;
}); });
const [selectedEstablishmentLogo, setSelectedEstablishmentLogoState] = useState(() => { const [selectedEstablishmentLogo, setSelectedEstablishmentLogoState] =
const storedLogo = sessionStorage.getItem('selectedEstablishmentLogo'); useState(() => {
return storedLogo ? JSON.parse(storedLogo) : null; const storedLogo = sessionStorage.getItem('selectedEstablishmentLogo');
}); return storedLogo ? JSON.parse(storedLogo) : null;
});
// Sauvegarder dans sessionStorage à chaque mise à jour // Sauvegarder dans sessionStorage à chaque mise à jour
const setSelectedEstablishmentId = (id) => { const setSelectedEstablishmentId = (id) => {
@ -106,8 +109,6 @@ export const EstablishmentProvider = ({ children }) => {
} }
const user = session.user; const user = session.user;
logger.debug('User Session:', user); logger.debug('User Session:', user);
setUser(user);
logger.debug('Establishments User= ', user);
const userEstablishments = user.roles.map((role, i) => ({ const userEstablishments = user.roles.map((role, i) => ({
id: role.establishment__id, id: role.establishment__id,
name: role.establishment__name, name: role.establishment__name,
@ -117,27 +118,37 @@ export const EstablishmentProvider = ({ children }) => {
role_id: i, role_id: i,
role_type: role.role_type, role_type: role.role_type,
})); }));
setEstablishments(userEstablishments); let roleIndexDefault = 0;
logger.debug('Establishments', user.roleIndexLoginDefault);
if (user.roles && user.roles.length > 0) { if (user.roles && user.roles.length > 0) {
let roleIndexDefault = 0;
if (userEstablishments.length > user.roleIndexLoginDefault) { if (userEstablishments.length > user.roleIndexLoginDefault) {
roleIndexDefault = user.roleIndexLoginDefault; roleIndexDefault = user.roleIndexLoginDefault;
} }
setSelectedRoleId(roleIndexDefault); }
if (userEstablishments.length > 0) { // flushSync force React à commiter tous les setState de manière synchrone
setSelectedEstablishmentId(userEstablishments[roleIndexDefault].id); // avant que endInitFunctionHandler (router.push) soit appelé.
setSelectedEstablishmentEvaluationFrequency( // Sans ça, ProtectedRoute verrait user=null au premier rendu post-navigation.
userEstablishments[roleIndexDefault].evaluation_frequency flushSync(() => {
); setUser(user);
setSelectedEstablishmentTotalCapacity( setEstablishments(userEstablishments);
userEstablishments[roleIndexDefault].total_capacity if (user.roles && user.roles.length > 0) {
); setSelectedRoleId(roleIndexDefault);
setSelectedEstablishmentLogo( if (userEstablishments.length > 0) {
userEstablishments[roleIndexDefault].logo setSelectedEstablishmentId(userEstablishments[roleIndexDefault].id);
); setSelectedEstablishmentEvaluationFrequency(
setProfileRole(userEstablishments[roleIndexDefault].role_type); userEstablishments[roleIndexDefault].evaluation_frequency
);
setSelectedEstablishmentTotalCapacity(
userEstablishments[roleIndexDefault].total_capacity
);
setSelectedEstablishmentLogo(
userEstablishments[roleIndexDefault].logo
);
setProfileRole(userEstablishments[roleIndexDefault].role_type);
}
} }
});
logger.debug('Establishments', user.roleIndexLoginDefault);
if (user.roles && user.roles.length > 0) {
if (endInitFunctionHandler) { if (endInitFunctionHandler) {
const role = session.user.roles[roleIndexDefault].role_type; const role = session.user.roles[roleIndexDefault].role_type;
endInitFunctionHandler(role); endInitFunctionHandler(role);

View File

@ -1,6 +1,5 @@
import NextAuth from 'next-auth'; import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials'; import CredentialsProvider from 'next-auth/providers/credentials';
import { getJWT, refreshJWT } from '@/app/actions/authAction';
import jwt_decode from 'jsonwebtoken'; import jwt_decode from 'jsonwebtoken';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
@ -13,19 +12,32 @@ const options = {
email: { label: 'Email', type: 'email' }, email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' }, password: { label: 'Password', type: 'password' },
}, },
authorize: async (credentials, req) => { authorize: async (credentials) => {
// URL calculée ici (pas au niveau module) pour garantir que NEXT_PUBLIC_API_URL est chargé
const loginUrl = `${process.env.NEXT_PUBLIC_API_URL}/Auth/login`;
try { try {
const data = { const res = await fetch(loginUrl, {
email: credentials.email, method: 'POST',
password: credentials.password, headers: {
}; 'Content-Type': 'application/json',
// Connection: close évite le SocketError undici lié au keep-alive vers Daphne
Connection: 'close',
},
body: JSON.stringify({
email: credentials.email,
password: credentials.password,
}),
});
const user = await getJWT(data); if (!res.ok) {
const body = await res.json().catch(() => ({}));
if (user) { throw new Error(body?.errorMessage || 'Identifiants invalides');
return user;
} }
const user = await res.json();
return user || null;
} catch (error) { } catch (error) {
logger.error('Authorize error:', error.message);
throw new Error(error.message || 'Invalid credentials'); throw new Error(error.message || 'Invalid credentials');
} }
}, },
@ -33,8 +45,10 @@ const options = {
], ],
session: { session: {
strategy: 'jwt', strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60, // 30 jours maxAge: 60 * 60, // 1 Hour
updateAge: 24 * 60 * 60, // 24 heures // 0 = réécrire le cookie à chaque fois que le token change (indispensable avec
// un access token Django de 15 min, sinon le cookie expiré reste en place)
updateAge: 0,
}, },
cookies: { cookies: {
sessionToken: { sessionToken: {
@ -64,25 +78,61 @@ const options = {
return token; return token;
} }
// Token expiré, essayer de le rafraîchir // Token Django expiré (lifetime = 15 min), essayer de le rafraîchir
logger.info('JWT: access token expiré, tentative de refresh');
if (!token.refresh) {
logger.error('JWT: refresh token absent dans la session');
return { ...token, error: 'RefreshTokenError' };
}
const refreshUrl = `${process.env.NEXT_PUBLIC_API_URL}/Auth/refreshJWT`;
if (!process.env.NEXT_PUBLIC_API_URL) {
logger.error('JWT: NEXT_PUBLIC_API_URL non défini, refresh impossible');
return { ...token, error: 'RefreshTokenError' };
}
try { try {
const response = await refreshJWT({ refresh: token.refresh }); const res = await fetch(refreshUrl, {
if (response && response?.token) { method: 'POST',
return { headers: {
...token, 'Content-Type': 'application/json',
token: response.token, // Connection: close évite le SocketError undici lié au keep-alive vers Daphne
refresh: response.refresh, Connection: 'close',
tokenExpires: jwt_decode.decode(response.token).exp * 1000, },
}; body: JSON.stringify({ refresh: token.refresh }),
} else { });
throw new Error('Failed to refresh token');
if (!res.ok) {
const body = await res.json().catch(() => ({}));
logger.error('JWT: refresh échoué', { status: res.status, body });
throw new Error(`Refresh HTTP ${res.status}`);
} }
const response = await res.json();
if (!response?.token) {
logger.error('JWT: réponse refresh sans token', { response });
throw new Error('Réponse refresh invalide');
}
logger.info('JWT: refresh réussi');
return {
...token,
token: response.token,
refresh: response.refresh,
tokenExpires: jwt_decode.decode(response.token).exp * 1000,
error: undefined,
};
} catch (error) { } catch (error) {
logger.error('Refresh token failed:', error); logger.error('JWT: refresh token failed', { message: error.message });
return token; return { ...token, error: 'RefreshTokenError' };
} }
}, },
async session({ session, token }) { async session({ session, token }) {
if (token?.error === 'RefreshTokenError') {
session.error = 'RefreshTokenError';
return session;
}
if (token && token?.token) { if (token && token?.token) {
const { user_id, email, roles, roleIndexLoginDefault } = const { user_id, email, roles, roleIndexLoginDefault } =
jwt_decode.decode(token.token); jwt_decode.decode(token.token);

View File

@ -0,0 +1,149 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import FeeTypeSection from '@/components/Structure/Tarification/FeeTypeSection';
// Mock du contexte établissement
jest.mock('@/context/EstablishmentContext', () => ({
useEstablishment: () => ({ selectedEstablishmentId: 1 }),
}));
// Mock des sous-composants pour isoler FeeTypeSection
jest.mock(
'@/components/Structure/Tarification/FeesSection',
() =>
function MockFeesSection({ type }) {
return (
<div data-testid={`fees-section-type-${type}`}>
FeesSection type={type}
</div>
);
}
);
jest.mock(
'@/components/Structure/Tarification/DiscountsSection',
() =>
function MockDiscountsSection({ type }) {
return (
<div data-testid={`discounts-section-type-${type}`}>
DiscountsSection type={type}
</div>
);
}
);
jest.mock(
'@/components/PaymentPlanSelector',
() =>
function MockPaymentPlanSelector({ type }) {
return (
<div data-testid={`payment-plan-type-${type}`}>
PaymentPlanSelector type={type}
</div>
);
}
);
jest.mock(
'@/components/PaymentModeSelector',
() =>
function MockPaymentModeSelector({ type }) {
return (
<div data-testid={`payment-mode-type-${type}`}>
PaymentModeSelector type={type}
</div>
);
}
);
jest.mock('@/utils/Url', () => ({
BE_SCHOOL_FEES_URL: '/api/fees',
BE_SCHOOL_DISCOUNTS_URL: '/api/discounts',
BE_SCHOOL_PAYMENT_PLANS_URL: '/api/payment-plans',
BE_SCHOOL_PAYMENT_MODES_URL: '/api/payment-modes',
}));
const defaultProps = {
title: "Frais d'inscription",
fees: [],
setFees: jest.fn(),
discounts: [],
setDiscounts: jest.fn(),
paymentPlans: [],
setPaymentPlans: jest.fn(),
paymentModes: [],
setPaymentModes: jest.fn(),
type: 0,
handleCreate: jest.fn(),
handleEdit: jest.fn(),
handleDelete: jest.fn(),
onDiscountDelete: jest.fn(),
};
describe('FeeTypeSection - type inscription (type=0)', () => {
it('affiche le titre passé en props', () => {
render(<FeeTypeSection {...defaultProps} />);
expect(screen.getByText("Frais d'inscription")).toBeInTheDocument();
});
it('rend le composant FeesSection avec le bon type', () => {
render(<FeeTypeSection {...defaultProps} />);
expect(screen.getByTestId('fees-section-type-0')).toBeInTheDocument();
});
it('rend le composant DiscountsSection avec le bon type', () => {
render(<FeeTypeSection {...defaultProps} />);
expect(screen.getByTestId('discounts-section-type-0')).toBeInTheDocument();
});
it('rend le composant PaymentPlanSelector avec le bon type', () => {
render(<FeeTypeSection {...defaultProps} />);
expect(screen.getByTestId('payment-plan-type-0')).toBeInTheDocument();
});
it('rend le composant PaymentModeSelector avec le bon type', () => {
render(<FeeTypeSection {...defaultProps} />);
expect(screen.getByTestId('payment-mode-type-0')).toBeInTheDocument();
});
});
describe('FeeTypeSection - type scolarité (type=1)', () => {
const tuitionProps = {
...defaultProps,
title: 'Frais de scolarité',
type: 1,
};
it('affiche le titre "Frais de scolarité"', () => {
render(<FeeTypeSection {...tuitionProps} />);
expect(screen.getByText('Frais de scolarité')).toBeInTheDocument();
});
it('rend tous les sous-composants avec type=1', () => {
render(<FeeTypeSection {...tuitionProps} />);
expect(screen.getByTestId('fees-section-type-1')).toBeInTheDocument();
expect(screen.getByTestId('discounts-section-type-1')).toBeInTheDocument();
expect(screen.getByTestId('payment-plan-type-1')).toBeInTheDocument();
expect(screen.getByTestId('payment-mode-type-1')).toBeInTheDocument();
});
});
describe('FeeTypeSection - transmission des handlers', () => {
it('passe les fonctions handleCreate, handleEdit, handleDelete aux sous-composants', () => {
const handleCreate = jest.fn();
const handleEdit = jest.fn();
const handleDelete = jest.fn();
// On vérifie que le composant se rend sans erreur avec les handlers
expect(() =>
render(
<FeeTypeSection
{...defaultProps}
handleCreate={handleCreate}
handleEdit={handleEdit}
handleDelete={handleDelete}
/>
)
).not.toThrow();
});
});

View File

@ -0,0 +1,145 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import FeesManagement from '@/components/Structure/Tarification/FeesManagement';
jest.mock('@/context/EstablishmentContext', () => ({
useEstablishment: () => ({ selectedEstablishmentId: 1 }),
}));
jest.mock('@/utils/Url', () => ({
BE_SCHOOL_FEES_URL: '/api/fees',
BE_SCHOOL_DISCOUNTS_URL: '/api/discounts',
BE_SCHOOL_PAYMENT_PLANS_URL: '/api/payment-plans',
BE_SCHOOL_PAYMENT_MODES_URL: '/api/payment-modes',
}));
jest.mock('@/utils/logger', () => ({ error: jest.fn() }));
jest.mock(
'@/components/Structure/Tarification/FeesSection',
() =>
function MockFeesSection({ fees, unified }) {
return (
<div
data-testid="fees-section"
data-unified={unified ? 'true' : 'false'}
>
{fees.map((f) => (
<span key={f.id}>{f.name}</span>
))}
</div>
);
}
);
jest.mock(
'@/components/Structure/Tarification/DiscountsSection',
() =>
function MockDiscountsSection({ discounts, unified }) {
return (
<div
data-testid="discounts-section"
data-unified={unified ? 'true' : 'false'}
>
{discounts.map((d) => (
<span key={d.id}>{d.name}</span>
))}
</div>
);
}
);
jest.mock(
'@/components/PaymentPlanSelector',
() =>
function MockPaymentPlanSelector({ allPaymentPlans }) {
return (
<div data-testid="payment-plan-selector">
{(allPaymentPlans ?? []).length} plans
</div>
);
}
);
jest.mock(
'@/components/PaymentModeSelector',
() =>
function MockPaymentModeSelector({ allPaymentModes }) {
return (
<div data-testid="payment-mode-selector">
{(allPaymentModes ?? []).length} modes
</div>
);
}
);
const defaultProps = {
registrationFees: [],
setRegistrationFees: jest.fn(),
tuitionFees: [],
setTuitionFees: jest.fn(),
registrationDiscounts: [],
setRegistrationDiscounts: jest.fn(),
tuitionDiscounts: [],
setTuitionDiscounts: jest.fn(),
registrationPaymentPlans: [],
setRegistrationPaymentPlans: jest.fn(),
tuitionPaymentPlans: [],
setTuitionPaymentPlans: jest.fn(),
registrationPaymentModes: [],
setRegistrationPaymentModes: jest.fn(),
tuitionPaymentModes: [],
setTuitionPaymentModes: jest.fn(),
handleCreate: jest.fn(),
handleEdit: jest.fn(),
handleDelete: jest.fn(),
};
describe('FeesManagement - vue unifiée', () => {
it('affiche la section des frais en mode unifié', () => {
render(<FeesManagement {...defaultProps} />);
const section = screen.getByTestId('fees-section');
expect(section).toBeInTheDocument();
expect(section).toHaveAttribute('data-unified', 'true');
});
it('affiche la section des réductions en mode unifié', () => {
render(<FeesManagement {...defaultProps} />);
const section = screen.getByTestId('discounts-section');
expect(section).toBeInTheDocument();
expect(section).toHaveAttribute('data-unified', 'true');
});
it('affiche le sélecteur de plans de paiement', () => {
render(<FeesManagement {...defaultProps} />);
expect(screen.getByTestId('payment-plan-selector')).toBeInTheDocument();
});
it('affiche le sélecteur de modes de paiement', () => {
render(<FeesManagement {...defaultProps} />);
expect(screen.getByTestId('payment-mode-selector')).toBeInTheDocument();
});
it('fusionne les frais inscription et scolarité en une seule liste', () => {
render(
<FeesManagement
{...defaultProps}
registrationFees={[{ id: 1, name: 'Inscription A', type: 0 }]}
tuitionFees={[{ id: 2, name: 'Scolarité B', type: 1 }]}
/>
);
expect(screen.getByText('Inscription A')).toBeInTheDocument();
expect(screen.getByText('Scolarité B')).toBeInTheDocument();
});
it('fusionne les plans de paiement inscription et scolarité', () => {
render(
<FeesManagement
{...defaultProps}
registrationPaymentPlans={[{ id: 10, plan_type: 1, type: 0 }]}
tuitionPaymentPlans={[{ id: 11, plan_type: 1, type: 1 }]}
/>
);
expect(screen.getByText('2 plans')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,211 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import FeesSection from '@/components/Structure/Tarification/FeesSection';
/* eslint-disable react/display-name */
// Mock du contexte établissement
jest.mock('@/context/EstablishmentContext', () => ({
useEstablishment: () => ({ selectedEstablishmentId: 1 }),
}));
// Mock des composants UI pour isoler les tests unitaires
jest.mock(
'@/components/Table',
() =>
({ data, columns, renderCell, emptyMessage }) => {
if (!data || data.length === 0)
return <div data-testid="empty-message">{emptyMessage}</div>;
return (
<table>
<tbody>
{data.map((row) => (
<tr key={row.id}>
{columns.map((col) => (
<td key={col.name}>{renderCell(row, col.name)}</td>
))}
</tr>
))}
</tbody>
</table>
);
}
);
jest.mock(
'@/components/Popup',
() =>
({ isOpen, message, onConfirm, onCancel }) =>
isOpen ? (
<div data-testid="popup">
<p>{message}</p>
<button onClick={onConfirm}>Confirmer</button>
<button onClick={onCancel}>Annuler</button>
</div>
) : null
);
jest.mock('@/components/SectionHeader', () => ({ title, button, onClick }) => (
<div>
<h2>{title}</h2>
{button && <button onClick={onClick}>Ajouter</button>}
</div>
));
jest.mock('@/components/AlertMessage', () => ({ title, message }) => (
<div data-testid="alert-message">
<strong>{title}</strong>
<p>{message}</p>
</div>
));
jest.mock(
'@/components/Form/InputText',
() =>
({ name, value, onChange, placeholder }) => (
<input
name={name}
value={value}
onChange={onChange}
placeholder={placeholder}
/>
)
);
jest.mock('@/components/Form/CheckBox', () => ({ item, handleChange }) => (
<input type="checkbox" onChange={handleChange} />
));
jest.mock('@/utils/logger', () => ({
error: jest.fn(),
debug: jest.fn(),
}));
const mockFee = {
id: 1,
name: 'Frais test',
base_amount: '200.00',
description: 'Description test',
updated_at_formatted: '01-01-2026 10:00',
is_active: true,
discounts: [],
type: 0,
};
describe('FeesSection - type inscription (type=0)', () => {
const defaultProps = {
fees: [mockFee],
setFees: jest.fn(),
handleCreate: jest.fn(),
handleEdit: jest.fn(),
handleDelete: jest.fn(),
type: 0,
};
it('affiche le titre "Liste des frais"', () => {
render(<FeesSection {...defaultProps} />);
expect(screen.getByText('Liste des frais')).toBeInTheDocument();
});
it('affiche les données du frais dans le tableau', () => {
render(<FeesSection {...defaultProps} />);
expect(screen.getByText('Frais test')).toBeInTheDocument();
expect(screen.getByText('200.00 €')).toBeInTheDocument();
});
it('affiche le bouton Ajouter en mode gestion', () => {
render(<FeesSection {...defaultProps} />);
expect(screen.getByText('Ajouter')).toBeInTheDocument();
});
it('affiche le message vide quand la liste est vide', () => {
render(<FeesSection {...defaultProps} fees={[]} />);
expect(screen.getByTestId('empty-message')).toBeInTheDocument();
expect(screen.getByText('Aucun frais enregistré')).toBeInTheDocument();
});
});
describe('FeesSection - type scolarité (type=1)', () => {
const defaultProps = {
fees: [{ ...mockFee, type: 1 }],
setFees: jest.fn(),
handleCreate: jest.fn(),
handleEdit: jest.fn(),
handleDelete: jest.fn(),
type: 1,
};
it('affiche le titre "Liste des frais" aussi pour type=1', () => {
render(<FeesSection {...defaultProps} />);
expect(screen.getByText('Liste des frais')).toBeInTheDocument();
});
it('affiche le message vide générique quand la liste est vide', () => {
render(<FeesSection {...defaultProps} fees={[]} />);
expect(screen.getByText('Aucun frais enregistré')).toBeInTheDocument();
});
});
describe('FeesSection - mode sélection (subscriptionMode)', () => {
const defaultProps = {
fees: [mockFee],
setFees: jest.fn(),
handleCreate: jest.fn(),
handleEdit: jest.fn(),
handleDelete: jest.fn(),
type: 0,
subscriptionMode: true,
selectedFees: [],
handleFeeSelection: jest.fn(),
};
it('cache le header section en mode subscription', () => {
render(<FeesSection {...defaultProps} />);
expect(screen.queryByText('Liste des frais')).not.toBeInTheDocument();
});
it("n'affiche pas le bouton Ajouter en mode subscription", () => {
render(<FeesSection {...defaultProps} />);
expect(screen.queryByText('Ajouter')).not.toBeInTheDocument();
});
});
describe("FeesSection - création d'un nouveau frais", () => {
it('initialise le nouveau frais avec le bon type', () => {
const setFees = jest.fn();
const handleCreate = jest.fn(() =>
Promise.resolve({ id: 2, name: 'Nouveau', base_amount: '100' })
);
render(
<FeesSection
fees={[]}
setFees={setFees}
handleCreate={handleCreate}
handleEdit={jest.fn()}
handleDelete={jest.fn()}
type={0}
/>
);
fireEvent.click(screen.getByText('Ajouter'));
// Le nouveau frais doit apparaître dans le tableau
expect(screen.queryByPlaceholderText('Nom des frais')).toBeInTheDocument();
});
it('initialise le nouveau frais avec type=1 pour les frais de scolarité', () => {
render(
<FeesSection
fees={[]}
setFees={jest.fn()}
handleCreate={jest.fn()}
handleEdit={jest.fn()}
handleDelete={jest.fn()}
type={1}
/>
);
fireEvent.click(screen.getByText('Ajouter'));
expect(screen.queryByPlaceholderText('Nom des frais')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,101 @@
import { getSession } from 'next-auth/react';
import {
requestResponseHandler,
errorHandler,
triggerSignOut,
} from '@/app/actions/actionsHandlers';
import logger from '@/utils/logger';
// Déduplique les appels concurrents à getSession() :
// si plusieurs fetchWithAuth() partent en même temps (chargement de page),
// ils partagent la même promesse au lieu de déclencher N refreshs JWT en parallèle.
let _pendingSessionPromise = null;
const getSessionOnce = () => {
if (!_pendingSessionPromise) {
_pendingSessionPromise = getSession().finally(() => {
_pendingSessionPromise = null;
});
}
return _pendingSessionPromise;
};
/**
* Récupère le token JWT Bearer depuis la session NextAuth.
* @returns {Promise<string|null>}
*/
export const getAuthToken = async () => {
const session = await getSessionOnce();
if (!session) {
logger.warn('getAuthToken: session nulle, aucun token envoyé');
return null;
}
if (session?.error === 'RefreshTokenError') {
logger.warn(
'getAuthToken: RefreshTokenError détecté, déconnexion en cours'
);
await triggerSignOut();
return null;
}
if (!session?.user?.token) {
logger.warn('getAuthToken: session présente mais token absent', {
session,
});
return null;
}
return session.user.token;
};
/**
* Wrapper de fetch qui injecte automatiquement le header Authorization Bearer
* depuis la session NextAuth, puis passe la réponse dans requestResponseHandler.
*
* - Ajoute Content-Type: application/json par défaut (sauf si le body est FormData)
* - Ajoute credentials: 'include' par défaut
* - Les options.headers passées en paramètre surchargent les défauts (ex: X-CSRFToken)
*
* @param {string} url
* @param {RequestInit} options
* @returns {Promise<any>} Corps de la réponse désérialisé
*/
export const fetchWithAuth = async (url, options = {}) => {
const token = await getAuthToken();
const isFormData = options.body instanceof FormData;
const headers = {
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...options.headers,
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
return fetch(url, {
credentials: 'include',
...options,
headers,
})
.then(requestResponseHandler)
.catch(errorHandler);
};
/**
* Variante de fetchWithAuth qui retourne la Response brute sans passer
* par requestResponseHandler. Utile quand l'appelant gère lui-même response.ok.
*
* @param {string} url
* @param {RequestInit} options
* @returns {Promise<Response>}
*/
export const fetchWithAuthRaw = async (url, options = {}) => {
const token = await getAuthToken();
const headers = {
...options.headers,
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
return fetch(url, {
credentials: 'include',
...options,
headers,
});
};

View File

@ -21,4 +21,5 @@ DB_PASSWORD="postgres"
DB_HOST="database" DB_HOST="database"
DB_PORT="5432" DB_PORT="5432"
URL_DJANGO="http://localhost:8080" URL_DJANGO="http://localhost:8080"
SECRET_KEY="<SIGNINGKEY>" SECRET_KEY="<SECRET_KEY>"
WEBHOOK_API_KEY="<WEBHOOK_API_KEY>"

84
premier-pas.md Normal file
View File

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

BIN
premier-pas.pdf Normal file

Binary file not shown.