mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-04 04:01:27 +00:00
Compare commits
7 Commits
7464b19de5
...
ci-jenkins
| Author | SHA1 | Date | |
|---|---|---|---|
| e9a30b7bde | |||
| ff1d113698 | |||
| 12a6ad1d61 | |||
| 856443d4ed | |||
| ace4dcbf07 | |||
| 61f63f9dc9 | |||
| d9e998d2ff |
1
.github/copilot-instructions.md
vendored
1
.github/copilot-instructions.md
vendored
@ -56,4 +56,3 @@ 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)
|
|
||||||
|
|||||||
53
.github/instructions/run-tests.instruction.md
vendored
53
.github/instructions/run-tests.instruction.md
vendored
@ -1,53 +0,0 @@
|
|||||||
---
|
|
||||||
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 |
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,4 +3,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
hardcoded-strings-report.md
|
hardcoded-strings-report.md
|
||||||
backend.env
|
backend.env
|
||||||
*.log
|
|
||||||
@ -2,12 +2,6 @@ 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):
|
||||||
@ -24,45 +18,3 @@ 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
|
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||||
|
|
||||||
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(blank=True, default=False)),
|
('is_active', models.BooleanField(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')),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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, blank=True)
|
is_active = models.BooleanField(default=False)
|
||||||
updated_date = models.DateTimeField(auto_now=True)
|
updated_date = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
@ -1,553 +0,0 @@
|
|||||||
"""
|
|
||||||
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)")
|
|
||||||
|
|
||||||
@ -17,12 +17,10 @@ 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, permission_classes
|
from rest_framework.decorators import action, api_view
|
||||||
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
|
||||||
@ -30,28 +28,13 @@ 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
|
from N3wtSchool import bdd, error, settings
|
||||||
|
|
||||||
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",
|
||||||
@ -60,15 +43,11 @@ class LoginRateThrottle(AnonRateThrottle):
|
|||||||
}))}
|
}))}
|
||||||
)
|
)
|
||||||
@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')],
|
||||||
@ -91,11 +70,6 @@ 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')
|
||||||
@ -114,8 +88,6 @@ 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)}
|
||||||
@ -146,8 +118,6 @@ 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}
|
||||||
@ -182,12 +152,8 @@ 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_protect, name='dispatch')
|
@method_decorator(csrf_exempt, 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(
|
||||||
@ -274,14 +240,12 @@ 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(),
|
||||||
}
|
}
|
||||||
@ -291,23 +255,16 @@ 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)}", exc_info=True)
|
logger.error(f"Erreur lors de la création du token: {str(e)}")
|
||||||
# On lève l'exception pour que l'appelant (LoginView / RefreshJWTView)
|
return None
|
||||||
# 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(
|
||||||
@ -333,6 +290,7 @@ 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")
|
||||||
@ -377,16 +335,14 @@ 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': 'Token invalide'}, status=400)
|
return JsonResponse({'errorMessage': f'Token invalide: {str(e)}'}, status=400)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Erreur inattendue: {str(e)}", exc_info=True)
|
logger.error(f"Erreur inattendue: {str(e)}")
|
||||||
return JsonResponse({'errorMessage': 'Erreur interne du serveur'}, status=500)
|
return JsonResponse({'errorMessage': f'Erreur inattendue: {str(e)}'}, status=400)
|
||||||
|
|
||||||
@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=[
|
||||||
@ -474,8 +430,6 @@ 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(
|
||||||
@ -525,8 +479,6 @@ 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(
|
||||||
@ -573,9 +525,7 @@ 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)}
|
||||||
@ -646,8 +596,6 @@ 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}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
@ -1,145 +1,3 @@
|
|||||||
"""
|
from django.test import TestCase
|
||||||
Tests unitaires pour le module Common.
|
|
||||||
Vérifie que les endpoints Domain et Category requièrent une authentification JWT.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
# 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="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)
|
|
||||||
|
|||||||
@ -4,7 +4,6 @@ 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
|
||||||
@ -17,8 +16,6 @@ from .serializers import (
|
|||||||
@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)
|
||||||
@ -35,8 +32,6 @@ 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)
|
||||||
@ -70,8 +65,6 @@ 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)
|
||||||
@ -88,8 +81,6 @@ 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)
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||||
|
|
||||||
import Establishment.models
|
import Establishment.models
|
||||||
import django.contrib.postgres.fields
|
import django.contrib.postgres.fields
|
||||||
|
|||||||
@ -1,92 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@ -4,7 +4,6 @@ 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
|
||||||
@ -16,29 +15,9 @@ import N3wtSchool.mailManager as mailer
|
|||||||
import os
|
import os
|
||||||
from N3wtSchool import settings
|
from N3wtSchool import settings
|
||||||
|
|
||||||
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
class IsWebhookApiKey(BasePermission):
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
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)
|
||||||
@ -65,7 +44,6 @@ 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):
|
||||||
@ -109,9 +87,7 @@ 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")
|
password = directeur_data.get("password", "Provisoire01!")
|
||||||
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(
|
||||||
|
|||||||
@ -1,116 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@ -2,8 +2,6 @@ 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
|
||||||
|
|
||||||
@ -22,11 +20,9 @@ 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 (keys): {list(request.data.keys()) if request.data else []}") # Ne pas logger les valeurs (RGPD)
|
logger.info(f"Request data received: {request.data}")
|
||||||
logger.info(f"Request content type: {request.content_type}")
|
logger.info(f"Request content type: {request.content_type}")
|
||||||
|
|
||||||
data = request.data
|
data = request.data
|
||||||
@ -38,9 +34,11 @@ 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 (count): {len(recipients)}")
|
logger.info(f"Recipients: {recipients} (type: {type(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.debug(f"Message length: {len(message) if message else 0}")
|
logger.info(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:
|
||||||
@ -72,12 +70,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)}", exc_info=True)
|
logger.error(f"Exception during email sending: {str(e)}")
|
||||||
return Response({'error': 'Erreur lors de l\'envoi de l\'email'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
logger.error(f"Exception type: {type(e)}")
|
||||||
|
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.
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import django.utils.timezone
|
import django.utils.timezone
|
||||||
|
|||||||
@ -1,130 +0,0 @@
|
|||||||
"""
|
|
||||||
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])
|
|
||||||
@ -1,274 +0,0 @@
|
|||||||
"""
|
|
||||||
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])
|
|
||||||
@ -2,7 +2,6 @@ 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
|
||||||
@ -26,8 +25,6 @@ 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
|
||||||
"""
|
"""
|
||||||
@ -37,8 +34,7 @@ class InstantConversationListView(APIView):
|
|||||||
)
|
)
|
||||||
def get(self, request, user_id=None):
|
def get(self, request, user_id=None):
|
||||||
try:
|
try:
|
||||||
# Utiliser l'utilisateur authentifié — ignorer user_id de l'URL (protection IDOR)
|
user = Profile.objects.get(id=user_id)
|
||||||
user = request.user
|
|
||||||
|
|
||||||
conversations = Conversation.objects.filter(
|
conversations = Conversation.objects.filter(
|
||||||
participants__participant=user,
|
participants__participant=user,
|
||||||
@ -54,8 +50,6 @@ 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
|
||||||
"""
|
"""
|
||||||
@ -73,8 +67,6 @@ 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
|
||||||
"""
|
"""
|
||||||
@ -87,19 +79,23 @@ 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')
|
||||||
|
|
||||||
# Utiliser l'utilisateur authentifié — ignorer user_id du paramètre (protection IDOR)
|
# Récupérer l'utilisateur actuel depuis les paramètres de requête
|
||||||
user = request.user
|
user_id = request.GET.get('user_id')
|
||||||
|
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:
|
except Exception as e:
|
||||||
return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
return Response({'error': str(e)}, 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é
|
||||||
"""
|
"""
|
||||||
@ -120,20 +116,21 @@ 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, content]):
|
if not all([conversation_id, sender_id, content]):
|
||||||
return Response(
|
return Response(
|
||||||
{'error': 'conversation_id and content are required'},
|
{'error': 'conversation_id, sender_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)
|
||||||
|
|
||||||
# L'expéditeur est toujours l'utilisateur authentifié (protection IDOR)
|
# Vérifier que l'expéditeur existe et peut envoyer dans cette conversation
|
||||||
sender = request.user
|
sender = Profile.objects.get(id=sender_id)
|
||||||
participant = ConversationParticipant.objects.filter(
|
participant = ConversationParticipant.objects.filter(
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
participant=sender,
|
participant=sender,
|
||||||
@ -175,12 +172,10 @@ 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:
|
except Exception as e:
|
||||||
return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
return Response({'error': str(e)}, 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
|
||||||
"""
|
"""
|
||||||
@ -195,16 +190,15 @@ class InstantMarkAsReadView(APIView):
|
|||||||
),
|
),
|
||||||
responses={200: openapi.Response('Success')}
|
responses={200: openapi.Response('Success')}
|
||||||
)
|
)
|
||||||
def post(self, request):
|
def post(self, request, conversation_id):
|
||||||
try:
|
try:
|
||||||
# Utiliser l'utilisateur authentifié — ignorer user_id du body (protection IDOR)
|
user_id = request.data.get('user_id')
|
||||||
# conversation_id est lu depuis le body (pas depuis l'URL)
|
if not user_id:
|
||||||
conversation_id = request.data.get('conversation_id')
|
return Response({'error': 'user_id is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
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=request.user,
|
participant_id=user_id,
|
||||||
is_active=True
|
is_active=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -215,12 +209,10 @@ 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:
|
except Exception as e:
|
||||||
return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
return Response({'error': str(e)}, 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
|
||||||
"""
|
"""
|
||||||
@ -253,8 +245,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:
|
except Exception as e:
|
||||||
return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
return Response({'error': str(e)}, 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",
|
||||||
@ -274,12 +266,10 @@ 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:
|
except Exception as e:
|
||||||
return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
return Response({'error': str(e)}, 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
|
||||||
"""
|
"""
|
||||||
@ -311,17 +301,18 @@ 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:
|
if not conversation_id or not sender_id:
|
||||||
return Response({'error': 'conversation_id requis'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': 'conversation_id et sender_id requis'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# Vérifier que la conversation existe et que l'utilisateur authentifié y participe (protection IDOR)
|
# Vérifier que la conversation existe et que l'utilisateur y participe
|
||||||
try:
|
try:
|
||||||
conversation = Conversation.objects.get(id=conversation_id)
|
conversation = Conversation.objects.get(id=conversation_id)
|
||||||
sender = request.user
|
sender = Profile.objects.get(id=sender_id)
|
||||||
|
|
||||||
# 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(
|
||||||
@ -377,12 +368,10 @@ class FileUploadView(APIView):
|
|||||||
'filePath': file_path
|
'filePath': file_path
|
||||||
}, status=status.HTTP_200_OK)
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
except Exception:
|
except Exception as e:
|
||||||
return Response({'error': "Erreur lors de l'upload"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
return Response({'error': f'Erreur lors de l\'upload: {str(e)}'}, 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
|
||||||
"""
|
"""
|
||||||
@ -430,8 +419,6 @@ 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
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@ -1,115 +0,0 @@
|
|||||||
"""
|
|
||||||
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)")
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
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 *
|
||||||
|
|
||||||
@ -9,11 +8,8 @@ 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):
|
||||||
# Filtrer les notifications de l'utilisateur authentifié uniquement (protection IDOR)
|
notifsList=bdd.getAllObjects(Notification)
|
||||||
notifsList = Notification.objects.filter(user=request.user)
|
notifs_serializer=NotificationSerializer(notifsList, many=True)
|
||||||
notifs_serializer = NotificationSerializer(notifsList, many=True)
|
|
||||||
|
|
||||||
return JsonResponse(notifs_serializer.data, safe=False)
|
return JsonResponse(notifs_serializer.data, safe=False)
|
||||||
@ -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: 'Un compte a été détecté et existe déjà pour cet établissement',
|
PROFIL_ACTIVE: 'Le profil est déjà actif',
|
||||||
}
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
from django.core.mail import get_connection, EmailMultiAlternatives, EmailMessage
|
from django.core.mail import send_mail, 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
|
||||||
@ -208,123 +208,3 @@ def isValid(message, fiche_inscription):
|
|||||||
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
|
|
||||||
@ -7,22 +7,5 @@ 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
|
||||||
|
|||||||
@ -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', 'False').lower() in ('true', '1')
|
DEBUG = os.getenv('DJANGO_DEBUG', True)
|
||||||
|
|
||||||
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
|
ALLOWED_HOSTS = ['*']
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
@ -62,7 +62,6 @@ INSTALLED_APPS = [
|
|||||||
'N3wtSchool',
|
'N3wtSchool',
|
||||||
'drf_yasg',
|
'drf_yasg',
|
||||||
'rest_framework_simplejwt',
|
'rest_framework_simplejwt',
|
||||||
'rest_framework_simplejwt.token_blacklist',
|
|
||||||
'channels',
|
'channels',
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -125,15 +124,9 @@ 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", "file"],
|
"handlers": ["console"],
|
||||||
"level": os.getenv("ROOT_LOG_LEVEL", "INFO"),
|
"level": os.getenv("ROOT_LOG_LEVEL", "INFO"),
|
||||||
},
|
},
|
||||||
"loggers": {
|
"loggers": {
|
||||||
@ -178,31 +171,9 @@ 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
|
||||||
|
|
||||||
@ -213,12 +184,12 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
'OPTIONS': {
|
'OPTIONS': {
|
||||||
'min_length': 10,
|
'min_length': 6,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
#{
|
||||||
'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',
|
||||||
},
|
},
|
||||||
@ -305,16 +276,6 @@ 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'
|
||||||
|
|
||||||
@ -351,22 +312,10 @@ 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': (
|
||||||
'Auth.backends.LoggingJWTAuthentication',
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
'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'
|
||||||
@ -384,28 +333,11 @@ REDIS_PORT = 6379
|
|||||||
REDIS_DB = 0
|
REDIS_DB = 0
|
||||||
REDIS_PASSWORD = None
|
REDIS_PASSWORD = None
|
||||||
|
|
||||||
_secret_key_default = '<SECRET_KEY>'
|
SECRET_KEY = os.getenv('SECRET_KEY', 'QWQ8bYlCz1NpQ9G0vR5kxMnvWszfH2y3')
|
||||||
_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': True,
|
'ROTATE_REFRESH_TOKENS': False,
|
||||||
'BLACKLIST_AFTER_ROTATION': True,
|
'BLACKLIST_AFTER_ROTATION': True,
|
||||||
'ALGORITHM': 'HS256',
|
'ALGORITHM': 'HS256',
|
||||||
'SIGNING_KEY': SECRET_KEY,
|
'SIGNING_KEY': SECRET_KEY,
|
||||||
@ -414,7 +346,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': 'type',
|
'TOKEN_TYPE_CLAIM': 'token_type',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Django Channels Configuration
|
# Django Channels Configuration
|
||||||
|
|||||||
@ -1,66 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
@ -1,125 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
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
|
||||||
|
|
||||||
@ -12,8 +11,6 @@ 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)
|
||||||
@ -42,8 +39,6 @@ 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:
|
||||||
@ -74,8 +69,6 @@ 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)
|
||||||
@ -135,8 +128,6 @@ 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)
|
||||||
@ -159,8 +150,6 @@ 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)
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||||
|
|
||||||
import django.contrib.postgres.fields
|
import django.contrib.postgres.fields
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
|||||||
@ -60,7 +60,6 @@ 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
|
||||||
@ -156,12 +155,6 @@ 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
|
||||||
|
|||||||
@ -1,353 +1,3 @@
|
|||||||
"""
|
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.test import TestCase, override_settings
|
# Create your tests here.
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|||||||
@ -4,7 +4,6 @@ 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,
|
||||||
@ -12,7 +11,6 @@ from .models import (
|
|||||||
Planning,
|
Planning,
|
||||||
Discount,
|
Discount,
|
||||||
Fee,
|
Fee,
|
||||||
FeeType,
|
|
||||||
PaymentPlan,
|
PaymentPlan,
|
||||||
PaymentMode,
|
PaymentMode,
|
||||||
EstablishmentCompetency,
|
EstablishmentCompetency,
|
||||||
@ -37,15 +35,12 @@ 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:
|
||||||
@ -70,8 +65,6 @@ 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)
|
||||||
@ -93,8 +86,6 @@ 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:
|
||||||
@ -111,17 +102,8 @@ 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_instance = teacher_serializer.save()
|
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)
|
||||||
@ -129,8 +111,6 @@ 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)
|
||||||
@ -138,49 +118,21 @@ 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):
|
||||||
# Suppression du Teacher et du ProfileRole associé
|
return delete_object(Teacher, id, related_field='profile_role')
|
||||||
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:
|
||||||
@ -205,8 +157,6 @@ 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)
|
||||||
@ -229,8 +179,6 @@ 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)
|
||||||
@ -249,8 +197,6 @@ 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)
|
||||||
@ -281,21 +227,13 @@ 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()
|
||||||
if filter not in ('registration', 'tuition'):
|
fee_type_value = 0 if filter == 'registration' else 1
|
||||||
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)
|
||||||
@ -313,8 +251,6 @@ 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)
|
||||||
@ -341,8 +277,6 @@ 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:
|
||||||
@ -367,8 +301,6 @@ 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)
|
||||||
@ -395,8 +327,6 @@ 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:
|
||||||
@ -421,8 +351,6 @@ 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)
|
||||||
@ -449,8 +377,6 @@ 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:
|
||||||
@ -475,8 +401,6 @@ 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)
|
||||||
@ -503,8 +427,6 @@ 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:
|
||||||
@ -528,8 +450,6 @@ 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)
|
||||||
@ -561,8 +481,6 @@ 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')
|
||||||
@ -756,8 +674,6 @@ 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)
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
@ -2,9 +2,6 @@ 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__'
|
||||||
@ -1,116 +0,0 @@
|
|||||||
"""
|
|
||||||
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])
|
|
||||||
@ -5,10 +5,8 @@ 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.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||||
|
|
||||||
import Subscriptions.models
|
import Subscriptions.models
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
@ -51,7 +51,6 @@ 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(
|
||||||
@ -94,9 +93,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_form_file_upload_to)),
|
('registration_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)),
|
('sepa_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)),
|
('fusion_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_file_path)),
|
||||||
('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')),
|
||||||
@ -167,8 +166,6 @@ 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')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -197,7 +194,6 @@ 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')),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -214,28 +214,9 @@ 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_form_file_upload_to(instance, filename):
|
def registration_file_path(instance, filename):
|
||||||
"""
|
# Génère le chemin : registration_files/dossier_rf_{student_id}/filename
|
||||||
Génère le chemin de stockage pour les fichiers du dossier d'inscription.
|
return f'registration_files/dossier_rf_{instance.student_id}/{filename}'
|
||||||
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):
|
||||||
@ -257,17 +238,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_form_file_upload_to,
|
upload_to=registration_file_path,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
sepa_file = models.FileField(
|
sepa_file = models.FileField(
|
||||||
upload_to=registration_form_file_upload_to,
|
upload_to=registration_file_path,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
fusion_file = models.FileField(
|
fusion_file = models.FileField(
|
||||||
upload_to=registration_form_file_upload_to,
|
upload_to=registration_file_path,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
@ -304,23 +285,13 @@ class RegistrationForm(models.Model):
|
|||||||
except RegistrationForm.DoesNotExist:
|
except RegistrationForm.DoesNotExist:
|
||||||
old_fileGroup = None
|
old_fileGroup = None
|
||||||
|
|
||||||
# Supprimer les anciens fichiers si remplacés (évite les suffixes Django)
|
# Vérifier si un fichier existant doit être remplacé
|
||||||
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:
|
||||||
_delete_file_if_exists(old_instance.sepa_file)
|
# Supprimer l'ancien fichier
|
||||||
|
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
|
||||||
|
|
||||||
@ -514,18 +485,10 @@ 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):
|
||||||
@ -535,36 +498,19 @@ 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 d’inscription donné.
|
Récupère tous les fichiers liés à un dossier d’inscription 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:
|
||||||
if reg_file.file and hasattr(reg_file.file, 'path'):
|
|
||||||
if os.path.exists(reg_file.file.path):
|
|
||||||
filenames.append(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):
|
||||||
@ -594,24 +540,22 @@ 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.master.name if self.master else f"ParentFile_{self.pk}"
|
return self.name
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
# Supprimer l'ancien fichier si remplacé (évite les suffixes Django)
|
if self.pk: # Si l'objet existe déjà dans la base de données
|
||||||
if self.pk:
|
|
||||||
try:
|
try:
|
||||||
old_instance = RegistrationParentFileTemplate.objects.get(pk=self.pk)
|
old_instance = RegistrationParentFileTemplate.objects.get(pk=self.pk)
|
||||||
# Si le fichier change ou est supprimé
|
if old_instance.file and (not self.file or self.file.name == ''):
|
||||||
if old_instance.file:
|
if os.path.exists(old_instance.file.path):
|
||||||
if old_instance.file != self.file or not self.file or self.file.name == '':
|
old_instance.file.delete(save=False)
|
||||||
_delete_file_if_exists(old_instance.file)
|
|
||||||
if not self.file or self.file.name == '':
|
|
||||||
self.file = None
|
self.file = None
|
||||||
|
else:
|
||||||
|
print(f"Le fichier {old_instance.file.path} n'existe pas.")
|
||||||
except RegistrationParentFileTemplate.DoesNotExist:
|
except RegistrationParentFileTemplate.DoesNotExist:
|
||||||
pass
|
print("Ancienne instance introuvable.")
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -21,7 +21,6 @@ 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()
|
||||||
@ -216,14 +215,6 @@ 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)
|
||||||
|
|||||||
@ -1,63 +0,0 @@
|
|||||||
<!-- 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>
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -19,8 +19,7 @@ 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, PdfReader
|
from PyPDF2 import PdfMerger
|
||||||
from PyPDF2.errors import PdfReadError
|
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
import logging
|
import logging
|
||||||
@ -32,29 +31,6 @@ 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.
|
||||||
@ -368,70 +344,12 @@ 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:
|
||||||
# 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)
|
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()
|
||||||
@ -460,11 +378,25 @@ 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.")
|
||||||
|
|
||||||
# Enregistrer directement le fichier dans le champ registration_file (écrase l'existant)
|
# Vérifier si un fichier avec le même nom existe déjà et le supprimer
|
||||||
|
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:
|
||||||
save_file_replacing_existing(
|
registerForm.registration_file.save(
|
||||||
registerForm.registration_file,
|
os.path.basename(filename), # Utiliser uniquement le nom de fichier
|
||||||
os.path.basename(filename),
|
|
||||||
File(BytesIO(pdf.content)),
|
File(BytesIO(pdf.content)),
|
||||||
save=True
|
save=True
|
||||||
)
|
)
|
||||||
@ -555,11 +487,6 @@ 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", "")
|
||||||
value = field.get("value", "")
|
|
||||||
# Afficher la valeur si elle existe
|
|
||||||
if value not in (None, ""):
|
|
||||||
c.drawString(100, y, f"{label} [{ftype}] : {value}")
|
|
||||||
else:
|
|
||||||
c.drawString(100, y, f"{label} [{ftype}]")
|
c.drawString(100, y, f"{label} [{ftype}]")
|
||||||
y -= 25
|
y -= 25
|
||||||
if y < 100:
|
if y < 100:
|
||||||
|
|||||||
@ -9,7 +9,6 @@ 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
|
||||||
@ -324,27 +323,6 @@ 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:
|
||||||
@ -354,32 +332,9 @@ 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:
|
||||||
@ -422,23 +377,14 @@ 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 = None
|
|
||||||
try:
|
|
||||||
merged_pdf_content = util.merge_files_pdf(fileNames)
|
merged_pdf_content = util.merge_files_pdf(fileNames)
|
||||||
|
|
||||||
# Mise à jour du champ fusion_file avec le fichier fusionné
|
# Mise à jour du champ registration_file avec le fichier fusionné
|
||||||
util.save_file_replacing_existing(
|
registerForm.fusion_file.save(
|
||||||
registerForm.fusion_file,
|
f"dossier_complet.pdf",
|
||||||
"dossier_complet.pdf",
|
|
||||||
File(merged_pdf_content),
|
File(merged_pdf_content),
|
||||||
save=True
|
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
|
||||||
@ -480,65 +426,8 @@ 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)
|
||||||
|
|
||||||
|
|||||||
@ -6,10 +6,11 @@ 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
|
from Subscriptions.serializers import RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer
|
||||||
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
|
||||||
@ -175,3 +176,97 @@ class RegistrationParentFileMasterSimpleView(APIView):
|
|||||||
return JsonResponse({'message': 'La suppression du fichier parent a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
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)
|
||||||
|
|||||||
@ -1,21 +1,128 @@
|
|||||||
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 RegistrationSchoolFileTemplateSerializer
|
from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer
|
||||||
from Subscriptions.models import RegistrationSchoolFileTemplate
|
from Subscriptions.models import RegistrationSchoolFileMaster, RegistrationSchoolFileTemplate, RegistrationParentFileMaster, RegistrationParentFileTemplate
|
||||||
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é",
|
||||||
@ -58,8 +165,6 @@ 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={
|
||||||
@ -84,83 +189,12 @@ 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)
|
||||||
|
|
||||||
@ -189,3 +223,195 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
|
|||||||
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
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)
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
__version__ = "0.0.3"
|
__version__ = "0.0.4"
|
||||||
|
|||||||
Binary file not shown.
@ -1,2 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
docker exec -it n3wt-school-backend-1 python3 manage.py test --settings=N3wtSchool.test_settings Auth.tests
|
|
||||||
@ -66,7 +66,7 @@ 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)
|
||||||
|
|||||||
4
Front-End/.babelrc
Normal file
4
Front-End/.babelrc
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"presets": ["next/babel"],
|
||||||
|
"plugins": []
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "n3wt-school-front-end",
|
"name": "n3wt-school-front-end",
|
||||||
"version": "0.0.3",
|
"version": "0.0.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
Award,
|
Award,
|
||||||
Calendar,
|
Calendar,
|
||||||
Settings,
|
Settings,
|
||||||
|
LogOut,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@ -28,14 +29,16 @@ 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 { RIGHTS } from '@/utils/rights';
|
import { getRightStr, 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, clearContext } =
|
const { profileRole, establishments, user, clearContext } =
|
||||||
useEstablishment();
|
useEstablishment();
|
||||||
|
|
||||||
const sidebarItems = {
|
const sidebarItems = {
|
||||||
@ -94,15 +97,45 @@ 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);
|
||||||
};
|
};
|
||||||
@ -112,15 +145,6 @@ 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 */}
|
||||||
@ -132,7 +156,7 @@ export default function Layout({ children }) {
|
|||||||
<Sidebar
|
<Sidebar
|
||||||
establishments={establishments}
|
establishments={establishments}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
items={sidebarItemsToDisplay}
|
items={Object.values(sidebarItems)}
|
||||||
onCloseMobile={toggleSidebar}
|
onCloseMobile={toggleSidebar}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -31,7 +31,6 @@ 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() {
|
||||||
@ -53,7 +52,7 @@ export default function Page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const csrfToken = useCsrfToken();
|
const csrfToken = useCsrfToken();
|
||||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
const { selectedEstablishmentId } = useEstablishment();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedEstablishmentId) {
|
if (selectedEstablishmentId) {
|
||||||
@ -260,10 +259,20 @@ export default function Page() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdatePlanning = (planningId, updatedData) => {
|
const handleUpdatePlanning = (url, planningId, updatedData) => {
|
||||||
updatePlanning(planningId, updatedData, csrfToken)
|
fetch(`${url}/${planningId}`, {
|
||||||
|
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);
|
||||||
@ -307,8 +316,6 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...(profileRole !== 0
|
|
||||||
? [
|
|
||||||
{
|
{
|
||||||
id: 'Fees',
|
id: 'Fees',
|
||||||
label: 'Tarifs',
|
label: 'Tarifs',
|
||||||
@ -338,8 +345,6 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
{
|
||||||
id: 'Files',
|
id: 'Files',
|
||||||
label: 'Documents',
|
label: 'Documents',
|
||||||
@ -348,7 +353,6 @@ export default function Page() {
|
|||||||
<FilesGroupsManagement
|
<FilesGroupsManagement
|
||||||
csrfToken={csrfToken}
|
csrfToken={csrfToken}
|
||||||
selectedEstablishmentId={selectedEstablishmentId}
|
selectedEstablishmentId={selectedEstablishmentId}
|
||||||
profileRole={profileRole}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
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';
|
||||||
@ -18,7 +17,6 @@ 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';
|
||||||
@ -85,9 +83,12 @@ 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([]);
|
||||||
|
|
||||||
@ -98,40 +99,9 @@ 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, profileRole } = useEstablishment();
|
const { selectedEstablishmentId } = useEstablishment();
|
||||||
const { showNotification } = useNotification();
|
const { showNotification } = useNotification();
|
||||||
|
|
||||||
const openSepaUploadModal = (row) => {
|
const openSepaUploadModal = (row) => {
|
||||||
@ -520,18 +490,10 @@ 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}&email=${row.student.guardians[0].associated_profile_email}&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}&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]: [
|
||||||
@ -839,7 +801,6 @@ export default function Page({ params: { locale } }) {
|
|||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{profileRole !== 0 && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`;
|
const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`;
|
||||||
@ -849,7 +810,6 @@ export default function Page({ params: { locale } }) {
|
|||||||
>
|
>
|
||||||
<Plus className="w-5 h-5" />
|
<Plus className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@ -893,25 +853,6 @@ 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}
|
||||||
|
|||||||
@ -10,10 +10,8 @@ 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);
|
||||||
@ -22,7 +20,6 @@ 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'
|
||||||
@ -87,45 +84,6 @@ 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 />;
|
||||||
}
|
}
|
||||||
@ -135,15 +93,10 @@ 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}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,4 @@
|
|||||||
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
|
||||||
@ -17,18 +6,6 @@ export const triggerSignOut = async () => {
|
|||||||
*/
|
*/
|
||||||
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;
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
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,
|
||||||
@ -74,49 +73,92 @@ 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 fetchWithAuth(url);
|
return fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateProfileRoles = (id, data, csrfToken) => {
|
export const updateProfileRoles = (id, data, csrfToken) => {
|
||||||
return fetchWithAuth(`${BE_AUTH_PROFILES_ROLES_URL}/${id}`, {
|
const request = new Request(`${BE_AUTH_PROFILES_ROLES_URL}/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'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 = (id, csrfToken) => {
|
export const deleteProfileRoles = async (id, csrfToken) => {
|
||||||
return fetchWithAuth(`${BE_AUTH_PROFILES_ROLES_URL}/${id}`, {
|
const response = await fetch(`${BE_AUTH_PROFILES_ROLES_URL}/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'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 fetchWithAuth(`${BE_AUTH_PROFILES_URL}`);
|
return fetch(`${BE_AUTH_PROFILES_URL}`)
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createProfile = (data, csrfToken) => {
|
export const createProfile = (data, csrfToken) => {
|
||||||
return fetchWithAuth(`${BE_AUTH_PROFILES_URL}`, {
|
const request = new Request(`${BE_AUTH_PROFILES_URL}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'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) => {
|
||||||
return fetchWithAuth(`${BE_AUTH_PROFILES_URL}/${id}`, {
|
const request = new Request(`${BE_AUTH_PROFILES_URL}/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
|
return fetch(request).then(requestResponseHandler).catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateProfile = (id, data, csrfToken) => {
|
export const updateProfile = (id, data, csrfToken) => {
|
||||||
return fetchWithAuth(`${BE_AUTH_PROFILES_URL}/${id}`, {
|
const request = new Request(`${BE_AUTH_PROFILES_URL}/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'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) => {
|
||||||
|
|||||||
@ -2,20 +2,33 @@ 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 { fetchWithAuth } from '@/utils/fetchWithAuth';
|
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||||
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 fetchWithAuth(url);
|
return fetch(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 fetchWithAuth(BE_GESTIONEMAIL_SEND_EMAIL_URL, {
|
return fetch(BE_GESTIONEMAIL_SEND_EMAIL_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify(messageData),
|
body: JSON.stringify(messageData),
|
||||||
});
|
})
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,9 +7,23 @@ 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 { fetchWithAuth, getAuthToken } from '@/utils/fetchWithAuth';
|
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||||
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
|
||||||
*/
|
*/
|
||||||
@ -17,12 +31,15 @@ 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}/`;
|
||||||
return await fetchWithAuth(url, {
|
const response = await fetch(url, {
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
method: 'GET',
|
||||||
|
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);
|
||||||
throw error;
|
return errorHandler(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -45,12 +62,15 @@ export const fetchMessages = async (
|
|||||||
url += `&user_id=${userId}`;
|
url += `&user_id=${userId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await fetchWithAuth(url, {
|
const response = await fetch(url, {
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
method: 'GET',
|
||||||
|
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);
|
||||||
throw error;
|
return errorHandler(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -59,14 +79,16 @@ export const fetchMessages = async (
|
|||||||
*/
|
*/
|
||||||
export const sendMessage = async (messageData, csrfToken) => {
|
export const sendMessage = async (messageData, csrfToken) => {
|
||||||
try {
|
try {
|
||||||
return await fetchWithAuth(BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL, {
|
const response = await fetch(BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: buildHeaders(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);
|
||||||
throw error;
|
return errorHandler(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -81,14 +103,17 @@ 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
|
||||||
};
|
};
|
||||||
|
|
||||||
return await fetchWithAuth(BE_GESTIONMESSAGERIE_CREATE_CONVERSATION_URL, {
|
const response = await fetch(BE_GESTIONMESSAGERIE_CREATE_CONVERSATION_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: buildHeaders(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);
|
||||||
throw error;
|
return errorHandler(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -107,12 +132,16 @@ export const searchMessagerieRecipients = async (
|
|||||||
|
|
||||||
const url = `${baseUrl}?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`;
|
const url = `${baseUrl}?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`;
|
||||||
|
|
||||||
return await fetchWithAuth(url, {
|
const response = await fetch(url, {
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
method: 'GET',
|
||||||
|
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);
|
||||||
throw error;
|
return errorHandler(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -121,17 +150,19 @@ export const searchMessagerieRecipients = async (
|
|||||||
*/
|
*/
|
||||||
export const markAsRead = async (conversationId, userId, csrfToken) => {
|
export const markAsRead = async (conversationId, userId, csrfToken) => {
|
||||||
try {
|
try {
|
||||||
return await fetchWithAuth(BE_GESTIONMESSAGERIE_MARK_AS_READ_URL, {
|
const response = await fetch(BE_GESTIONMESSAGERIE_MARK_AS_READ_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: buildHeaders(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);
|
||||||
throw error;
|
return errorHandler(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -150,7 +181,6 @@ 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();
|
||||||
|
|
||||||
@ -193,10 +223,7 @@ export const uploadFile = async (
|
|||||||
xhr.withCredentials = true;
|
xhr.withCredentials = true;
|
||||||
xhr.timeout = 30000;
|
xhr.timeout = 30000;
|
||||||
|
|
||||||
// Ajouter les headers d'authentification pour XMLHttpRequest
|
// Ajouter le header CSRF pour XMLHttpRequest
|
||||||
if (token) {
|
|
||||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
|
||||||
}
|
|
||||||
if (csrfToken) {
|
if (csrfToken) {
|
||||||
xhr.setRequestHeader('X-CSRFToken', csrfToken);
|
xhr.setRequestHeader('X-CSRFToken', csrfToken);
|
||||||
}
|
}
|
||||||
@ -211,12 +238,14 @@ 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}/`;
|
||||||
return await fetchWithAuth(url, {
|
const response = await fetch(url, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: buildHeaders(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);
|
||||||
throw error;
|
return errorHandler(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,31 +1,49 @@
|
|||||||
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 { fetchWithAuth } from '@/utils/fetchWithAuth';
|
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||||
|
|
||||||
const getData = (url) => {
|
const getData = (url) => {
|
||||||
return fetchWithAuth(url);
|
return fetch(`${url}`).then(requestResponseHandler).catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createDatas = (url, newData, csrfToken) => {
|
const createDatas = (url, newData, csrfToken) => {
|
||||||
return fetchWithAuth(url, {
|
return fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'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 fetchWithAuth(url, {
|
return fetch(`${url}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'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 fetchWithAuth(url, {
|
return fetch(`${url}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
});
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchPlannings = (
|
export const fetchPlannings = (
|
||||||
|
|||||||
@ -5,113 +5,213 @@ 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 { fetchWithAuth, fetchWithAuthRaw } from '@/utils/fetchWithAuth';
|
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||||
|
|
||||||
// FETCH requests
|
// FETCH requests
|
||||||
|
|
||||||
export async function fetchRegistrationFileGroups(establishment) {
|
export async function fetchRegistrationFileGroups(establishment) {
|
||||||
return fetchWithAuth(
|
const response = await fetch(
|
||||||
`${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 = (groupId) => {
|
export const fetchRegistrationFileFromGroup = async (groupId) => {
|
||||||
return fetchWithAuth(
|
const response = await fetch(
|
||||||
`${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) => {
|
||||||
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}?establishment_id=${establishment}`;
|
let url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}?establishment_id=${establishment}`;
|
||||||
return fetchWithAuth(url);
|
const request = new Request(`${url}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return fetch(request).then(requestResponseHandler).catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchRegistrationParentFileMasters = (establishment) => {
|
export const fetchRegistrationParentFileMasters = (establishment) => {
|
||||||
const url = `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}?establishment_id=${establishment}`;
|
let url = `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}?establishment_id=${establishment}`;
|
||||||
return fetchWithAuth(url);
|
const request = new Request(`${url}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return fetch(request).then(requestResponseHandler).catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchRegistrationSchoolFileTemplates = (establishment) => {
|
export const fetchRegistrationSchoolFileTemplates = (establishment) => {
|
||||||
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}?establishment_id=${establishment}`;
|
let url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}?establishment_id=${establishment}`;
|
||||||
return fetchWithAuth(url);
|
const request = new Request(`${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) {
|
||||||
return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}`, {
|
const response = await fetch(
|
||||||
|
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}`,
|
||||||
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
body: JSON.stringify(groupData),
|
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 fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}`, {
|
return fetch(`${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 fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}`, {
|
return fetch(`${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 fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}`, {
|
return fetch(`${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 fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL}`, {
|
return fetch(`${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 = (groupId, groupData, csrfToken) => {
|
export const editRegistrationFileGroup = async (
|
||||||
return fetchWithAuth(
|
groupId,
|
||||||
|
groupData,
|
||||||
|
csrfToken
|
||||||
|
) => {
|
||||||
|
const response = await fetch(
|
||||||
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}/${groupId}`,
|
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}/${groupId}`,
|
||||||
{
|
{
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'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 fetchWithAuth(
|
return fetch(
|
||||||
`${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 fetchWithAuth(
|
return fetch(
|
||||||
`${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 = (
|
||||||
@ -119,14 +219,19 @@ export const editRegistrationSchoolFileTemplates = (
|
|||||||
data,
|
data,
|
||||||
csrfToken
|
csrfToken
|
||||||
) => {
|
) => {
|
||||||
return fetchWithAuth(
|
return fetch(
|
||||||
`${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 = (
|
||||||
@ -134,64 +239,86 @@ export const editRegistrationParentFileTemplates = (
|
|||||||
data,
|
data,
|
||||||
csrfToken
|
csrfToken
|
||||||
) => {
|
) => {
|
||||||
return fetchWithAuth(
|
return fetch(
|
||||||
`${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) {
|
||||||
return fetchWithAuthRaw(
|
const response = await fetch(
|
||||||
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}/${groupId}`,
|
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}/${groupId}`,
|
||||||
{
|
{
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deleteRegistrationSchoolFileMaster = (fileId, csrfToken) => {
|
export const deleteRegistrationSchoolFileMaster = (fileId, csrfToken) => {
|
||||||
return fetchWithAuthRaw(
|
return fetch(
|
||||||
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${fileId}`,
|
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${fileId}`,
|
||||||
{
|
{
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteRegistrationParentFileMaster = (id, csrfToken) => {
|
export const deleteRegistrationParentFileMaster = (id, csrfToken) => {
|
||||||
return fetchWithAuthRaw(
|
return fetch(
|
||||||
`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}/${id}`,
|
`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}/${id}`,
|
||||||
{
|
{
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteRegistrationSchoolFileTemplates = (fileId, csrfToken) => {
|
export const deleteRegistrationSchoolFileTemplates = (fileId, csrfToken) => {
|
||||||
return fetchWithAuthRaw(
|
return fetch(
|
||||||
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${fileId}`,
|
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${fileId}`,
|
||||||
{
|
{
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteRegistrationParentFileTemplate = (id, csrfToken) => {
|
export const deleteRegistrationParentFileTemplate = (id, csrfToken) => {
|
||||||
return fetchWithAuthRaw(
|
return fetch(
|
||||||
`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL}/${id}`,
|
`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL}/${id}`,
|
||||||
{
|
{
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,125 +10,185 @@ 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 { fetchWithAuth } from '@/utils/fetchWithAuth';
|
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||||
|
|
||||||
export const deleteEstablishmentCompetencies = (ids, csrfToken) => {
|
export const deleteEstablishmentCompetencies = (ids, csrfToken) => {
|
||||||
return fetchWithAuth(BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, {
|
return fetch(BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'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 fetchWithAuth(BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, {
|
return fetch(BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'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 fetchWithAuth(
|
return fetch(
|
||||||
`${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) => {
|
export const fetchSpecialities = (establishment) => {
|
||||||
return fetchWithAuth(
|
return fetch(
|
||||||
`${BE_SCHOOL_SPECIALITIES_URL}?establishment_id=${establishment}`
|
`${BE_SCHOOL_SPECIALITIES_URL}?establishment_id=${establishment}`
|
||||||
);
|
)
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchTeachers = (establishment) => {
|
export const fetchTeachers = (establishment) => {
|
||||||
return fetchWithAuth(`${BE_SCHOOL_TEACHERS_URL}?establishment_id=${establishment}`);
|
return fetch(`${BE_SCHOOL_TEACHERS_URL}?establishment_id=${establishment}`)
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchClasses = (establishment) => {
|
export const fetchClasses = (establishment) => {
|
||||||
return fetchWithAuth(
|
return fetch(
|
||||||
`${BE_SCHOOL_SCHOOLCLASSES_URL}?establishment_id=${establishment}`
|
`${BE_SCHOOL_SCHOOLCLASSES_URL}?establishment_id=${establishment}`
|
||||||
);
|
)
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchClasse = (id) => {
|
export const fetchClasse = (id) => {
|
||||||
return fetchWithAuth(`${BE_SCHOOL_SCHOOLCLASSES_URL}/${id}`);
|
return fetch(`${BE_SCHOOL_SCHOOLCLASSES_URL}/${id}`).then(
|
||||||
|
requestResponseHandler
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchSchedules = () => {
|
export const fetchSchedules = () => {
|
||||||
return fetchWithAuth(`${BE_SCHOOL_PLANNINGS_URL}`);
|
return fetch(`${BE_SCHOOL_PLANNINGS_URL}`)
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchRegistrationDiscounts = (establishment) => {
|
export const fetchRegistrationDiscounts = (establishment) => {
|
||||||
return fetchWithAuth(
|
return fetch(
|
||||||
`${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 fetchWithAuth(
|
return fetch(
|
||||||
`${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 fetchWithAuth(
|
return fetch(
|
||||||
`${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 fetchWithAuth(
|
return fetch(
|
||||||
`${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 fetchWithAuth(
|
return fetch(
|
||||||
`${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 fetchWithAuth(
|
return fetch(
|
||||||
`${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 fetchWithAuth(
|
return fetch(
|
||||||
`${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 fetchWithAuth(
|
return fetch(
|
||||||
`${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 fetchWithAuth(`${BE_SCHOOL_ESTABLISHMENT_URL}/${establishment}`);
|
return fetch(`${BE_SCHOOL_ESTABLISHMENT_URL}/${establishment}`)
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createDatas = (url, newData, csrfToken) => {
|
export const createDatas = (url, newData, csrfToken) => {
|
||||||
return fetchWithAuth(url, {
|
return fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'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 fetchWithAuth(`${url}/${id}`, {
|
return fetch(`${url}/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'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 fetchWithAuth(`${url}/${id}`, {
|
return fetch(`${url}/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
});
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { BE_SETTINGS_SMTP_URL } from '@/utils/Url';
|
import { BE_SETTINGS_SMTP_URL } from '@/utils/Url';
|
||||||
import { fetchWithAuth } from '@/utils/fetchWithAuth';
|
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||||
|
|
||||||
export const PENDING = 'pending';
|
export const PENDING = 'pending';
|
||||||
export const SUBSCRIBED = 'subscribed';
|
export const SUBSCRIBED = 'subscribed';
|
||||||
@ -10,15 +10,26 @@ export const fetchSmtpSettings = (csrfToken, establishment_id = null) => {
|
|||||||
if (establishment_id) {
|
if (establishment_id) {
|
||||||
url += `?establishment_id=${establishment_id}`;
|
url += `?establishment_id=${establishment_id}`;
|
||||||
}
|
}
|
||||||
return fetchWithAuth(url, {
|
return fetch(`${url}`, {
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
});
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const editSmtpSettings = (data, csrfToken) => {
|
export const editSmtpSettings = (data, csrfToken) => {
|
||||||
return fetchWithAuth(`${BE_SETTINGS_SMTP_URL}/`, {
|
return fetch(`${BE_SETTINGS_SMTP_URL}/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -11,15 +11,20 @@ import {
|
|||||||
} from '@/utils/Url';
|
} from '@/utils/Url';
|
||||||
|
|
||||||
import { CURRENT_YEAR_FILTER } from '@/utils/constants';
|
import { CURRENT_YEAR_FILTER } from '@/utils/constants';
|
||||||
import { fetchWithAuth, fetchWithAuthRaw } from '@/utils/fetchWithAuth';
|
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
|
|
||||||
export const editStudentCompetencies = (data, csrfToken) => {
|
export const editStudentCompetencies = (data, csrfToken) => {
|
||||||
return fetchWithAuth(`${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}`, {
|
const request = new Request(`${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) => {
|
||||||
@ -28,7 +33,13 @@ 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}`;
|
||||||
|
|
||||||
return fetchWithAuth(url);
|
const request = new Request(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return fetch(request).then(requestResponseHandler).catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchRegisterForms = (
|
export const fetchRegisterForms = (
|
||||||
@ -42,22 +53,37 @@ 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 fetchWithAuth(url);
|
return fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchRegisterForm = (id) => {
|
export const fetchRegisterForm = (id) => {
|
||||||
return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`); // Utilisation de studentId au lieu de codeDI
|
return fetch(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`) // Utilisation de studentId au lieu de codeDI
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
};
|
};
|
||||||
export const fetchLastGuardian = () => {
|
export const fetchLastGuardian = () => {
|
||||||
return fetchWithAuth(`${BE_SUBSCRIPTION_LAST_GUARDIAN_ID_URL}`);
|
return fetch(`${BE_SUBSCRIPTION_LAST_GUARDIAN_ID_URL}`)
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const editRegisterForm = (id, data, csrfToken) => {
|
export const editRegisterForm = (id, data, csrfToken) => {
|
||||||
return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`, {
|
return fetch(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'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) => {
|
||||||
@ -80,12 +106,15 @@ export const autoSaveRegisterForm = async (id, data, csrfToken) => {
|
|||||||
}
|
}
|
||||||
autoSaveData.append('auto_save', 'true');
|
autoSaveData.append('auto_save', 'true');
|
||||||
|
|
||||||
return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`, {
|
return fetch(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`, {
|
||||||
method: 'PATCH', // Utiliser PATCH pour les mises à jour partielles
|
method: 'PATCH', // Utiliser PATCH pour les mises à jour partielles
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
body: autoSaveData,
|
body: autoSaveData,
|
||||||
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
.then(() => {})
|
.then(requestResponseHandler)
|
||||||
.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');
|
||||||
@ -98,30 +127,62 @@ 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 fetchWithAuth(url, {
|
return fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'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 fetchWithAuth(url);
|
return fetch(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 fetchWithAuth(url);
|
return fetch(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 fetchWithAuth(url);
|
return fetch(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 fetchWithAuth(url);
|
return fetch(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) => {
|
||||||
@ -134,68 +195,153 @@ export const fetchStudents = (establishment, id = null, status = null) => {
|
|||||||
url += `&status=${status}`;
|
url += `&status=${status}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fetchWithAuth(url);
|
const request = new Request(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) => {
|
||||||
return fetchWithAuth(
|
const request = new Request(
|
||||||
`${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) {
|
||||||
return fetchWithAuth(
|
const response = await fetch(
|
||||||
`${BE_SUBSCRIPTION_REGISTERFORM_FILE_TEMPLATE_URL}/${fileId}`
|
`${BE_SUBSCRIPTION_REGISTERFORM_FILE_TEMPLATE_URL}/${fileId}`,
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fetchSchoolFileTemplatesFromRegistrationFiles = (id) => {
|
|
||||||
return fetchWithAuth(
|
|
||||||
`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/school_file_templates`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchParentFileTemplatesFromRegistrationFiles = (id) => {
|
|
||||||
return fetchWithAuth(
|
|
||||||
`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/parent_file_templates`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dissociateGuardian = (studentId, guardianId) => {
|
|
||||||
return fetchWithAuth(
|
|
||||||
`${BE_SUBSCRIPTION_STUDENTS_URL}/${studentId}/guardians/${guardianId}/dissociate`,
|
|
||||||
{
|
{
|
||||||
method: 'PUT',
|
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) => {
|
||||||
|
const response = await fetch(
|
||||||
|
`${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) => {
|
||||||
|
const response = await fetch(
|
||||||
|
`${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) => {
|
||||||
|
const response = await fetch(
|
||||||
|
`${BE_SUBSCRIPTION_STUDENTS_URL}/${studentId}/guardians/${guardianId}/dissociate`,
|
||||||
|
{
|
||||||
|
credentials: 'include',
|
||||||
|
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 fetchWithAuth(
|
return fetch(
|
||||||
`${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 fetchWithAuth(`${BE_SUBSCRIPTION_ABSENCES_URL}`, {
|
return fetch(`${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 fetchWithAuth(`${BE_SUBSCRIPTION_ABSENCES_URL}/${absenceId}`, {
|
return fetch(`${BE_SUBSCRIPTION_ABSENCES_URL}/${absenceId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
body: JSON.stringify(payload),
|
'Content-Type': 'application/json',
|
||||||
|
'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 fetchWithAuthRaw(`${BE_SUBSCRIPTION_ABSENCES_URL}/${id}`, {
|
return fetch(`${BE_SUBSCRIPTION_ABSENCES_URL}/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -206,7 +352,16 @@ 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -218,14 +373,22 @@ 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: { 'X-CSRFToken': csrfToken },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
.then(requestResponseHandler)
|
||||||
|
.catch(errorHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -235,5 +398,14 @@ 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);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,16 +10,10 @@ 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) {
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
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';
|
||||||
@ -29,7 +28,6 @@ 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)
|
||||||
@ -39,14 +37,31 @@ export default function FormRenderer({
|
|||||||
control,
|
control,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
reset,
|
reset,
|
||||||
} = useForm({ defaultValues: initialValues });
|
} = useForm();
|
||||||
|
|
||||||
// Réinitialiser le formulaire quand les valeurs initiales changent
|
// Fonction utilitaire pour envoyer les données au backend
|
||||||
useEffect(() => {
|
const sendFormDataToBackend = async (formData) => {
|
||||||
if (initialValues && Object.keys(initialValues).length > 0) {
|
try {
|
||||||
reset(initialValues);
|
// Cette fonction peut être remplacée par votre propre implémentation
|
||||||
|
// 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}`);
|
||||||
}
|
}
|
||||||
}, [initialValues, reset]);
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit = async (data) => {
|
const onSubmit = async (data) => {
|
||||||
logger.debug('=== DÉBUT onSubmit ===');
|
logger.debug('=== DÉBUT onSubmit ===');
|
||||||
|
|||||||
@ -1,88 +1,64 @@
|
|||||||
'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 FileUpload from '@/components/Form/FileUpload';
|
import { CheckCircle, Hourglass, FileText } from 'lucide-react';
|
||||||
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} schoolFileTemplates - Liste des templates de formulaires
|
* @param {Array} schoolFileMasters - Liste des formulaires maîtres
|
||||||
* @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({
|
||||||
schoolFileTemplates,
|
schoolFileMasters,
|
||||||
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(() => {
|
||||||
// Initialisation complète de formsValidation et formsData pour chaque template
|
if (existingResponses && Object.keys(existingResponses).length > 0) {
|
||||||
if (schoolFileTemplates && schoolFileTemplates.length > 0) {
|
setFormsData(existingResponses);
|
||||||
// Fusionner avec l'état existant pour préserver les données locales
|
|
||||||
setFormsData((prevData) => {
|
|
||||||
const dataState = { ...prevData };
|
|
||||||
schoolFileTemplates.forEach((tpl) => {
|
|
||||||
// Ne mettre à jour que si on n'a pas encore de données locales ou si les données du serveur ont changé
|
|
||||||
const hasLocalData = prevData[tpl.id] && Object.keys(prevData[tpl.id]).length > 0;
|
|
||||||
const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
|
|
||||||
|
|
||||||
if (!hasLocalData && hasServerData) {
|
// Marquer les formulaires avec réponses comme valides
|
||||||
// Pas de données locales mais données serveur : utiliser les données serveur
|
const validationState = {};
|
||||||
dataState[tpl.id] = existingResponses[tpl.id];
|
Object.keys(existingResponses).forEach((formId) => {
|
||||||
} else if (!hasLocalData && !hasServerData) {
|
if (
|
||||||
// Pas de données du tout : initialiser à vide
|
existingResponses[formId] &&
|
||||||
dataState[tpl.id] = {};
|
Object.keys(existingResponses[formId]).length > 0
|
||||||
|
) {
|
||||||
|
validationState[formId] = true;
|
||||||
}
|
}
|
||||||
// Si hasLocalData : on garde les données locales existantes
|
|
||||||
});
|
|
||||||
return dataState;
|
|
||||||
});
|
});
|
||||||
|
setFormsValidation(validationState);
|
||||||
|
}
|
||||||
|
}, [existingResponses]);
|
||||||
|
|
||||||
// Fusionner avec l'état de validation existant
|
// Debug: Log des formulaires maîtres reçus
|
||||||
setFormsValidation((prevValidation) => {
|
useEffect(() => {
|
||||||
const validationState = { ...prevValidation };
|
logger.debug(
|
||||||
schoolFileTemplates.forEach((tpl) => {
|
'DynamicFormsList - Formulaires maîtres reçus:',
|
||||||
const hasLocalValidation = prevValidation[tpl.id] === true;
|
schoolFileMasters
|
||||||
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(() => {
|
||||||
// Un document est considéré comme "validé" s'il est validé par l'école OU complété localement OU déjà existant
|
const allFormsValid = schoolFileMasters.every(
|
||||||
const allFormsValid = schoolFileTemplates.every(
|
(master, index) => formsValidation[master.id] === true
|
||||||
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
|
||||||
@ -109,7 +85,7 @@ export default function DynamicFormsList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Passer au formulaire suivant si disponible
|
// Passer au formulaire suivant si disponible
|
||||||
if (currentTemplateIndex < schoolFileTemplates.length - 1) {
|
if (currentTemplateIndex < schoolFileMasters.length - 1) {
|
||||||
setCurrentTemplateIndex(currentTemplateIndex + 1);
|
setCurrentTemplateIndex(currentTemplateIndex + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,6 +95,16 @@ 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é
|
||||||
*/
|
*/
|
||||||
@ -149,44 +135,10 @@ export default function DynamicFormsList({
|
|||||||
* Obtient le formulaire actuel à afficher
|
* Obtient le formulaire actuel à afficher
|
||||||
*/
|
*/
|
||||||
const getCurrentTemplate = () => {
|
const getCurrentTemplate = () => {
|
||||||
return schoolFileTemplates[currentTemplateIndex];
|
return schoolFileMasters[currentTemplateIndex];
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentTemplate = getCurrentTemplate();
|
if (!schoolFileMasters || schoolFileMasters.length === 0) {
|
||||||
|
|
||||||
// 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" />
|
||||||
@ -195,6 +147,8 @@ 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 */}
|
||||||
@ -203,253 +157,128 @@ 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 */}
|
|
||||||
{
|
{
|
||||||
schoolFileTemplates.filter(tpl => {
|
Object.keys(formsValidation).filter((id) => formsValidation[id])
|
||||||
// Validé ou complété localement
|
.length
|
||||||
return tpl.isValidated === true ||
|
}{' '}
|
||||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
/ {schoolFileMasters.length} complétés
|
||||||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0);
|
|
||||||
}).length
|
|
||||||
}
|
|
||||||
{' / '}
|
|
||||||
{schoolFileTemplates.length} complétés
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tri des templates par état */}
|
|
||||||
{(() => {
|
|
||||||
// Helper pour état
|
|
||||||
const getState = tpl => {
|
|
||||||
if (tpl.isValidated === true) return 0; // validé
|
|
||||||
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; // complété/en attente
|
|
||||||
return 2; // à compléter/refusé
|
|
||||||
};
|
|
||||||
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => {
|
|
||||||
return getState(a) - getState(b);
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{sortedTemplates.map((tpl, index) => {
|
{schoolFileMasters.map((master, index) => {
|
||||||
const isActive = schoolFileTemplates[currentTemplateIndex]?.id === tpl.id;
|
const isActive = index === currentTemplateIndex;
|
||||||
const isValidated = typeof tpl.isValidated === 'boolean' ? tpl.isValidated : undefined;
|
const isCompleted = isFormCompleted(master.id);
|
||||||
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 (
|
return (
|
||||||
<li
|
<li
|
||||||
key={tpl.id}
|
key={master.id}
|
||||||
className={`flex items-center cursor-pointer p-2 rounded-md transition-colors ${
|
className={`flex items-center cursor-pointer p-2 rounded-md transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? `${bgClass.replace('50', '200')} ${borderClass.replace('200', '300')} ${textClass.replace('700', '900')} font-semibold`
|
? 'bg-blue-100 text-blue-700 font-semibold'
|
||||||
: `${bgClass} ${borderClass} ${textClass}`
|
: isCompleted
|
||||||
|
? 'text-green-600 hover:bg-green-50'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setCurrentTemplateIndex(schoolFileTemplates.findIndex(t => t.id === tpl.id))}
|
onClick={() => setCurrentTemplateIndex(index)}
|
||||||
>
|
>
|
||||||
<span className="mr-3">{icon}</span>
|
<span className="mr-3">
|
||||||
<div className="flex-1 min-w-0">
|
{getFormStatusIcon(master.id, isActive)}
|
||||||
<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>
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm truncate">
|
||||||
|
{master.formMasterData?.title ||
|
||||||
|
master.title ||
|
||||||
|
master.name ||
|
||||||
|
'Formulaire sans nom'}
|
||||||
</div>
|
</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">
|
<div className="text-xs text-gray-500">
|
||||||
{tpl.formMasterData?.fields || tpl.fields
|
{master.formMasterData?.fields || master.fields
|
||||||
? `${(tpl.formMasterData?.fields || tpl.fields).length} champ(s)`
|
? `${(master.formMasterData?.fields || master.fields).length} champ(s)`
|
||||||
: 'À compléter'}
|
: 'À compléter'}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</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">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<h3 className="text-xl font-semibold text-gray-800 mb-2">
|
||||||
<h3 className="text-xl font-semibold text-gray-800">
|
{currentTemplate.formMasterData?.title ||
|
||||||
{currentTemplate.name}
|
currentTemplate.title ||
|
||||||
|
currentTemplate.name ||
|
||||||
|
'Formulaire sans nom'}
|
||||||
</h3>
|
</h3>
|
||||||
{/* Label d'état */}
|
|
||||||
{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.formTemplateData?.description ||
|
{currentTemplate.formMasterData?.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 {(() => {
|
Formulaire {currentTemplateIndex + 1} sur{' '}
|
||||||
// Trouver l'index du template courant dans la liste triée
|
{schoolFileMasters.length}
|
||||||
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>
|
||||||
|
|
||||||
{/* Affichage dynamique ou existant */}
|
{/* Vérifier si le formulaire maître a des données de configuration */}
|
||||||
{isDynamicForm(currentTemplate) ? (
|
{(currentTemplate.formMasterData?.fields &&
|
||||||
|
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.formTemplateData?.title ||
|
currentTemplate.formMasterData?.title ||
|
||||||
currentTemplate.title ||
|
currentTemplate.title ||
|
||||||
currentTemplate.name ||
|
currentTemplate.name ||
|
||||||
'Formulaire',
|
'Formulaire',
|
||||||
fields:
|
fields:
|
||||||
currentTemplate.formTemplateData?.fields ||
|
currentTemplate.formMasterData?.fields ||
|
||||||
currentTemplate.fields ||
|
currentTemplate.fields ||
|
||||||
[],
|
[],
|
||||||
submitLabel:
|
submitLabel:
|
||||||
currentTemplate.formTemplateData?.submitLabel || 'Valider',
|
currentTemplate.formMasterData?.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}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// Formulaire existant (PDF, image, etc.)
|
<div className="text-center py-8">
|
||||||
<div className="flex flex-col items-center gap-6">
|
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||||
{/* Cas validé : affichage en iframe */}
|
<p className="text-gray-600">
|
||||||
{currentTemplate.isValidated === true && currentTemplate.file && (
|
Ce formulaire n'est pas encore configuré.
|
||||||
<iframe
|
</p>
|
||||||
src={`${BASE_URL}${currentTemplate.file}`}
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
title={currentTemplate.name}
|
Contactez l'administration pour plus d'informations.
|
||||||
className="w-full"
|
</p>
|
||||||
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 >= schoolFileTemplates.length && (
|
{currentTemplateIndex >= schoolFileMasters.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">
|
||||||
|
|||||||
@ -5,12 +5,15 @@ 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,
|
||||||
@ -19,7 +22,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 { FE_PARENTS_HOME_URL } from '@/utils/Url';
|
import { BASE_URL, 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';
|
||||||
@ -29,6 +32,7 @@ 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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -43,6 +47,7 @@ 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,
|
||||||
@ -278,8 +283,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 = schoolFileTemplates.find(
|
const currentTemplate = schoolFileMasters.find(
|
||||||
(template) => template.id === templateId
|
(master) => master.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é`);
|
||||||
@ -289,16 +294,17 @@ export default function InscriptionFormShared({
|
|||||||
const formTemplateData = {
|
const formTemplateData = {
|
||||||
id: currentTemplate.id,
|
id: currentTemplate.id,
|
||||||
title:
|
title:
|
||||||
currentTemplate.formTemplateData?.title ||
|
currentTemplate.formMasterData?.title ||
|
||||||
currentTemplate.title ||
|
currentTemplate.title ||
|
||||||
currentTemplate.name ||
|
currentTemplate.name ||
|
||||||
'Formulaire',
|
'Formulaire',
|
||||||
fields: (
|
fields: (
|
||||||
currentTemplate.formTemplateData?.fields ||
|
currentTemplate.formMasterData?.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 }
|
||||||
: {}),
|
: {}),
|
||||||
@ -309,8 +315,8 @@ export default function InscriptionFormShared({
|
|||||||
? { value: formData[field.id] || '' }
|
? { value: formData[field.id] || '' }
|
||||||
: {}),
|
: {}),
|
||||||
})),
|
})),
|
||||||
submitLabel: currentTemplate.formTemplateData?.submitLabel || 'Valider',
|
submitLabel: currentTemplate.formMasterData?.submitLabel || 'Valider',
|
||||||
responses: formData,
|
responses: formData, // Garder aussi les réponses brutes pour facilité d'accès
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sauvegarder les réponses du formulaire via l'API RegistrationSchoolFileTemplate
|
// Sauvegarder les réponses du formulaire via l'API RegistrationSchoolFileTemplate
|
||||||
@ -325,37 +331,18 @@ export default function InscriptionFormShared({
|
|||||||
);
|
);
|
||||||
logger.debug("Réponse de l'API:", result);
|
logger.debug("Réponse de l'API:", result);
|
||||||
|
|
||||||
// Prendre en compte la réponse du back pour mettre à jour les réponses locales
|
// Mettre à jour l'état local des réponses
|
||||||
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]: newResponses,
|
[templateId]: formData,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setSchoolFileTemplates((prevTemplates) => {
|
// Mettre à jour l'état local pour indiquer que le formulaire est complété
|
||||||
return prevTemplates.map((template) =>
|
setSchoolFileMasters((prevMasters) => {
|
||||||
template.id === templateId
|
return prevMasters.map((master) =>
|
||||||
? { ...template, completed: true, responses: newResponses }
|
master.id === templateId
|
||||||
: template
|
? { ...master, completed: true, responses: formData }
|
||||||
|
: master
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -367,6 +354,7 @@ 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);
|
||||||
}
|
}
|
||||||
@ -382,19 +370,47 @@ 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
|
fetchParentFileTemplatesFromRegistrationFiles(studentId).then((data) => {
|
||||||
const fetchAllResponses = async () => {
|
setParentFileTemplates(data);
|
||||||
|
|
||||||
|
// Initialiser uploadedFiles avec uniquement les fichiers dont `file` n'est pas null
|
||||||
|
const filteredFiles = data
|
||||||
|
.filter((item) => item.file !== null)
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
fileName: item.file,
|
||||||
|
}));
|
||||||
|
setUploadedFiles(filteredFiles);
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchProfiles()
|
||||||
|
.then((data) => {
|
||||||
|
setProfiles(data);
|
||||||
|
})
|
||||||
|
.catch((error) => logger.error('Error fetching profiles : ', error));
|
||||||
|
|
||||||
|
if (selectedEstablishmentId) {
|
||||||
|
// Fetch data for school file masters
|
||||||
|
fetchRegistrationSchoolFileMasters(selectedEstablishmentId)
|
||||||
|
.then(async (data) => {
|
||||||
|
logger.debug('School file masters fetched:', data);
|
||||||
|
setSchoolFileMasters(data);
|
||||||
|
|
||||||
|
// Récupérer les données existantes de chaque template
|
||||||
const responsesMap = {};
|
const responsesMap = {};
|
||||||
for (const template of data) {
|
for (const master of data) {
|
||||||
if (template.id) {
|
if (master.id) {
|
||||||
try {
|
try {
|
||||||
const templateData = await fetchFormResponses(template.id);
|
const templateData = await fetchFormResponses(master.id);
|
||||||
if (templateData && templateData.formTemplateData) {
|
if (templateData && templateData.formTemplateData) {
|
||||||
|
// Si on a les réponses brutes sauvegardées, les utiliser
|
||||||
if (templateData.formTemplateData.responses) {
|
if (templateData.formTemplateData.responses) {
|
||||||
responsesMap[template.id] = templateData.formTemplateData.responses;
|
responsesMap[master.id] =
|
||||||
|
templateData.formTemplateData.responses;
|
||||||
} else {
|
} else {
|
||||||
// Extraire les réponses depuis les champs
|
// Sinon, extraire les réponses depuis les champs
|
||||||
const responses = {};
|
const responses = {};
|
||||||
if (templateData.formTemplateData.fields) {
|
if (templateData.formTemplateData.fields) {
|
||||||
templateData.formTemplateData.fields.forEach((field) => {
|
templateData.formTemplateData.fields.forEach((field) => {
|
||||||
@ -418,42 +434,24 @@ export default function InscriptionFormShared({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
responsesMap[template.id] = responses;
|
responsesMap[master.id] = responses;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Pas de données existantes pour le template ${template.id}:`,
|
`Pas de données existantes pour le template ${master.id}:`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
|
// Ce n'est pas critique si un template n'a pas de données
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setFormResponses(responsesMap);
|
setFormResponses(responsesMap);
|
||||||
};
|
|
||||||
fetchAllResponses();
|
|
||||||
});
|
|
||||||
|
|
||||||
fetchParentFileTemplatesFromRegistrationFiles(studentId).then((data) => {
|
|
||||||
setParentFileTemplates(data);
|
|
||||||
|
|
||||||
// Initialiser uploadedFiles avec uniquement les fichiers dont `file` n'est pas null
|
|
||||||
const filteredFiles = data
|
|
||||||
.filter((item) => item.file !== null)
|
|
||||||
.map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
fileName: item.file,
|
|
||||||
}));
|
|
||||||
setUploadedFiles(filteredFiles);
|
|
||||||
});
|
|
||||||
|
|
||||||
fetchProfiles()
|
|
||||||
.then((data) => {
|
|
||||||
setProfiles(data);
|
|
||||||
})
|
})
|
||||||
.catch((error) => logger.error('Error fetching profiles : ', error));
|
.catch((error) =>
|
||||||
|
logger.error('Error fetching school file masters:', error)
|
||||||
|
);
|
||||||
|
|
||||||
if (selectedEstablishmentId) {
|
|
||||||
// Fetch data for registration payment modes
|
// Fetch data for registration payment modes
|
||||||
handleRegistrationPaymentModes();
|
handleRegistrationPaymentModes();
|
||||||
|
|
||||||
@ -466,7 +464,7 @@ export default function InscriptionFormShared({
|
|||||||
// Fetch data for tuition payment plans
|
// Fetch data for tuition payment plans
|
||||||
handleTuitionnPaymentPlans();
|
handleTuitionnPaymentPlans();
|
||||||
}
|
}
|
||||||
}, [studentId, selectedEstablishmentId]);
|
}, [selectedEstablishmentId]);
|
||||||
|
|
||||||
const handleRegistrationPaymentModes = () => {
|
const handleRegistrationPaymentModes = () => {
|
||||||
fetchRegistrationPaymentModes(selectedEstablishmentId)
|
fetchRegistrationPaymentModes(selectedEstablishmentId)
|
||||||
@ -516,22 +514,10 @@ export default function InscriptionFormShared({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Générer le nom du fichier : <nom_template>.<extension d'origine>
|
|
||||||
let extension = '';
|
|
||||||
if (file.name && file.name.lastIndexOf('.') !== -1) {
|
|
||||||
extension = file.name.substring(file.name.lastIndexOf('.'));
|
|
||||||
}
|
|
||||||
// Nettoyer le nom du template pour éviter les caractères spéciaux
|
|
||||||
const cleanName = (selectedFile.name || 'document')
|
|
||||||
.replace(/[^a-zA-Z0-9_\-]/g, '_')
|
|
||||||
.replace(/_+/g, '_')
|
|
||||||
.replace(/^_+|_+$/g, '');
|
|
||||||
const finalFileName = `${cleanName}${extension}`;
|
|
||||||
|
|
||||||
const updateData = new FormData();
|
const updateData = new FormData();
|
||||||
updateData.append('file', file, finalFileName);
|
updateData.append('file', file);
|
||||||
|
|
||||||
return editRegistrationSchoolFileTemplates(
|
return editRegistrationParentFileTemplates(
|
||||||
selectedFile.id,
|
selectedFile.id,
|
||||||
updateData,
|
updateData,
|
||||||
csrfToken
|
csrfToken
|
||||||
@ -542,10 +528,11 @@ 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 }
|
? { ...uploadedFile, fileName: response.data.file } // Met à jour le fichier téléversé
|
||||||
: 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,
|
||||||
@ -565,11 +552,11 @@ export default function InscriptionFormShared({
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return response;
|
return response; // Retourner la réponse pour signaler le succès
|
||||||
})
|
})
|
||||||
.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;
|
throw error; // Relancer l'erreur pour que l'appelant puisse la capturer
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -600,7 +587,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 }
|
? { ...uploadedFile, fileName: null, fileUrl: null } // Réinitialiser les champs liés au fichier
|
||||||
: uploadedFile
|
: uploadedFile
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@ -799,12 +786,11 @@ export default function InscriptionFormShared({
|
|||||||
{/* Page 5 : Formulaires dynamiques d'inscription */}
|
{/* Page 5 : Formulaires dynamiques d'inscription */}
|
||||||
{currentPage === 5 && (
|
{currentPage === 5 && (
|
||||||
<DynamicFormsList
|
<DynamicFormsList
|
||||||
schoolFileTemplates={schoolFileTemplates}
|
schoolFileMasters={schoolFileMasters}
|
||||||
existingResponses={formResponses}
|
existingResponses={formResponses}
|
||||||
onFormSubmit={handleDynamicFormSubmit}
|
onFormSubmit={handleDynamicFormSubmit}
|
||||||
onValidationChange={handleDynamicFormsValidationChange}
|
onValidationChange={handleDynamicFormsValidationChange}
|
||||||
enable={enable}
|
enable={enable}
|
||||||
onFileUpload={handleFileUpload}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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: false,
|
is_active: true,
|
||||||
profile_data: {
|
profile_data: {
|
||||||
email: '',
|
email: '',
|
||||||
password: 'Provisoire01!',
|
password: 'Provisoire01!',
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
'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';
|
||||||
@ -9,55 +8,24 @@ import {
|
|||||||
fetchParentFileTemplatesFromRegistrationFiles,
|
fetchParentFileTemplatesFromRegistrationFiles,
|
||||||
} from '@/app/actions/subscriptionAction';
|
} from '@/app/actions/subscriptionAction';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import { School, FileText } from 'lucide-react';
|
import { School, CheckCircle, Hourglass, 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,
|
||||||
@ -120,27 +88,14 @@ 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,
|
||||||
@ -170,17 +125,6 @@ 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 (
|
||||||
@ -218,8 +162,8 @@ export default function ValidateSubscription({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Colonne droite : Liste des documents, Option de fusion, Affectation, Refus */}
|
{/* Colonne droite : Liste des documents, Option de fusion et Affectation */}
|
||||||
<div className="w-1/4 flex flex-col flex-1 gap-4 h-full">
|
<div className="w-1/4 flex flex-col gap-4">
|
||||||
{/* 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">
|
||||||
@ -243,176 +187,60 @@ export default function ValidateSubscription({
|
|||||||
<FileText className="w-5 h-5 text-green-600" />
|
<FileText className="w-5 h-5 text-green-600" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1">{template.name}</span>
|
{template.name}
|
||||||
{/* 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>
|
||||||
|
|
||||||
{/* Nouvelle section Options de validation : carte unique, sélecteur de classe (ligne 1), toggle fusion (ligne 2 aligné à droite) */}
|
{/* Option de fusion */}
|
||||||
{allChecked && allValidated && (
|
<div className="bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200">
|
||||||
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 flex flex-col gap-4">
|
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||||
<div>
|
Option de fusion
|
||||||
<SelectChoice
|
</h3>
|
||||||
name="associated_class"
|
<div className="flex items-center justify-between">
|
||||||
label="Liste des classes"
|
|
||||||
placeHolder="Sélectionner une classe"
|
|
||||||
selected={formData.associated_class || ''}
|
|
||||||
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
|
<ToggleSwitch
|
||||||
label="Fusionner les documents"
|
label="Fusionner les documents"
|
||||||
checked={mergeDocuments}
|
checked={mergeDocuments}
|
||||||
onChange={handleToggleMergeDocuments}
|
onChange={handleToggleMergeDocuments}
|
||||||
className="ml-0"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Boutons Valider/Refuser en bas, centrés */}
|
{/* Section Affectation */}
|
||||||
<div className="mt-auto py-4">
|
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
Affectation à une classe
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SelectChoice
|
||||||
|
name="associated_class"
|
||||||
|
label="Classe"
|
||||||
|
placeHolder="Sélectionner une classe"
|
||||||
|
selected={formData.associated_class || ''} // La valeur actuelle de la classe associée
|
||||||
|
callback={(e) => onChange('associated_class', e.target.value)} // Met à jour formData
|
||||||
|
choices={classes.map((classe) => ({
|
||||||
|
value: classe.id,
|
||||||
|
label: classe.atmosphere_name,
|
||||||
|
}))} // Liste des classes disponibles
|
||||||
|
required
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
text="Soumettre"
|
text="Valider le dossier d'inscription"
|
||||||
onClick={e => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// 1. Si tous les documents ne sont pas cochés, rien ne se passe (bouton désactivé)
|
handleAssignClass();
|
||||||
// 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
|
primary
|
||||||
className={`w-full h-12 rounded-md shadow-sm focus:outline-none ${
|
className={`px-4 py-2 rounded-md shadow-sm focus:outline-none ${
|
||||||
!allChecked || (allChecked && allValidated && !formData.associated_class)
|
!isPageValid
|
||||||
? 'bg-gray-300 text-gray-700 cursor-not-allowed'
|
? 'bg-gray-300 text-gray-700 cursor-not-allowed'
|
||||||
: 'bg-emerald-500 text-white hover:bg-emerald-600'
|
: 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||||
}`}
|
}`}
|
||||||
disabled={
|
disabled={!isPageValid}
|
||||||
!allChecked || (allChecked && allValidated && !formData.associated_class)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Popup de confirmation si refus */}
|
|
||||||
<Popup
|
|
||||||
isOpen={showRefusedPopup}
|
|
||||||
onCancel={() => setShowRefusedPopup(false)}
|
|
||||||
onConfirm={() => {
|
|
||||||
setShowRefusedPopup(false);
|
|
||||||
handleRefuseDossier();
|
|
||||||
}}
|
|
||||||
message={
|
|
||||||
<span>
|
|
||||||
{`Le dossier d'inscription de ${firstName} ${lastName} va être refusé. Un email sera envoyé au responsable à l'adresse : `}
|
|
||||||
<span className="font-semibold text-blue-700">{email}</span>
|
|
||||||
{' avec la liste des documents non validés :'}
|
|
||||||
<ul className="list-disc ml-6 mt-2">
|
|
||||||
{refusedDocs.map(doc => (
|
|
||||||
<li key={doc.idx}>{doc.name}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Popup de confirmation finale si tous validés et classe sélectionnée */}
|
|
||||||
<Popup
|
|
||||||
isOpen={showFinalValidationPopup}
|
|
||||||
onCancel={() => setShowFinalValidationPopup(false)}
|
|
||||||
onConfirm={() => {
|
|
||||||
setShowFinalValidationPopup(false);
|
|
||||||
handleAssignClass();
|
|
||||||
}}
|
|
||||||
message={
|
|
||||||
<span>
|
|
||||||
{`Le dossier d'inscription de ${lastName} ${firstName} va être validé et l'élève affecté à la classe sélectionnée.`}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, 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' },
|
||||||
@ -10,14 +9,8 @@ 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,
|
||||||
@ -26,45 +19,23 @@ 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 = [...new Set(modes.map((mode) => mode.mode))];
|
const activeModes = paymentModes.map((mode) => mode.mode);
|
||||||
setActivePaymentModes(activeModes);
|
setActivePaymentModes(activeModes);
|
||||||
}, [modes]);
|
}, [paymentModes]);
|
||||||
|
|
||||||
const handleModeToggle = (modeId) => {
|
const handleModeToggle = (modeId) => {
|
||||||
const isActive = activePaymentModes.includes(modeId);
|
const updatedMode = paymentModes.find((mode) => mode.mode === modeId);
|
||||||
|
const isActive = !!updatedMode;
|
||||||
|
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
if (unified) {
|
|
||||||
[0, 1].forEach((t) =>
|
|
||||||
handleCreate({
|
|
||||||
mode: modeId,
|
|
||||||
type: t,
|
|
||||||
establishment: selectedEstablishmentId,
|
|
||||||
}).catch((e) => logger.error(e))
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
handleCreate({
|
handleCreate({
|
||||||
mode: modeId,
|
mode: modeId,
|
||||||
type,
|
type,
|
||||||
establishment: selectedEstablishmentId,
|
establishment: selectedEstablishmentId,
|
||||||
}).catch((e) => logger.error(e));
|
});
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const toDelete = modes.filter((m) => m.mode === modeId);
|
handleDelete(updatedMode.id, null);
|
||||||
toDelete.forEach((m) =>
|
|
||||||
handleDelete(m.id, null).catch((e) => logger.error(e))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect } 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,22 +13,8 @@ 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,
|
||||||
@ -38,63 +24,38 @@ const PaymentPlanSelector = ({
|
|||||||
const { selectedEstablishmentId } = useEstablishment();
|
const { selectedEstablishmentId } = useEstablishment();
|
||||||
const [checkedPlans, setCheckedPlans] = useState([]);
|
const [checkedPlans, setCheckedPlans] = useState([]);
|
||||||
|
|
||||||
const plans = useMemo(
|
// Vérifie si un plan existe pour ce type (par id)
|
||||||
() =>
|
|
||||||
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));
|
||||||
toDelete.forEach((p) =>
|
handleDelete(updatedPlan.id, null);
|
||||||
handleDelete(p.id, null).catch((e) => logger.error(e))
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
setCheckedPlans((prev) => [...prev, planOption.id]);
|
setCheckedPlans((prev) => [...prev, planOption.id]);
|
||||||
if (unified) {
|
|
||||||
// Créer pour inscription (0) et scolarité (1)
|
|
||||||
[0, 1].forEach((t) =>
|
|
||||||
handleCreate({
|
|
||||||
plan_type: planOption.id,
|
|
||||||
type: t,
|
|
||||||
establishment: selectedEstablishmentId,
|
|
||||||
}).catch((e) => logger.error(e))
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
handleCreate({
|
handleCreate({
|
||||||
plan_type: planOption.id,
|
plan_type: planOption.id,
|
||||||
type,
|
type,
|
||||||
establishment: selectedEstablishmentId,
|
establishment: selectedEstablishmentId,
|
||||||
}).catch((e) => logger.error(e));
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (plans.length > 0) {
|
if (paymentPlans && paymentPlans.length > 0) {
|
||||||
const ids = plans.map((plan) =>
|
setCheckedPlans(
|
||||||
typeof plan.plan_type === 'object' ? plan.plan_type.id : plan.plan_type
|
paymentPlans.map((plan) =>
|
||||||
|
typeof plan.plan_type === 'object'
|
||||||
|
? plan.plan_type.id
|
||||||
|
: plan.plan_type
|
||||||
|
)
|
||||||
);
|
);
|
||||||
setCheckedPlans([...new Set(ids)]);
|
|
||||||
} else {
|
|
||||||
setCheckedPlans([]);
|
|
||||||
}
|
}
|
||||||
}, [plans]);
|
}, [paymentPlans]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@ -1,32 +1,27 @@
|
|||||||
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 { data: session, status } = useSession();
|
const { user, profileRole } = useEstablishment();
|
||||||
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(() => {
|
||||||
// Ne pas agir tant que NextAuth charge la session
|
logger.debug({
|
||||||
if (status === 'loading') return;
|
user,
|
||||||
|
profileRole,
|
||||||
|
requiredRight,
|
||||||
|
hasRequiredRight,
|
||||||
|
});
|
||||||
|
|
||||||
logger.debug({ status, profileRole, requiredRight });
|
if (user && profileRole !== null) {
|
||||||
|
|
||||||
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
|
||||||
);
|
);
|
||||||
@ -35,18 +30,21 @@ 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) {
|
if (redirectUrl !== null) {
|
||||||
router.push(redirectUrl);
|
router.push(`${redirectUrl}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// User non authentifié
|
||||||
|
router.push(`${FE_USERS_LOGIN_URL}`);
|
||||||
}
|
}
|
||||||
}, [status, profileRole, requiredRight]);
|
}, [user, profileRole]);
|
||||||
|
|
||||||
if (status === 'loading' || !hasRequiredRight) return <Loader />;
|
// Autoriser l'affichage si authentifié et rôle correct
|
||||||
|
return hasRequiredRight ? children : null;
|
||||||
return children;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProtectedRoute;
|
export default ProtectedRoute;
|
||||||
|
|||||||
@ -7,6 +7,7 @@ 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';
|
||||||
@ -43,7 +44,7 @@ export default function CompetenciesList({
|
|||||||
3: false,
|
3: false,
|
||||||
4: false,
|
4: false,
|
||||||
});
|
});
|
||||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
const { selectedEstablishmentId } = useEstablishment();
|
||||||
const csrfToken = useCsrfToken();
|
const csrfToken = useCsrfToken();
|
||||||
const { showNotification } = useNotification();
|
const { showNotification } = useNotification();
|
||||||
|
|
||||||
@ -279,7 +280,6 @@ 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">
|
||||||
{profileRole !== 0 && (
|
|
||||||
<Button
|
<Button
|
||||||
text="Sauvegarder"
|
text="Sauvegarder"
|
||||||
className={`px-6 py-2 rounded-md shadow ${
|
className={`px-6 py-2 rounded-md shadow ${
|
||||||
@ -291,7 +291,6 @@ export default function CompetenciesList({
|
|||||||
primary
|
primary
|
||||||
disabled={!hasSelection}
|
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">
|
||||||
|
|||||||
@ -5,7 +5,6 @@ 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 },
|
||||||
@ -73,8 +72,6 @@ const TreeView = forwardRef(function TreeView(
|
|||||||
clearSelection: () => setSelectedCompetencies({}),
|
clearSelection: () => setSelectedCompetencies({}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { profileRole } = useEstablishment();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{data.map((domaine) => (
|
{data.map((domaine) => (
|
||||||
@ -115,18 +112,12 @@ 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={
|
onClick={() => handleCompetenceClick(competence)}
|
||||||
profileRole !== 0
|
|
||||||
? () => handleCompetenceClick(competence)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
style={{
|
style={{
|
||||||
cursor:
|
cursor:
|
||||||
competence.state === 'required'
|
competence.state === 'required'
|
||||||
? 'default'
|
? 'default'
|
||||||
: profileRole !== 0
|
: 'pointer',
|
||||||
? 'pointer'
|
|
||||||
: 'default',
|
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -130,7 +130,9 @@ 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 { selectedEstablishmentId, profileRole } = useEstablishment();
|
const [detailsModalVisible, setDetailsModalVisible] = useState(false);
|
||||||
|
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();
|
||||||
@ -447,25 +449,6 @@ 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
|
||||||
@ -551,7 +534,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={profileRole !== 0}
|
button={true}
|
||||||
onClick={handleAddClass}
|
onClick={handleAddClass}
|
||||||
/>
|
/>
|
||||||
<Table
|
<Table
|
||||||
|
|||||||
@ -29,7 +29,7 @@ const SpecialitiesSection = ({
|
|||||||
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
||||||
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
||||||
|
|
||||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
const { selectedEstablishmentId } = 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' },
|
||||||
...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []),
|
{ 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={profileRole !== 0}
|
button={true}
|
||||||
onClick={handleAddSpeciality}
|
onClick={handleAddSpeciality}
|
||||||
/>
|
/>
|
||||||
<Table
|
<Table
|
||||||
|
|||||||
@ -3,7 +3,8 @@ 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 { DndProvider, useDrop } from 'react-dnd';
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
|
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';
|
||||||
@ -127,6 +128,7 @@ 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({});
|
||||||
@ -138,49 +140,40 @@ const TeachersSection = ({
|
|||||||
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
||||||
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
||||||
|
|
||||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
const [confirmPopupVisible, setConfirmPopupVisible] = useState(false);
|
||||||
|
const [confirmPopupMessage, setConfirmPopupMessage] = useState('');
|
||||||
|
const [confirmPopupOnConfirm, setConfirmPopupOnConfirm] = useState(() => {});
|
||||||
|
|
||||||
// --- UTILS ---
|
const { selectedEstablishmentId } = useEstablishment();
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
if (existingProfile) {
|
// Vérifier si l'email correspond à un profil existant
|
||||||
logger.info(
|
const existingProfile = profiles.find((profile) => profile.email === email);
|
||||||
`Adresse email déjà utilisée pour le profil ${existingProfile.id}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFormData({
|
setFormData((prevData) => ({
|
||||||
|
...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 = () => {
|
||||||
@ -202,15 +195,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(logger.error);
|
.catch((error) => {
|
||||||
|
logger.error(error);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveNewTeacher = () => {
|
const handleSaveNewTeacher = () => {
|
||||||
@ -241,29 +234,16 @@ 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) setLocalErrors(error.details);
|
if (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');
|
||||||
@ -272,24 +252,51 @@ 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 profileRoleData = {
|
const data = {
|
||||||
|
last_name: updatedData.last_name,
|
||||||
|
first_name: updatedData.first_name,
|
||||||
|
profile_role_data: {
|
||||||
id: updatedData.profile_role,
|
id: updatedData.profile_role,
|
||||||
establishment: selectedEstablishmentId,
|
establishment: selectedEstablishmentId,
|
||||||
role_type: updatedData.role_type || 0,
|
role_type: updatedData.role_type || 0,
|
||||||
profile: updatedData.existingProfileId,
|
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, {
|
handleEdit(id, data)
|
||||||
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) =>
|
||||||
@ -301,7 +308,10 @@ const TeachersSection = ({
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('Error:', error.message);
|
logger.error('Error:', error.message);
|
||||||
if (error.details) setLocalErrors(error.details);
|
if (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');
|
||||||
@ -311,12 +321,45 @@ const TeachersSection = ({
|
|||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
const { name, value, type, checked } = e.target;
|
const { name, value, type, checked } = e.target;
|
||||||
let parsedValue = type === 'checkbox' ? (checked ? 1 : 0) : value;
|
let parsedValue = 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) => {
|
||||||
updateFormData({ specialities: selectedSpecialities });
|
if (editingTeacher) {
|
||||||
|
setFormData((prevData) => ({
|
||||||
|
...prevData,
|
||||||
|
specialities: selectedSpecialities,
|
||||||
|
}));
|
||||||
|
} else if (newTeacher) {
|
||||||
|
setNewTeacher((prevData) => ({
|
||||||
|
...prevData,
|
||||||
|
specialities: selectedSpecialities,
|
||||||
|
}));
|
||||||
|
setFormData((prevData) => ({
|
||||||
|
...prevData,
|
||||||
|
specialities: selectedSpecialities,
|
||||||
|
}));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditTeacher = (teacher) => {
|
const handleEditTeacher = (teacher) => {
|
||||||
@ -363,7 +406,6 @@ 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':
|
||||||
@ -422,7 +464,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}
|
||||||
@ -521,7 +563,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' },
|
||||||
...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []),
|
{ name: 'ACTIONS', label: 'Actions' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -531,7 +573,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={profileRole !== 0}
|
button={true}
|
||||||
onClick={handleAddTeacher}
|
onClick={handleAddTeacher}
|
||||||
/>
|
/>
|
||||||
<Table
|
<Table
|
||||||
|
|||||||
209
Front-End/src/components/Structure/Files/CreateDocumentModal.js
Normal file
209
Front-End/src/components/Structure/Files/CreateDocumentModal.js
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import Modal from '@/components/Modal';
|
||||||
|
import { FolderPlus, FileText, FilePlus2, ArrowLeft, Settings2, Upload as UploadIcon } from 'lucide-react';
|
||||||
|
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
|
||||||
|
import FileUpload from '@/components/Form/FileUpload';
|
||||||
|
|
||||||
|
export default function CreateDocumentModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onCreateGroup,
|
||||||
|
onCreateParentFile,
|
||||||
|
onCreateSchoolFileMaster,
|
||||||
|
groups = [],
|
||||||
|
}) {
|
||||||
|
const [step, setStep] = useState('main'); // main | choose_form | form_builder | file_upload
|
||||||
|
const [fileName, setFileName] = useState('');
|
||||||
|
const [selectedGroupsFileUpload, setSelectedGroupsFileUpload] = useState([]);
|
||||||
|
const [uploadedFile, setUploadedFile] = useState(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setStep('main');
|
||||||
|
setFileName('');
|
||||||
|
setSelectedGroupsFileUpload([]);
|
||||||
|
setUploadedFile(null);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Handler pour chaque type
|
||||||
|
const handleSelect = (type) => {
|
||||||
|
if (type === 'groupe') {
|
||||||
|
setStep('main');
|
||||||
|
onCreateGroup();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
if (type === 'formulaire') {
|
||||||
|
setStep('choose_form');
|
||||||
|
}
|
||||||
|
if (type === 'parent') {
|
||||||
|
setStep('main');
|
||||||
|
onCreateParentFile();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Retour au menu principal
|
||||||
|
const handleBack = () => setStep('main');
|
||||||
|
|
||||||
|
// Submit pour formulaire existant
|
||||||
|
const handleFileUploadSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!fileName || selectedGroupsFileUpload.length === 0 || !uploadedFile) return;
|
||||||
|
onCreateSchoolFileMaster({
|
||||||
|
name: fileName,
|
||||||
|
group_ids: selectedGroupsFileUpload,
|
||||||
|
file: uploadedFile,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
setIsOpen={onClose}
|
||||||
|
title="Créer un document"
|
||||||
|
modalClassName="w-full max-w-md"
|
||||||
|
>
|
||||||
|
{step === 'main' && (
|
||||||
|
<div className="flex flex-col gap-6 py-4">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-blue-50 hover:bg-blue-100 border border-blue-200 transition"
|
||||||
|
onClick={() => handleSelect('groupe')}
|
||||||
|
>
|
||||||
|
<FolderPlus className="w-6 h-6 text-blue-600" />
|
||||||
|
<span className="font-semibold text-blue-800">Dossier d&aposinscription</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 transition"
|
||||||
|
onClick={() => handleSelect('formulaire')}
|
||||||
|
>
|
||||||
|
<FileText className="w-6 h-6 text-emerald-600" />
|
||||||
|
<span className="font-semibold text-emerald-800">Formulaire scolaire</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-orange-50 hover:bg-orange-100 border border-orange-200 transition"
|
||||||
|
onClick={() => handleSelect('parent')}
|
||||||
|
>
|
||||||
|
<FilePlus2 className="w-6 h-6 text-orange-500" />
|
||||||
|
<span className="font-semibold text-orange-700">Pièce à fournir</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{step === 'choose_form' && (
|
||||||
|
<div className="flex flex-col gap-4 py-4">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-emerald-100 hover:bg-emerald-200 border border-emerald-300 transition"
|
||||||
|
onClick={() => setStep('form_builder')}
|
||||||
|
>
|
||||||
|
<Settings2 className="w-6 h-6 text-emerald-700" />
|
||||||
|
<span className="font-semibold text-emerald-900">Formulaire personnalisé</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-300 transition"
|
||||||
|
onClick={() => setStep('file_upload')}
|
||||||
|
>
|
||||||
|
<UploadIcon className="w-6 h-6 text-gray-700" />
|
||||||
|
<span className="font-semibold text-gray-900">Importer un formulaire existant</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mt-2"
|
||||||
|
onClick={handleBack}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
<span>Retour</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{step === 'form_builder' && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-2"
|
||||||
|
onClick={handleBack}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
<span>Retour</span>
|
||||||
|
</button>
|
||||||
|
<FormTemplateBuilder
|
||||||
|
onSave={(data) => {
|
||||||
|
onCreateSchoolFileMaster(data);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
groups={groups}
|
||||||
|
isEditing={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{step === 'file_upload' && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-2"
|
||||||
|
onClick={handleBack}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
<span>Retour</span>
|
||||||
|
</button>
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={handleFileUploadSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border rounded px-3 py-2"
|
||||||
|
placeholder="Nom du formulaire"
|
||||||
|
value={fileName}
|
||||||
|
onChange={e => setFileName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{/* Sélecteur de groupes à cocher */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
Groupes d'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Edit,
|
Edit,
|
||||||
Trash2,
|
Trash2,
|
||||||
@ -6,6 +6,8 @@ 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';
|
||||||
@ -29,9 +31,11 @@ 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';
|
||||||
@ -192,8 +196,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([]);
|
||||||
@ -211,6 +215,7 @@ 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);
|
||||||
|
|
||||||
@ -818,7 +823,6 @@ 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" />
|
||||||
{profileRole !== 0 && (
|
|
||||||
<button
|
<button
|
||||||
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"
|
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"
|
||||||
onClick={() => setIsGroupModalOpen(true)}
|
onClick={() => setIsGroupModalOpen(true)}
|
||||||
@ -826,7 +830,6 @@ export default function FilesGroupsManagement({
|
|||||||
>
|
>
|
||||||
<Plus className="w-5 h-5" />
|
<Plus className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<SimpleList
|
<SimpleList
|
||||||
items={groups}
|
items={groups}
|
||||||
@ -867,7 +870,6 @@ export default function FilesGroupsManagement({
|
|||||||
<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" />
|
||||||
{profileRole !== 0 && (
|
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
buttonContent={
|
buttonContent={
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
@ -912,7 +914,6 @@ export default function FilesGroupsManagement({
|
|||||||
dropdownOpen={isDocDropdownOpen}
|
dropdownOpen={isDocDropdownOpen}
|
||||||
setDropdownOpen={setIsDocDropdownOpen}
|
setDropdownOpen={setIsDocDropdownOpen}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</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">
|
||||||
|
|||||||
@ -9,8 +9,6 @@ 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,
|
||||||
@ -18,7 +16,6 @@ const DiscountsSection = ({
|
|||||||
handleEdit,
|
handleEdit,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
type,
|
type,
|
||||||
unified = false,
|
|
||||||
subscriptionMode = false,
|
subscriptionMode = false,
|
||||||
selectedDiscounts,
|
selectedDiscounts,
|
||||||
handleDiscountSelection,
|
handleDiscountSelection,
|
||||||
@ -42,7 +39,7 @@ const DiscountsSection = ({
|
|||||||
amount: '',
|
amount: '',
|
||||||
description: '',
|
description: '',
|
||||||
discount_type: 0,
|
discount_type: 0,
|
||||||
type: unified ? 0 : type,
|
type: type,
|
||||||
establishment: selectedEstablishmentId,
|
establishment: selectedEstablishmentId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -222,21 +219,6 @@ 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">
|
||||||
@ -277,18 +259,6 @@ 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">
|
||||||
@ -365,25 +335,34 @@ 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' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const emptyMessage = (
|
let emptyMessage;
|
||||||
|
if (type === 0) {
|
||||||
|
emptyMessage = (
|
||||||
<AlertMessage
|
<AlertMessage
|
||||||
type="info"
|
type="info"
|
||||||
title="Aucune réduction enregistrée"
|
title="Aucune réduction enregistrée"
|
||||||
message="Aucune réduction n'a encore été 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">
|
||||||
@ -391,8 +370,8 @@ const DiscountsSection = ({
|
|||||||
<SectionHeader
|
<SectionHeader
|
||||||
icon={Tag}
|
icon={Tag}
|
||||||
discountStyle={true}
|
discountStyle={true}
|
||||||
title="Liste des réductions"
|
title={`${type == 0 ? "Liste des réductions sur les frais d'inscription" : 'Liste des réductions sur les frais de scolarité'}`}
|
||||||
description="Gérez vos réductions sur les frais d'inscription et de scolarité"
|
description={`Gérez ${type == 0 ? " vos réductions sur les frais d'inscription" : ' vos réductions sur les frais de scolarité'}`}
|
||||||
button={!subscriptionMode}
|
button={!subscriptionMode}
|
||||||
onClick={handleAddDiscount}
|
onClick={handleAddDiscount}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,145 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -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,141 +31,223 @@ const FeesManagement = ({
|
|||||||
handleEdit,
|
handleEdit,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
}) => {
|
}) => {
|
||||||
// Liste unique triée par type puis par nom
|
const handleDiscountDelete = (id, type) => {
|
||||||
const allFees = [...(registrationFees ?? []), ...(tuitionFees ?? [])].sort(
|
if (type === 0) {
|
||||||
(a, b) => a.type - b.type || (a.name ?? '').localeCompare(b.name ?? '')
|
setRegistrationFees((prevFees) =>
|
||||||
);
|
|
||||||
|
|
||||||
const setAllFees = (updater) => {
|
|
||||||
const next = typeof updater === 'function' ? updater(allFees) : updater;
|
|
||||||
setRegistrationFees(next.filter((f) => f.type === 0));
|
|
||||||
setTuitionFees(next.filter((f) => f.type === 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="w-full space-y-12">
|
|
||||||
{/* Tableau unique des frais */}
|
|
||||||
<FeesSection
|
|
||||||
fees={allFees}
|
|
||||||
setFees={setAllFees}
|
|
||||||
unified={true}
|
|
||||||
handleCreate={(feeData) => {
|
|
||||||
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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Tableau unique des réductions */}
|
|
||||||
<DiscountsSection
|
|
||||||
discounts={allDiscounts}
|
|
||||||
setDiscounts={setAllDiscounts}
|
|
||||||
unified={true}
|
|
||||||
handleCreate={(data) => {
|
|
||||||
const setter =
|
|
||||||
data.type === 0 ? setRegistrationDiscounts : setTuitionDiscounts;
|
|
||||||
return handleCreate(BE_SCHOOL_DISCOUNTS_URL, data, setter);
|
|
||||||
}}
|
|
||||||
handleEdit={(id, data) => {
|
|
||||||
const discount = allDiscounts.find((d) => d.id === id);
|
|
||||||
const discountType = data.type ?? discount?.type;
|
|
||||||
const setter =
|
|
||||||
discountType === 0 ? setRegistrationDiscounts : setTuitionDiscounts;
|
|
||||||
return handleEdit(BE_SCHOOL_DISCOUNTS_URL, id, data, setter);
|
|
||||||
}}
|
|
||||||
handleDelete={(id) => {
|
|
||||||
const discount = allDiscounts.find((d) => d.id === id);
|
|
||||||
const setter =
|
|
||||||
discount?.type === 0
|
|
||||||
? setRegistrationDiscounts
|
|
||||||
: setTuitionDiscounts;
|
|
||||||
return handleDelete(BE_SCHOOL_DISCOUNTS_URL, id, setter);
|
|
||||||
}}
|
|
||||||
onDiscountDelete={(id) => {
|
|
||||||
// Retire la réduction des frais concernés
|
|
||||||
setAllFees((prevFees) =>
|
|
||||||
prevFees.map((fee) => ({
|
prevFees.map((fee) => ({
|
||||||
...fee,
|
...fee,
|
||||||
discounts: fee.discounts.filter((dId) => dId !== id),
|
discounts: fee.discounts.filter((discountId) => discountId !== id),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}}
|
} else {
|
||||||
/>
|
setTuitionFees((prevFees) =>
|
||||||
|
prevFees.map((fee) => ({
|
||||||
|
...fee,
|
||||||
|
discounts: fee.discounts.filter((discountId) => discountId !== id),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
{/* Plans et modes de paiement communs */}
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<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">
|
||||||
|
Frais d'inscription
|
||||||
|
</span>
|
||||||
|
<hr className="flex-grow border-t-2 border-gray-300" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 w-4/5">
|
||||||
|
<FeesSection
|
||||||
|
fees={registrationFees}
|
||||||
|
setFees={setRegistrationFees}
|
||||||
|
discounts={registrationDiscounts}
|
||||||
|
handleCreate={(newData) =>
|
||||||
|
handleCreate(`${BE_SCHOOL_FEES_URL}`, newData, setRegistrationFees)
|
||||||
|
}
|
||||||
|
handleEdit={(id, updatedData) =>
|
||||||
|
handleEdit(
|
||||||
|
`${BE_SCHOOL_FEES_URL}`,
|
||||||
|
id,
|
||||||
|
updatedData,
|
||||||
|
setRegistrationFees
|
||||||
|
)
|
||||||
|
}
|
||||||
|
handleDelete={(id) =>
|
||||||
|
handleDelete(`${BE_SCHOOL_FEES_URL}`, id, setRegistrationFees)
|
||||||
|
}
|
||||||
|
type={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-12 w-4/5">
|
||||||
|
<DiscountsSection
|
||||||
|
discounts={registrationDiscounts}
|
||||||
|
setDiscounts={setRegistrationDiscounts}
|
||||||
|
handleCreate={(newData) =>
|
||||||
|
handleCreate(
|
||||||
|
`${BE_SCHOOL_DISCOUNTS_URL}`,
|
||||||
|
newData,
|
||||||
|
setRegistrationDiscounts
|
||||||
|
)
|
||||||
|
}
|
||||||
|
handleEdit={(id, updatedData) =>
|
||||||
|
handleEdit(
|
||||||
|
`${BE_SCHOOL_DISCOUNTS_URL}`,
|
||||||
|
id,
|
||||||
|
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
|
||||||
allPaymentPlans={allPaymentPlans}
|
paymentPlans={registrationPaymentPlans}
|
||||||
handleCreate={(data) => {
|
setPaymentPlans={setRegistrationPaymentPlans}
|
||||||
const setter =
|
handleCreate={(newData) =>
|
||||||
data.type === 0
|
handleCreate(
|
||||||
? setRegistrationPaymentPlans
|
`${BE_SCHOOL_PAYMENT_PLANS_URL}`,
|
||||||
: setTuitionPaymentPlans;
|
newData,
|
||||||
return handleCreate(BE_SCHOOL_PAYMENT_PLANS_URL, data, setter);
|
setRegistrationPaymentPlans
|
||||||
}}
|
)
|
||||||
handleDelete={(id) => {
|
}
|
||||||
const plan = allPaymentPlans.find((p) => p.id === id);
|
handleDelete={(id) =>
|
||||||
const setter =
|
handleDelete(
|
||||||
plan?.type === 0
|
`${BE_SCHOOL_PAYMENT_PLANS_URL}`,
|
||||||
? setRegistrationPaymentPlans
|
id,
|
||||||
: setTuitionPaymentPlans;
|
setRegistrationPaymentPlans
|
||||||
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
|
||||||
allPaymentModes={allPaymentModes}
|
paymentModes={registrationPaymentModes}
|
||||||
handleCreate={(data) => {
|
setPaymentModes={setRegistrationPaymentModes}
|
||||||
const setter =
|
handleCreate={(newData) =>
|
||||||
data.type === 0
|
handleCreate(
|
||||||
? setRegistrationPaymentModes
|
`${BE_SCHOOL_PAYMENT_MODES_URL}`,
|
||||||
: setTuitionPaymentModes;
|
newData,
|
||||||
return handleCreate(BE_SCHOOL_PAYMENT_MODES_URL, data, setter);
|
setRegistrationPaymentModes
|
||||||
}}
|
)
|
||||||
handleDelete={(id) => {
|
}
|
||||||
const mode = allPaymentModes.find((m) => m.id === id);
|
handleDelete={(id) =>
|
||||||
const setter =
|
handleDelete(
|
||||||
mode?.type === 0
|
`${BE_SCHOOL_PAYMENT_MODES_URL}`,
|
||||||
? setRegistrationPaymentModes
|
id,
|
||||||
: setTuitionPaymentModes;
|
setRegistrationPaymentModes
|
||||||
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>
|
||||||
|
|||||||
@ -9,13 +9,6 @@ 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,
|
||||||
@ -23,7 +16,6 @@ const FeesSection = ({
|
|||||||
handleEdit,
|
handleEdit,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
type,
|
type,
|
||||||
unified = false,
|
|
||||||
subscriptionMode = false,
|
subscriptionMode = false,
|
||||||
selectedFees,
|
selectedFees,
|
||||||
handleFeeSelection,
|
handleFeeSelection,
|
||||||
@ -37,9 +29,8 @@ 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(() => {});
|
||||||
// En mode unifié, le type effectif est celui du frais ou celui du formulaire de création
|
const labelTypeFrais =
|
||||||
const labelTypeFrais = (feeType) =>
|
type === 0 ? "Frais d'inscription" : 'Frais de scolarité';
|
||||||
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
|
||||||
@ -53,8 +44,10 @@ const FeesSection = ({
|
|||||||
name: '',
|
name: '',
|
||||||
base_amount: '',
|
base_amount: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
validity_start_date: '',
|
||||||
|
validity_end_date: '',
|
||||||
discounts: [],
|
discounts: [],
|
||||||
type: unified ? 0 : type,
|
type: type,
|
||||||
establishment: selectedEstablishmentId,
|
establishment: selectedEstablishmentId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -98,8 +91,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((updated) => {
|
.then((updatedFee) => {
|
||||||
setFees(fees.map((fee) => (fee.id === id ? updated : fee)));
|
setFees(fees.map((fee) => (fee.id === id ? updatedFee : fee)));
|
||||||
setEditingFee(null);
|
setEditingFee(null);
|
||||||
setLocalErrors({});
|
setLocalErrors({});
|
||||||
})
|
})
|
||||||
@ -200,21 +193,6 @@ 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">
|
||||||
@ -244,7 +222,6 @@ 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;
|
||||||
@ -254,18 +231,6 @@ 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">
|
||||||
@ -292,20 +257,22 @@ const FeesSection = ({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRemovePopupVisible(true);
|
setRemovePopupVisible(true);
|
||||||
setRemovePopupMessage(
|
setRemovePopupMessage(
|
||||||
`Attentions ! \nVous êtes sur le point de supprimer un ${feeLabel}.\nÊtes-vous sûr(e) de vouloir poursuivre l'opération ?`
|
`Attentions ! \nVous êtes sur le point de supprimer un ${labelTypeFrais} .\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(feeLabel + ' correctement supprimé');
|
setPopupMessage(
|
||||||
|
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 ' + feeLabel
|
'Erreur lors de la suppression du ' + labelTypeFrais
|
||||||
);
|
);
|
||||||
setPopupVisible(true);
|
setPopupVisible(true);
|
||||||
setRemovePopupVisible(false);
|
setRemovePopupVisible(false);
|
||||||
@ -340,33 +307,42 @@ 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' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const emptyMessage = (
|
let emptyMessage;
|
||||||
|
if (type === 0) {
|
||||||
|
emptyMessage = (
|
||||||
<AlertMessage
|
<AlertMessage
|
||||||
type="warning"
|
type="warning"
|
||||||
title="Aucun frais enregistré"
|
title="Aucun frais d'inscription enregistré"
|
||||||
message="Veuillez procéder à la création de nouveaux frais"
|
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="Liste des frais"
|
title={`${type == 0 ? "Liste des frais d'inscription" : 'Liste des frais de scolarité'}`}
|
||||||
description="Gérez vos frais d'inscription et de scolarité"
|
description={`Gérez${type == 0 ? " vos frais d'inscription" : ' vos frais de scolarité'}`}
|
||||||
button={!subscriptionMode}
|
button={!subscriptionMode}
|
||||||
onClick={handleAddFee}
|
onClick={handleAddFee}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,6 +1,4 @@
|
|||||||
'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();
|
||||||
@ -48,8 +46,7 @@ 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] =
|
const [selectedEstablishmentLogo, setSelectedEstablishmentLogoState] = useState(() => {
|
||||||
useState(() => {
|
|
||||||
const storedLogo = sessionStorage.getItem('selectedEstablishmentLogo');
|
const storedLogo = sessionStorage.getItem('selectedEstablishmentLogo');
|
||||||
return storedLogo ? JSON.parse(storedLogo) : null;
|
return storedLogo ? JSON.parse(storedLogo) : null;
|
||||||
});
|
});
|
||||||
@ -109,6 +106,8 @@ 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,
|
||||||
@ -118,19 +117,13 @@ export const EstablishmentProvider = ({ children }) => {
|
|||||||
role_id: i,
|
role_id: i,
|
||||||
role_type: role.role_type,
|
role_type: role.role_type,
|
||||||
}));
|
}));
|
||||||
let roleIndexDefault = 0;
|
setEstablishments(userEstablishments);
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// flushSync force React à commiter tous les setState de manière synchrone
|
|
||||||
// avant que endInitFunctionHandler (router.push) soit appelé.
|
|
||||||
// Sans ça, ProtectedRoute verrait user=null au premier rendu post-navigation.
|
|
||||||
flushSync(() => {
|
|
||||||
setUser(user);
|
|
||||||
setEstablishments(userEstablishments);
|
|
||||||
if (user.roles && user.roles.length > 0) {
|
|
||||||
setSelectedRoleId(roleIndexDefault);
|
setSelectedRoleId(roleIndexDefault);
|
||||||
if (userEstablishments.length > 0) {
|
if (userEstablishments.length > 0) {
|
||||||
setSelectedEstablishmentId(userEstablishments[roleIndexDefault].id);
|
setSelectedEstablishmentId(userEstablishments[roleIndexDefault].id);
|
||||||
@ -145,10 +138,6 @@ export const EstablishmentProvider = ({ children }) => {
|
|||||||
);
|
);
|
||||||
setProfileRole(userEstablishments[roleIndexDefault].role_type);
|
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);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
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';
|
||||||
|
|
||||||
@ -12,32 +13,19 @@ 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) => {
|
authorize: async (credentials, req) => {
|
||||||
// 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 res = await fetch(loginUrl, {
|
const data = {
|
||||||
method: 'POST',
|
|
||||||
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,
|
email: credentials.email,
|
||||||
password: credentials.password,
|
password: credentials.password,
|
||||||
}),
|
};
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
const user = await getJWT(data);
|
||||||
const body = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(body?.errorMessage || 'Identifiants invalides');
|
if (user) {
|
||||||
|
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');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -45,10 +33,8 @@ const options = {
|
|||||||
],
|
],
|
||||||
session: {
|
session: {
|
||||||
strategy: 'jwt',
|
strategy: 'jwt',
|
||||||
maxAge: 60 * 60, // 1 Hour
|
maxAge: 30 * 24 * 60 * 60, // 30 jours
|
||||||
// 0 = réécrire le cookie à chaque fois que le token change (indispensable avec
|
updateAge: 24 * 60 * 60, // 24 heures
|
||||||
// un access token Django de 15 min, sinon le cookie expiré reste en place)
|
|
||||||
updateAge: 0,
|
|
||||||
},
|
},
|
||||||
cookies: {
|
cookies: {
|
||||||
sessionToken: {
|
sessionToken: {
|
||||||
@ -78,61 +64,25 @@ const options = {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token Django expiré (lifetime = 15 min), essayer de le rafraîchir
|
// Token expiré, 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 res = await fetch(refreshUrl, {
|
const response = await refreshJWT({ refresh: token.refresh });
|
||||||
method: 'POST',
|
if (response && response?.token) {
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
// Connection: close évite le SocketError undici lié au keep-alive vers Daphne
|
|
||||||
Connection: 'close',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ refresh: token.refresh }),
|
|
||||||
});
|
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
...token,
|
...token,
|
||||||
token: response.token,
|
token: response.token,
|
||||||
refresh: response.refresh,
|
refresh: response.refresh,
|
||||||
tokenExpires: jwt_decode.decode(response.token).exp * 1000,
|
tokenExpires: jwt_decode.decode(response.token).exp * 1000,
|
||||||
error: undefined,
|
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to refresh token');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('JWT: refresh token failed', { message: error.message });
|
logger.error('Refresh token failed:', error);
|
||||||
return { ...token, error: 'RefreshTokenError' };
|
return token;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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);
|
||||||
|
|||||||
@ -1,149 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,145 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,211 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
85
JenkinsFile
85
JenkinsFile
@ -1,85 +0,0 @@
|
|||||||
pipeline {
|
|
||||||
agent any
|
|
||||||
|
|
||||||
environment {
|
|
||||||
DOCKER_REGISTRY = 'git.v0id.ovh'
|
|
||||||
ORGANIZATION = "n3wt-innov"
|
|
||||||
APP_NAME = 'n3wt-school'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Déclencher uniquement sur les tags
|
|
||||||
triggers {
|
|
||||||
issueCommentTrigger('.*deploy.*')
|
|
||||||
}
|
|
||||||
|
|
||||||
stages {
|
|
||||||
stage('Vérification du Tag') {
|
|
||||||
when {
|
|
||||||
expression { env.TAG_NAME != null }
|
|
||||||
}
|
|
||||||
steps {
|
|
||||||
script {
|
|
||||||
// Extraire la version du tag
|
|
||||||
env.VERSION = env.TAG_NAME
|
|
||||||
echo "Version détectée: ${env.VERSION}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Build Docker Images') {
|
|
||||||
when {
|
|
||||||
expression { env.TAG_NAME != null }
|
|
||||||
}
|
|
||||||
steps {
|
|
||||||
script {
|
|
||||||
// Donner les permissions d'exécution au script
|
|
||||||
sh 'chmod +x ./ci-scripts/makeDocker.sh'
|
|
||||||
|
|
||||||
// Exécuter le script avec la version
|
|
||||||
sh """
|
|
||||||
./ci-scripts/makeDocker.sh ${env.VERSION}
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Push sur Registry') {
|
|
||||||
when {
|
|
||||||
expression { env.TAG_NAME != null }
|
|
||||||
}
|
|
||||||
steps {
|
|
||||||
script {
|
|
||||||
withCredentials([usernamePassword(
|
|
||||||
credentialsId: 'docker-registry-credentials',
|
|
||||||
usernameVariable: 'REGISTRY_USER',
|
|
||||||
passwordVariable: 'REGISTRY_PASS'
|
|
||||||
)]) {
|
|
||||||
// Login au registry
|
|
||||||
sh "docker login ${DOCKER_REGISTRY} -u ${REGISTRY_USER} -p ${REGISTRY_PASS}"
|
|
||||||
|
|
||||||
// Push des images
|
|
||||||
sh """
|
|
||||||
docker push ${DOCKER_REGISTRY}/${ORGANIZATION}/${APP_NAME}/frontend:${env.VERSION}
|
|
||||||
docker push ${DOCKER_REGISTRY}/${ORGANIZATION}/${APP_NAME}/backend:${env.VERSION}
|
|
||||||
docker push ${DOCKER_REGISTRY}/${ORGANIZATION}/${APP_NAME}/frontend:latest
|
|
||||||
docker push ${DOCKER_REGISTRY}/${ORGANIZATION}/${APP_NAME}/backend:latest
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
post {
|
|
||||||
success {
|
|
||||||
echo "Build et push des images Docker réussis pour la version ${env.VERSION}"
|
|
||||||
}
|
|
||||||
failure {
|
|
||||||
echo "Échec du build ou du push des images Docker"
|
|
||||||
}
|
|
||||||
always {
|
|
||||||
// Nettoyage
|
|
||||||
sh 'docker system prune -f'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
27
README.md
27
README.md
@ -24,7 +24,7 @@ Maquette figma : https://www.figma.com/design/1BtWHIQlJDTeue2oYblefV/Maquette-Lo
|
|||||||
|
|
||||||
Lien de téléchargement : https://www.docker.com/get-started/
|
Lien de téléchargement : https://www.docker.com/get-started/
|
||||||
|
|
||||||
# Lancement de monteschool
|
# Lancement du projet
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@ -36,7 +36,7 @@ Lancement du front end
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
se connecter à localhost:8080
|
- se connecter à localhost:8080 pour le backend localhost:3000 pour le front
|
||||||
|
|
||||||
# Installation et développement en local
|
# Installation et développement en local
|
||||||
|
|
||||||
@ -57,25 +57,6 @@ npm i
|
|||||||
npm run format
|
npm run format
|
||||||
```
|
```
|
||||||
|
|
||||||
# Faire une livraison Mise en Production
|
# Mise en Production, Préparation de la release
|
||||||
|
|
||||||
```sh
|
- [MO_PREPARATION_MISE_EN_PROD](./docs/MEP/MO_PRE_MEP.md)
|
||||||
# Faire la première release (1.0.0)
|
|
||||||
npm run release -- --first-release
|
|
||||||
|
|
||||||
# Faire une prerelease (RC,alpha,beta)
|
|
||||||
npm run release -- --prerelease <name>
|
|
||||||
|
|
||||||
|
|
||||||
# Faire une release
|
|
||||||
npm run release
|
|
||||||
|
|
||||||
# Forcer la release sur un mode particulier (majeur, mineur ou patch)
|
|
||||||
# npm run script
|
|
||||||
npm run release -- --release-as minor
|
|
||||||
# Or
|
|
||||||
npm run release -- --release-as 1.1.0
|
|
||||||
|
|
||||||
# ignorer les hooks de commit lors de la release
|
|
||||||
npm run release -- --no-verify
|
|
||||||
```
|
|
||||||
|
|||||||
90
ci/build.Jenkinsfile
Normal file
90
ci/build.Jenkinsfile
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
pipeline {
|
||||||
|
|
||||||
|
agent {
|
||||||
|
label "SLAVE-N3WT"
|
||||||
|
}
|
||||||
|
|
||||||
|
options {
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
timestamps()
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
DOCKER_REGISTRY = "git.v0id.ovh"
|
||||||
|
ORG_NAME = "n3wt-innov"
|
||||||
|
APP_NAME = "n3wt-school"
|
||||||
|
|
||||||
|
IMAGE_FRONT = "${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/frontend"
|
||||||
|
IMAGE_BACK = "${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/backend"
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
|
||||||
|
stage("Check Tag") {
|
||||||
|
when {
|
||||||
|
not {
|
||||||
|
buildingTag()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
currentBuild.result = 'NOT_BUILT'
|
||||||
|
error("⚠️ Pipeline uniquement déclenchée sur les tags. Aucun tag détecté.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage("Build Docker Images") {
|
||||||
|
when {
|
||||||
|
buildingTag()
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
sh """
|
||||||
|
chmod +x ./ci/scripts/makeDocker.sh
|
||||||
|
./ci/scripts/makeDocker.sh ${TAG_NAME}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage("Push Images to Registry") {
|
||||||
|
when {
|
||||||
|
buildingTag()
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
withCredentials([usernamePassword(
|
||||||
|
credentialsId: "gitea-jenkins",
|
||||||
|
usernameVariable: "REGISTRY_USER",
|
||||||
|
passwordVariable: "REGISTRY_PASS"
|
||||||
|
)]) {
|
||||||
|
|
||||||
|
sh """
|
||||||
|
echo "Login registry..."
|
||||||
|
docker login ${DOCKER_REGISTRY} \
|
||||||
|
-u ${REGISTRY_USER} \
|
||||||
|
-p ${REGISTRY_PASS}
|
||||||
|
|
||||||
|
echo "Push version images..."
|
||||||
|
docker push ${IMAGE_FRONT}:${TAG_NAME}
|
||||||
|
docker push ${IMAGE_BACK}:${TAG_NAME}
|
||||||
|
|
||||||
|
echo "Tag latest..."
|
||||||
|
docker tag ${IMAGE_FRONT}:${TAG_NAME} ${IMAGE_FRONT}:latest
|
||||||
|
docker tag ${IMAGE_BACK}:${TAG_NAME} ${IMAGE_BACK}:latest
|
||||||
|
|
||||||
|
docker push ${IMAGE_FRONT}:latest
|
||||||
|
docker push ${IMAGE_BACK}:latest
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
sh """
|
||||||
|
docker builder prune -f
|
||||||
|
docker image prune -f
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user