mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-03 16:51:26 +00:00
Compare commits
17 Commits
26d4b5633f
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e50a0696f | |||
| 4248a589c5 | |||
| 7464b19de5 | |||
| c96b9562a2 | |||
| 7576b5a68c | |||
| e30a41a58b | |||
| c296af2c07 | |||
| fa843097ba | |||
| 2fef6d61a4 | |||
| 0501c1dd73 | |||
| 4f7d7d0024 | |||
| 8fd1b62ec0 | |||
| 3779a47417 | |||
| 05c68ebfaa | |||
| 195579e217 | |||
| ddcaba382e | |||
| a82483f3bd |
1
.github/copilot-instructions.md
vendored
1
.github/copilot-instructions.md
vendored
@ -56,3 +56,4 @@ Pour le front-end, les exigences de qualité sont les suivantes :
|
||||
|
||||
- **Tickets** : [issues guidelines](./instructions/issues.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
Normal file
53
.github/instructions/run-tests.instruction.md
vendored
Normal file
@ -0,0 +1,53 @@
|
||||
---
|
||||
applyTo: "**"
|
||||
---
|
||||
|
||||
# Lancer les tests – N3WT-SCHOOL
|
||||
|
||||
## Tests backend (Django)
|
||||
|
||||
Les tests backend tournent dans le conteneur Docker. Toujours utiliser `--settings=N3wtSchool.test_settings`.
|
||||
|
||||
```powershell
|
||||
# Tous les tests
|
||||
docker exec -it n3wt-school-backend-1 python3 manage.py test --settings=N3wtSchool.test_settings --verbosity=2
|
||||
|
||||
# Un module spécifique
|
||||
docker exec -it n3wt-school-backend-1 python3 manage.py test --settings=N3wtSchool.test_settings Auth.tests --verbosity=2
|
||||
```
|
||||
|
||||
### Points importants
|
||||
|
||||
- Le fichier `Back-End/N3wtSchool/test_settings.py` configure l'environnement de test :
|
||||
- Base PostgreSQL dédiée `school_test` (SQLite incompatible avec `ArrayField`)
|
||||
- Cache en mémoire locale (pas de Redis)
|
||||
- Channels en mémoire (`InMemoryChannelLayer`)
|
||||
- Throttling désactivé
|
||||
- Hashage MD5 (plus rapide)
|
||||
- Email en mode `locmem`
|
||||
- Si le conteneur n'est pas démarré : `docker compose up -d` depuis la racine du projet
|
||||
- Les logs `WARNING` dans la sortie des tests sont normaux (endpoints qui retournent 400/401 intentionnellement)
|
||||
|
||||
## Tests frontend (Jest)
|
||||
|
||||
```powershell
|
||||
# Depuis le dossier Front-End
|
||||
cd Front-End
|
||||
npm test -- --watchAll=false
|
||||
|
||||
# Avec couverture
|
||||
npm test -- --watchAll=false --coverage
|
||||
```
|
||||
|
||||
### Points importants
|
||||
|
||||
- Les tests sont dans `Front-End/src/test/`
|
||||
- Les warnings `ReactDOMTestUtils.act is deprecated` sont non bloquants (dépendance `@testing-library/react`)
|
||||
- Config Jest : `Front-End/jest.config.js`
|
||||
|
||||
## Résultats attendus
|
||||
|
||||
| Périmètre | Nb tests | Statut |
|
||||
| -------------- | -------- | ------ |
|
||||
| Backend Django | 121 | ✅ OK |
|
||||
| Frontend Jest | 24 | ✅ OK |
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -2,4 +2,5 @@
|
||||
.env
|
||||
node_modules/
|
||||
hardcoded-strings-report.md
|
||||
backend.env
|
||||
backend.env
|
||||
*.log
|
||||
@ -2,6 +2,12 @@ from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
from Auth.models import Profile
|
||||
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):
|
||||
def authenticate(self, request, username=None, password=None, **kwargs):
|
||||
@ -18,3 +24,45 @@ class EmailBackend(ModelBackend):
|
||||
except Profile.DoesNotExist:
|
||||
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 2025-11-30 11:02
|
||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
@ -24,7 +24,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('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)),
|
||||
('is_active', models.BooleanField(default=False)),
|
||||
('is_active', models.BooleanField(blank=True, default=False)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='profile_roles', to='Establishment.establishment')),
|
||||
],
|
||||
|
||||
553
Back-End/Auth/tests.py
Normal file
553
Back-End/Auth/tests.py
Normal file
@ -0,0 +1,553 @@
|
||||
"""
|
||||
Tests unitaires pour le module Auth.
|
||||
Vérifie :
|
||||
- L'accès public aux endpoints de login/CSRF/subscribe
|
||||
- La protection JWT des endpoints protégés (profils, rôles, session)
|
||||
- La génération et validation des tokens JWT
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile, ProfileRole
|
||||
from Establishment.models import Establishment
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_establishment():
|
||||
"""Crée un établissement minimal utilisé dans les tests."""
|
||||
return Establishment.objects.create(
|
||||
name="Ecole Test",
|
||||
address="1 rue de l'Ecole",
|
||||
total_capacity=100,
|
||||
establishment_type=[1],
|
||||
)
|
||||
|
||||
|
||||
def create_user(email="test@example.com", password="testpassword123"):
|
||||
"""Crée un utilisateur (Profile) de test."""
|
||||
user = Profile.objects.create_user(
|
||||
username=email,
|
||||
email=email,
|
||||
password=password,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def create_active_user_with_role(email="active@example.com", password="testpassword123"):
|
||||
"""Crée un utilisateur avec un rôle actif."""
|
||||
user = create_user(email=email, password=password)
|
||||
establishment = create_establishment()
|
||||
ProfileRole.objects.create(
|
||||
profile=user,
|
||||
role_type=ProfileRole.RoleType.PROFIL_ADMIN,
|
||||
establishment=establishment,
|
||||
is_active=True,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def get_jwt_token(user):
|
||||
"""Retourne un token d'accès JWT pour l'utilisateur donné."""
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return str(refresh.access_token)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests endpoints publics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
class CsrfEndpointTest(TestCase):
|
||||
"""Test de l'endpoint CSRF – doit être accessible sans authentification."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
|
||||
def test_csrf_endpoint_accessible_sans_auth(self):
|
||||
"""GET /Auth/csrf doit retourner 200 sans token."""
|
||||
response = self.client.get(reverse("Auth:csrf"))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn("csrfToken", response.json())
|
||||
|
||||
|
||||
@override_settings(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
class LoginEndpointTest(TestCase):
|
||||
"""Tests de l'endpoint de connexion."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse("Auth:login")
|
||||
self.user = create_active_user_with_role(
|
||||
email="logintest@example.com", password="secureP@ss1"
|
||||
)
|
||||
|
||||
def test_login_avec_identifiants_valides(self):
|
||||
"""POST /Auth/login avec identifiants valides retourne 200 et un token."""
|
||||
payload = {"email": "logintest@example.com", "password": "secureP@ss1"}
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload), content_type="application/json"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertIn("token", data)
|
||||
self.assertIn("refresh", data)
|
||||
|
||||
def test_login_avec_mauvais_mot_de_passe(self):
|
||||
"""POST /Auth/login avec mauvais mot de passe retourne 400 ou 401."""
|
||||
payload = {"email": "logintest@example.com", "password": "wrongpassword"}
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload), content_type="application/json"
|
||||
)
|
||||
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
|
||||
|
||||
def test_login_avec_email_inexistant(self):
|
||||
"""POST /Auth/login avec email inconnu retourne 400 ou 401."""
|
||||
payload = {"email": "unknown@example.com", "password": "anypassword"}
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload), content_type="application/json"
|
||||
)
|
||||
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
|
||||
|
||||
def test_login_accessible_sans_authentification(self):
|
||||
"""L'endpoint de login doit être accessible sans token JWT."""
|
||||
# On vérifie juste que l'on n'obtient pas 401/403 pour raison d'auth manquante
|
||||
payload = {"email": "logintest@example.com", "password": "secureP@ss1"}
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload), content_type="application/json"
|
||||
)
|
||||
self.assertNotEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
@override_settings(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
class RefreshJWTEndpointTest(TestCase):
|
||||
"""Tests de l'endpoint de rafraîchissement du token JWT."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse("Auth:refresh_jwt")
|
||||
self.user = create_active_user_with_role(email="refresh@example.com")
|
||||
|
||||
def test_refresh_avec_token_valide(self):
|
||||
"""POST /Auth/refreshJWT avec refresh token valide retourne un nouvel access token."""
|
||||
import jwt, uuid
|
||||
from datetime import datetime, timedelta
|
||||
from django.conf import settings as django_settings
|
||||
# RefreshJWTView attend le format custom (type='refresh'), pas le format SimpleJWT
|
||||
refresh_payload = {
|
||||
'user_id': self.user.id,
|
||||
'type': 'refresh',
|
||||
'jti': str(uuid.uuid4()),
|
||||
'exp': datetime.utcnow() + timedelta(days=1),
|
||||
'iat': datetime.utcnow(),
|
||||
}
|
||||
custom_refresh = jwt.encode(
|
||||
refresh_payload,
|
||||
django_settings.SIMPLE_JWT['SIGNING_KEY'],
|
||||
algorithm=django_settings.SIMPLE_JWT['ALGORITHM'],
|
||||
)
|
||||
payload = {"refresh": custom_refresh}
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload), content_type="application/json"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn("token", response.json())
|
||||
|
||||
def test_refresh_avec_token_invalide(self):
|
||||
"""POST /Auth/refreshJWT avec token invalide retourne 401."""
|
||||
payload = {"refresh": "invalid.token.here"}
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload), content_type="application/json"
|
||||
)
|
||||
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
|
||||
|
||||
def test_refresh_accessible_sans_authentification(self):
|
||||
"""L'endpoint de refresh doit être accessible sans token d'accès."""
|
||||
refresh = RefreshToken.for_user(self.user)
|
||||
payload = {"refresh": str(refresh)}
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload), content_type="application/json"
|
||||
)
|
||||
self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests endpoints protégés – Session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
class SessionEndpointTest(TestCase):
|
||||
"""Tests de l'endpoint d'information de session."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse("Auth:infoSession")
|
||||
self.user = create_active_user_with_role(email="session@example.com")
|
||||
|
||||
def test_info_session_sans_token_retourne_401(self):
|
||||
"""GET /Auth/infoSession sans token doit retourner 401."""
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_info_session_avec_token_valide_retourne_200(self):
|
||||
"""GET /Auth/infoSession avec token valide doit retourner 200 et les données utilisateur."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertIn("user", data)
|
||||
self.assertEqual(data["user"]["email"], self.user.email)
|
||||
|
||||
def test_info_session_avec_token_invalide_retourne_401(self):
|
||||
"""GET /Auth/infoSession avec token invalide doit retourner 401."""
|
||||
self.client.credentials(HTTP_AUTHORIZATION="Bearer token.invalide.xyz")
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_info_session_avec_token_expire_retourne_401(self):
|
||||
"""GET /Auth/infoSession avec un token expiré doit retourner 401."""
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
expired_payload = {
|
||||
'user_id': self.user.id,
|
||||
'exp': datetime.utcnow() - timedelta(hours=1),
|
||||
}
|
||||
expired_token = jwt.encode(
|
||||
expired_payload, django_settings.SECRET_KEY, algorithm='HS256'
|
||||
)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {expired_token}")
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests endpoints protégés – Profils
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
REST_FRAMEWORK={
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
),
|
||||
},
|
||||
)
|
||||
class ProfileEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints de profils."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.profiles_url = reverse("Auth:profile")
|
||||
self.user = create_active_user_with_role(email="profile_auth@example.com")
|
||||
|
||||
def test_get_profiles_sans_auth_retourne_401(self):
|
||||
"""GET /Auth/profiles sans token doit retourner 401."""
|
||||
response = self.client.get(self.profiles_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_profiles_avec_auth_retourne_200(self):
|
||||
"""GET /Auth/profiles avec token valide doit retourner 200."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = self.client.get(self.profiles_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_post_profile_sans_auth_retourne_401(self):
|
||||
"""POST /Auth/profiles sans token doit retourner 401."""
|
||||
payload = {"email": "new@example.com", "password": "pass123"}
|
||||
response = self.client.post(
|
||||
self.profiles_url,
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_profile_par_id_sans_auth_retourne_401(self):
|
||||
"""GET /Auth/profiles/{id} sans token doit retourner 401."""
|
||||
url = reverse("Auth:profile", kwargs={"id": self.user.id})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_put_profile_sans_auth_retourne_401(self):
|
||||
"""PUT /Auth/profiles/{id} sans token doit retourner 401."""
|
||||
url = reverse("Auth:profile", kwargs={"id": self.user.id})
|
||||
payload = {"email": self.user.email}
|
||||
response = self.client.put(
|
||||
url, data=json.dumps(payload), content_type="application/json"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_delete_profile_sans_auth_retourne_401(self):
|
||||
"""DELETE /Auth/profiles/{id} sans token doit retourner 401."""
|
||||
url = reverse("Auth:profile", kwargs={"id": self.user.id})
|
||||
response = self.client.delete(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests endpoints protégés – ProfileRole
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
REST_FRAMEWORK={
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
),
|
||||
},
|
||||
)
|
||||
class ProfileRoleEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints de rôles."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.profile_roles_url = reverse("Auth:profileRoles")
|
||||
self.user = create_active_user_with_role(email="roles_auth@example.com")
|
||||
|
||||
def test_get_profile_roles_sans_auth_retourne_401(self):
|
||||
"""GET /Auth/profileRoles sans token doit retourner 401."""
|
||||
response = self.client.get(self.profile_roles_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_profile_roles_avec_auth_retourne_200(self):
|
||||
"""GET /Auth/profileRoles avec token valide doit retourner 200."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = self.client.get(self.profile_roles_url)
|
||||
self.assertNotIn(
|
||||
response.status_code,
|
||||
[status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN],
|
||||
msg="Un token valide ne doit pas être rejeté par la couche d'authentification",
|
||||
)
|
||||
|
||||
def test_post_profile_role_sans_auth_retourne_401(self):
|
||||
"""POST /Auth/profileRoles sans token doit retourner 401."""
|
||||
payload = {"profile": self.user.id, "role_type": 1}
|
||||
response = self.client.post(
|
||||
self.profile_roles_url,
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests de génération de token JWT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
class JWTTokenGenerationTest(TestCase):
|
||||
"""Tests de génération et validation des tokens JWT."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = create_user(email="jwt@example.com", password="jwttest123")
|
||||
|
||||
def test_generation_token_valide(self):
|
||||
"""Un token généré pour un utilisateur est valide et contient user_id."""
|
||||
import jwt
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
token = get_jwt_token(self.user)
|
||||
self.assertIsNotNone(token)
|
||||
self.assertIsInstance(token, str)
|
||||
decoded = jwt.decode(token, django_settings.SECRET_KEY, algorithms=["HS256"])
|
||||
self.assertEqual(decoded["user_id"], self.user.id)
|
||||
|
||||
def test_refresh_token_permet_obtenir_nouvel_access_token(self):
|
||||
"""Le refresh token permet d'obtenir un nouvel access token via SimpleJWT."""
|
||||
refresh = RefreshToken.for_user(self.user)
|
||||
access = refresh.access_token
|
||||
self.assertIsNotNone(str(access))
|
||||
self.assertIsNotNone(str(refresh))
|
||||
|
||||
def test_token_different_par_utilisateur(self):
|
||||
"""Deux utilisateurs différents ont des tokens différents."""
|
||||
user2 = create_user(email="jwt2@example.com", password="jwttest123")
|
||||
token1 = get_jwt_token(self.user)
|
||||
token2 = get_jwt_token(user2)
|
||||
self.assertNotEqual(token1, token2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests de sécurité — Correction des vulnérabilités identifiées
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
class SessionViewTokenTypeTest(TestCase):
|
||||
"""
|
||||
SessionView doit rejeter les refresh tokens.
|
||||
Avant la correction, jwt.decode() était appelé sans vérification du claim 'type',
|
||||
ce qui permettait d'utiliser un refresh token là où seul un access token est attendu.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse("Auth:infoSession")
|
||||
self.user = create_active_user_with_role(email="session_type@example.com")
|
||||
|
||||
def test_refresh_token_rejete_par_session_view(self):
|
||||
"""
|
||||
Utiliser un refresh token SimpleJWT sur /infoSession doit retourner 401.
|
||||
"""
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
# Fabriquer manuellement un token de type 'refresh' signé avec la clé correcte
|
||||
refresh_payload = {
|
||||
'user_id': self.user.id,
|
||||
'type': 'refresh', # ← type incorrect pour cet endpoint
|
||||
'jti': 'test-refresh-jti',
|
||||
'exp': datetime.utcnow() + timedelta(days=1),
|
||||
'iat': datetime.utcnow(),
|
||||
}
|
||||
refresh_token = jwt.encode(
|
||||
refresh_payload, django_settings.SECRET_KEY, algorithm='HS256'
|
||||
)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {refresh_token}")
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(
|
||||
response.status_code, status.HTTP_401_UNAUTHORIZED,
|
||||
"Un refresh token ne doit pas être accepté sur /infoSession (OWASP A07 - Auth Failures)"
|
||||
)
|
||||
|
||||
def test_access_token_accepte_par_session_view(self):
|
||||
"""Un access token de type 'access' est accepté."""
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
access_payload = {
|
||||
'user_id': self.user.id,
|
||||
'type': 'access',
|
||||
'jti': 'test-access-jti',
|
||||
'exp': datetime.utcnow() + timedelta(minutes=15),
|
||||
'iat': datetime.utcnow(),
|
||||
}
|
||||
access_token = jwt.encode(
|
||||
access_payload, django_settings.SECRET_KEY, algorithm='HS256'
|
||||
)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}")
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
@override_settings(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
class RefreshJWTErrorLeakTest(TestCase):
|
||||
"""
|
||||
RefreshJWTView ne doit pas retourner les messages d'exception internes.
|
||||
Avant la correction, str(e) était renvoyé directement au client.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse("Auth:refresh_jwt")
|
||||
|
||||
def test_token_invalide_ne_revele_pas_details_internes(self):
|
||||
"""
|
||||
Un token invalide doit retourner un message générique, pas les détails de l'exception.
|
||||
"""
|
||||
payload = {"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.forged.signature"}
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload), content_type="application/json"
|
||||
)
|
||||
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
|
||||
body = response.content.decode()
|
||||
# Le message ne doit pas contenir de traceback ou de détails internes de bibliothèque
|
||||
self.assertNotIn("Traceback", body)
|
||||
self.assertNotIn("jwt.exceptions", body)
|
||||
self.assertNotIn("simplejwt", body.lower())
|
||||
|
||||
def test_erreur_reponse_est_generique(self):
|
||||
"""
|
||||
Le message d'erreur doit être 'Token invalide' (générique), pas le str(e).
|
||||
"""
|
||||
payload = {"refresh": "bad.token.data"}
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload), content_type="application/json"
|
||||
)
|
||||
data = response.json()
|
||||
self.assertIn('errorMessage', data)
|
||||
# Le message doit être le message générique, pas la chaîne brute de l'exception
|
||||
self.assertIn(data['errorMessage'], ['Token invalide', 'Format de token invalide',
|
||||
'Refresh token expiré', 'Erreur interne du serveur'])
|
||||
|
||||
|
||||
@override_settings(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
class SecurityHeadersTest(TestCase):
|
||||
"""
|
||||
Les en-têtes de sécurité HTTP doivent être présents dans toutes les réponses.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
|
||||
def test_x_content_type_options_present(self):
|
||||
"""X-Content-Type-Options: nosniff doit être présent."""
|
||||
response = self.client.get(reverse("Auth:csrf"))
|
||||
self.assertEqual(
|
||||
response.get('X-Content-Type-Options'), 'nosniff',
|
||||
"X-Content-Type-Options: nosniff doit être défini (prévient le MIME sniffing)"
|
||||
)
|
||||
|
||||
def test_referrer_policy_present(self):
|
||||
"""Referrer-Policy doit être présent."""
|
||||
response = self.client.get(reverse("Auth:csrf"))
|
||||
self.assertIsNotNone(
|
||||
response.get('Referrer-Policy'),
|
||||
"Referrer-Policy doit être défini"
|
||||
)
|
||||
|
||||
def test_csp_frame_ancestors_present(self):
|
||||
"""Content-Security-Policy doit contenir frame-ancestors."""
|
||||
response = self.client.get(reverse("Auth:csrf"))
|
||||
csp = response.get('Content-Security-Policy', '')
|
||||
self.assertIn('frame-ancestors', csp,
|
||||
"CSP doit définir frame-ancestors (protection clickjacking)")
|
||||
self.assertIn("object-src 'none'", csp,
|
||||
"CSP doit définir object-src 'none' (prévient les plugins malveillants)")
|
||||
|
||||
@ -17,10 +17,12 @@ from datetime import datetime, timedelta
|
||||
import jwt
|
||||
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from . import validator
|
||||
from .models import Profile, ProfileRole
|
||||
from rest_framework.decorators import action, api_view
|
||||
from rest_framework.decorators import action, api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from django.db.models import Q
|
||||
|
||||
from Auth.serializers import ProfileSerializer, ProfileRoleSerializer
|
||||
@ -28,13 +30,28 @@ from Subscriptions.models import RegistrationForm, Guardian
|
||||
import N3wtSchool.mailManager as mailer
|
||||
import Subscriptions.util as util
|
||||
import logging
|
||||
from N3wtSchool import bdd, error, settings
|
||||
from N3wtSchool import bdd, error
|
||||
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
|
||||
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(
|
||||
method='get',
|
||||
operation_description="Obtenir un token CSRF",
|
||||
@ -43,11 +60,15 @@ logger = logging.getLogger("AuthViews")
|
||||
}))}
|
||||
)
|
||||
@api_view(['GET'])
|
||||
@permission_classes([AllowAny])
|
||||
def csrf(request):
|
||||
token = get_token(request)
|
||||
return JsonResponse({'csrfToken': token})
|
||||
|
||||
class SessionView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = [] # SessionView gère sa propre validation JWT
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Vérifier une session utilisateur",
|
||||
manual_parameters=[openapi.Parameter('Authorization', openapi.IN_HEADER, type=openapi.TYPE_STRING, description='Bearer token')],
|
||||
@ -70,6 +91,11 @@ class SessionView(APIView):
|
||||
token = request.META.get('HTTP_AUTHORIZATION', '').split('Bearer ')[-1]
|
||||
try:
|
||||
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')
|
||||
user = Profile.objects.get(id=userid)
|
||||
roles = ProfileRole.objects.filter(profile=user).values('role_type', 'establishment__name')
|
||||
@ -88,6 +114,8 @@ class SessionView(APIView):
|
||||
return JsonResponse({"error": "Invalid token"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
class ProfileView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Obtenir la liste des profils",
|
||||
responses={200: ProfileSerializer(many=True)}
|
||||
@ -118,6 +146,8 @@ class ProfileView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class ProfileSimpleView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Obtenir un profil par son ID",
|
||||
responses={200: ProfileSerializer}
|
||||
@ -152,8 +182,12 @@ class ProfileSimpleView(APIView):
|
||||
def delete(self, request, id):
|
||||
return bdd.delete_object(Profile, id)
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class LoginView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
throttle_classes = [LoginRateThrottle]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Connexion utilisateur",
|
||||
request_body=openapi.Schema(
|
||||
@ -240,12 +274,14 @@ def makeToken(user):
|
||||
})
|
||||
|
||||
# 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 = {
|
||||
'user_id': user.id,
|
||||
'email': user.email,
|
||||
'roleIndexLoginDefault': user.roleIndexLoginDefault,
|
||||
'roles': roles,
|
||||
'type': 'access',
|
||||
'jti': str(uuid.uuid4()),
|
||||
'exp': datetime.utcnow() + settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'],
|
||||
'iat': datetime.utcnow(),
|
||||
}
|
||||
@ -255,16 +291,23 @@ def makeToken(user):
|
||||
refresh_payload = {
|
||||
'user_id': user.id,
|
||||
'type': 'refresh',
|
||||
'jti': str(uuid.uuid4()),
|
||||
'exp': datetime.utcnow() + settings.SIMPLE_JWT['REFRESH_TOKEN_LIFETIME'],
|
||||
'iat': datetime.utcnow(),
|
||||
}
|
||||
refresh_token = jwt.encode(refresh_payload, settings.SIMPLE_JWT['SIGNING_KEY'], algorithm=settings.SIMPLE_JWT['ALGORITHM'])
|
||||
return access_token, refresh_token
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la création du token: {str(e)}")
|
||||
return None
|
||||
logger.error(f"Erreur lors de la création du token: {str(e)}", exc_info=True)
|
||||
# On lève l'exception pour que l'appelant (LoginView / RefreshJWTView)
|
||||
# retourne une erreur HTTP 500 explicite plutôt que de crasher silencieusement
|
||||
# sur le unpack d'un None.
|
||||
raise
|
||||
|
||||
class RefreshJWTView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
throttle_classes = [LoginRateThrottle]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Rafraîchir le token d'accès",
|
||||
request_body=openapi.Schema(
|
||||
@ -290,7 +333,6 @@ class RefreshJWTView(APIView):
|
||||
))
|
||||
}
|
||||
)
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
def post(self, request):
|
||||
data = JSONParser().parse(request)
|
||||
refresh_token = data.get("refresh")
|
||||
@ -335,14 +377,16 @@ class RefreshJWTView(APIView):
|
||||
return JsonResponse({'errorMessage': 'Refresh token expiré'}, status=400)
|
||||
except InvalidTokenError as e:
|
||||
logger.error(f"Token invalide: {str(e)}")
|
||||
return JsonResponse({'errorMessage': f'Token invalide: {str(e)}'}, status=400)
|
||||
return JsonResponse({'errorMessage': 'Token invalide'}, status=400)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur inattendue: {str(e)}")
|
||||
return JsonResponse({'errorMessage': f'Erreur inattendue: {str(e)}'}, status=400)
|
||||
logger.error(f"Erreur inattendue: {str(e)}", exc_info=True)
|
||||
return JsonResponse({'errorMessage': 'Erreur interne du serveur'}, status=500)
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class SubscribeView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Inscription utilisateur",
|
||||
manual_parameters=[
|
||||
@ -430,6 +474,8 @@ class SubscribeView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class NewPasswordView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Demande de nouveau mot de passe",
|
||||
request_body=openapi.Schema(
|
||||
@ -479,6 +525,8 @@ class NewPasswordView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class ResetPasswordView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Réinitialisation du mot de passe",
|
||||
request_body=openapi.Schema(
|
||||
@ -525,7 +573,9 @@ class ResetPasswordView(APIView):
|
||||
return JsonResponse({'message': retour, "errorMessage": retourErreur, "errorFields": errorFields}, safe=False)
|
||||
|
||||
class ProfileRoleView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
pagination_class = CustomProfilesPagination
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Obtenir la liste des profile_roles",
|
||||
responses={200: ProfileRoleSerializer(many=True)}
|
||||
@ -596,6 +646,8 @@ class ProfileRoleView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class ProfileRoleSimpleView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Obtenir un profile_role par son ID",
|
||||
responses={200: ProfileRoleSerializer}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
@ -1,3 +1,145 @@
|
||||
from django.test import TestCase
|
||||
"""
|
||||
Tests unitaires pour le module Common.
|
||||
Vérifie que les endpoints Domain et Category requièrent une authentification JWT.
|
||||
"""
|
||||
|
||||
# Create your tests here.
|
||||
import json
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile
|
||||
|
||||
|
||||
def create_user(email="common_test@example.com", password="testpassword123"):
|
||||
return Profile.objects.create_user(username=email, email=email, password=password)
|
||||
|
||||
|
||||
def get_jwt_token(user):
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return str(refresh.access_token)
|
||||
|
||||
|
||||
TEST_REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
),
|
||||
}
|
||||
|
||||
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(
|
||||
CACHES=TEST_CACHES,
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
|
||||
)
|
||||
class DomainEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints Domain."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.list_url = reverse("Common:domain_list_create")
|
||||
self.user = create_user()
|
||||
|
||||
def test_get_domains_sans_auth_retourne_401(self):
|
||||
"""GET /Common/domains sans token doit retourner 401."""
|
||||
response = self.client.get(self.list_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_post_domain_sans_auth_retourne_401(self):
|
||||
"""POST /Common/domains sans token doit retourner 401."""
|
||||
response = self.client.post(
|
||||
self.list_url,
|
||||
data=json.dumps({"name": "Musique"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_domains_avec_auth_retourne_200(self):
|
||||
"""GET /Common/domains avec token valide doit retourner 200."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = self.client.get(self.list_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_put_domain_sans_auth_retourne_401(self):
|
||||
"""PUT /Common/domains/{id} sans token doit retourner 401."""
|
||||
url = reverse("Common:domain_detail", kwargs={"id": 1})
|
||||
response = self.client.put(
|
||||
url,
|
||||
data=json.dumps({"name": "Danse"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_delete_domain_sans_auth_retourne_401(self):
|
||||
"""DELETE /Common/domains/{id} sans token doit retourner 401."""
|
||||
url = reverse("Common:domain_detail", kwargs={"id": 1})
|
||||
response = self.client.delete(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Category
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(
|
||||
CACHES=TEST_CACHES,
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
|
||||
)
|
||||
class CategoryEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints Category."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.list_url = reverse("Common:category_list_create")
|
||||
self.user = create_user(email="category_test@example.com")
|
||||
|
||||
def test_get_categories_sans_auth_retourne_401(self):
|
||||
"""GET /Common/categories sans token doit retourner 401."""
|
||||
response = self.client.get(self.list_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_post_category_sans_auth_retourne_401(self):
|
||||
"""POST /Common/categories sans token doit retourner 401."""
|
||||
response = self.client.post(
|
||||
self.list_url,
|
||||
data=json.dumps({"name": "Jazz"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_categories_avec_auth_retourne_200(self):
|
||||
"""GET /Common/categories avec token valide doit retourner 200."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = self.client.get(self.list_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_put_category_sans_auth_retourne_401(self):
|
||||
"""PUT /Common/categories/{id} sans token doit retourner 401."""
|
||||
url = reverse("Common:category_detail", kwargs={"id": 1})
|
||||
response = self.client.put(
|
||||
url,
|
||||
data=json.dumps({"name": "Classique"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_delete_category_sans_auth_retourne_401(self):
|
||||
"""DELETE /Common/categories/{id} sans token doit retourner 401."""
|
||||
url = reverse("Common:category_detail", kwargs={"id": 1})
|
||||
response = self.client.delete(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
@ -4,18 +4,21 @@ from django.utils.decorators import method_decorator
|
||||
from rest_framework.parsers import JSONParser
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from .models import (
|
||||
Domain,
|
||||
Domain,
|
||||
Category
|
||||
)
|
||||
from .serializers import (
|
||||
DomainSerializer,
|
||||
DomainSerializer,
|
||||
CategorySerializer
|
||||
)
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class DomainListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
domains = Domain.objects.all()
|
||||
serializer = DomainSerializer(domains, many=True)
|
||||
@ -32,6 +35,8 @@ class DomainListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class DomainDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
try:
|
||||
domain = Domain.objects.get(id=id)
|
||||
@ -65,6 +70,8 @@ class DomainDetailView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class CategoryListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
categories = Category.objects.all()
|
||||
serializer = CategorySerializer(categories, many=True)
|
||||
@ -81,6 +88,8 @@ class CategoryListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class CategoryDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
try:
|
||||
category = Category.objects.get(id=id)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
||||
|
||||
import Establishment.models
|
||||
import django.contrib.postgres.fields
|
||||
|
||||
92
Back-End/Establishment/tests.py
Normal file
92
Back-End/Establishment/tests.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""
|
||||
Tests unitaires pour le module Establishment.
|
||||
Vérifie que les endpoints requièrent une authentification JWT.
|
||||
"""
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile
|
||||
|
||||
|
||||
def create_user(email="establishment_test@example.com", password="testpassword123"):
|
||||
return Profile.objects.create_user(username=email, email=email, password=password)
|
||||
|
||||
|
||||
def get_jwt_token(user):
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return str(refresh.access_token)
|
||||
|
||||
|
||||
TEST_REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
),
|
||||
}
|
||||
|
||||
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
|
||||
|
||||
|
||||
@override_settings(
|
||||
CACHES=TEST_CACHES,
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
|
||||
)
|
||||
class EstablishmentEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints Establishment."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.list_url = reverse("Establishment:establishment_list_create")
|
||||
self.user = create_user()
|
||||
|
||||
def test_get_establishments_sans_auth_retourne_401(self):
|
||||
"""GET /Establishment/establishments sans token doit retourner 401."""
|
||||
response = self.client.get(self.list_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_post_establishment_sans_auth_retourne_401(self):
|
||||
"""POST /Establishment/establishments sans token doit retourner 401."""
|
||||
import json
|
||||
response = self.client.post(
|
||||
self.list_url,
|
||||
data=json.dumps({"name": "Ecole Alpha"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_establishment_detail_sans_auth_retourne_401(self):
|
||||
"""GET /Establishment/establishments/{id} sans token doit retourner 401."""
|
||||
url = reverse("Establishment:establishment_detail", kwargs={"id": 1})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_put_establishment_sans_auth_retourne_401(self):
|
||||
"""PUT /Establishment/establishments/{id} sans token doit retourner 401."""
|
||||
import json
|
||||
url = reverse("Establishment:establishment_detail", kwargs={"id": 1})
|
||||
response = self.client.put(
|
||||
url,
|
||||
data=json.dumps({"name": "Ecole Beta"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_delete_establishment_sans_auth_retourne_401(self):
|
||||
"""DELETE /Establishment/establishments/{id} sans token doit retourner 401."""
|
||||
url = reverse("Establishment:establishment_detail", kwargs={"id": 1})
|
||||
response = self.client.delete(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_establishments_avec_auth_retourne_200(self):
|
||||
"""GET /Establishment/establishments avec token valide doit retourner 200."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = self.client.get(self.list_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@ -4,6 +4,7 @@ from django.utils.decorators import method_decorator
|
||||
from rest_framework.parsers import JSONParser, MultiPartParser, FormParser
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated, BasePermission
|
||||
from .models import Establishment
|
||||
from .serializers import EstablishmentSerializer
|
||||
from N3wtSchool.bdd import delete_object, getAllObjects, getObject
|
||||
@ -15,9 +16,29 @@ import N3wtSchool.mailManager as mailer
|
||||
import os
|
||||
from N3wtSchool import settings
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
|
||||
class IsWebhookApiKey(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
api_key = settings.WEBHOOK_API_KEY
|
||||
if not api_key:
|
||||
return False
|
||||
return request.headers.get('X-API-Key') == api_key
|
||||
|
||||
|
||||
class IsAuthenticatedOrWebhookApiKey(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if request.user and request.user.is_authenticated:
|
||||
return True
|
||||
return IsWebhookApiKey().has_permission(request, view)
|
||||
|
||||
|
||||
class EstablishmentListCreateView(APIView):
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method == 'POST':
|
||||
return [IsAuthenticatedOrWebhookApiKey()]
|
||||
return [IsAuthenticated()]
|
||||
|
||||
def get(self, request):
|
||||
establishments = getAllObjects(Establishment)
|
||||
establishments_serializer = EstablishmentSerializer(establishments, many=True)
|
||||
@ -44,6 +65,7 @@ class EstablishmentListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class EstablishmentDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def get(self, request, id=None):
|
||||
@ -87,7 +109,9 @@ def create_establishment_with_directeur(establishment_data):
|
||||
directeur_email = directeur_data.get("email")
|
||||
last_name = directeur_data.get("last_name", "")
|
||||
first_name = directeur_data.get("first_name", "")
|
||||
password = directeur_data.get("password", "Provisoire01!")
|
||||
password = directeur_data.get("password")
|
||||
if not password:
|
||||
raise ValueError("Le champ 'directeur.password' est obligatoire pour créer un établissement.")
|
||||
|
||||
# Création ou récupération du profil utilisateur
|
||||
profile, created = Profile.objects.get_or_create(
|
||||
|
||||
116
Back-End/GestionEmail/tests_security.py
Normal file
116
Back-End/GestionEmail/tests_security.py
Normal file
@ -0,0 +1,116 @@
|
||||
"""
|
||||
Tests de sécurité — GestionEmail
|
||||
Vérifie :
|
||||
- search_recipients nécessite une authentification (plus accessible anonymement)
|
||||
- send-email nécessite une authentification
|
||||
- Les données personnelles ne sont pas dans les logs INFO
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile, ProfileRole
|
||||
from Establishment.models import Establishment
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_user_with_role(email, password="TestPass!123"):
|
||||
user = Profile.objects.create_user(
|
||||
username=email, email=email, password=password
|
||||
)
|
||||
est = Establishment.objects.create(
|
||||
name=f"Ecole {email}",
|
||||
address="1 rue Test",
|
||||
total_capacity=50,
|
||||
establishment_type=[1],
|
||||
)
|
||||
ProfileRole.objects.create(
|
||||
profile=user,
|
||||
role_type=ProfileRole.RoleType.PROFIL_ECOLE,
|
||||
establishment=est,
|
||||
is_active=True,
|
||||
)
|
||||
return user, est
|
||||
|
||||
|
||||
OVERRIDE = dict(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests : search_recipients exige une authentification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class SearchRecipientsAuthTest(TestCase):
|
||||
"""
|
||||
GET /email/search-recipients/ doit retourner 401 si non authentifié.
|
||||
Avant la correction, cet endpoint était accessible anonymement
|
||||
(harvesting d'emails des membres d'un établissement).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse('GestionEmail:search_recipients')
|
||||
|
||||
def test_sans_auth_retourne_401(self):
|
||||
"""Accès anonyme doit être rejeté avec 401."""
|
||||
response = self.client.get(self.url, {'q': 'test', 'establishment_id': 1})
|
||||
self.assertEqual(
|
||||
response.status_code, status.HTTP_401_UNAUTHORIZED,
|
||||
"search_recipients doit exiger une authentification (OWASP A01 - Broken Access Control)"
|
||||
)
|
||||
|
||||
def test_avec_auth_et_query_vide_retourne_200_ou_liste_vide(self):
|
||||
"""Un utilisateur authentifié sans terme de recherche reçoit une liste vide."""
|
||||
user, est = create_user_with_role('search_auth@test.com')
|
||||
token = str(RefreshToken.for_user(user).access_token)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
response = self.client.get(self.url, {'q': '', 'establishment_id': est.id})
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
|
||||
|
||||
def test_avec_auth_et_establishment_manquant_retourne_400(self):
|
||||
"""Un utilisateur authentifié sans establishment_id reçoit 400."""
|
||||
user, _ = create_user_with_role('search_noest@test.com')
|
||||
token = str(RefreshToken.for_user(user).access_token)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
response = self.client.get(self.url, {'q': 'alice'})
|
||||
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests : send-email exige une authentification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class SendEmailAuthTest(TestCase):
|
||||
"""
|
||||
POST /email/send-email/ doit retourner 401 si non authentifié.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse('GestionEmail:send_email')
|
||||
|
||||
def test_sans_auth_retourne_401(self):
|
||||
"""Accès anonyme à l'envoi d'email doit être rejeté."""
|
||||
payload = {
|
||||
'recipients': ['victim@example.com'],
|
||||
'subject': 'Test',
|
||||
'message': 'Hello',
|
||||
'establishment_id': 1,
|
||||
}
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload), content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
@ -2,6 +2,8 @@ from django.http.response import JsonResponse
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
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 Auth.models import Profile, ProfileRole
|
||||
|
||||
@ -20,9 +22,11 @@ class SendEmailView(APIView):
|
||||
"""
|
||||
API pour envoyer des emails aux parents et professeurs.
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
# Ajouter du debug
|
||||
logger.info(f"Request data received: {request.data}")
|
||||
logger.info(f"Request data received (keys): {list(request.data.keys()) if request.data else []}") # Ne pas logger les valeurs (RGPD)
|
||||
logger.info(f"Request content type: {request.content_type}")
|
||||
|
||||
data = request.data
|
||||
@ -34,11 +38,9 @@ class SendEmailView(APIView):
|
||||
establishment_id = data.get('establishment_id', '')
|
||||
|
||||
# Debug des données reçues
|
||||
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"Recipients (count): {len(recipients)}")
|
||||
logger.info(f"Subject: {subject}")
|
||||
logger.info(f"Message length: {len(message) if message else 0}")
|
||||
logger.debug(f"Message length: {len(message) if message else 0}")
|
||||
logger.info(f"Establishment ID: {establishment_id}")
|
||||
|
||||
if not recipients or not message:
|
||||
@ -70,12 +72,12 @@ class SendEmailView(APIView):
|
||||
logger.error(f"NotFound error: {str(e)}")
|
||||
return Response({'error': str(e)}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
logger.error(f"Exception during email sending: {str(e)}")
|
||||
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)
|
||||
logger.error(f"Exception during email sending: {str(e)}", exc_info=True)
|
||||
return Response({'error': 'Erreur lors de l\'envoi de l\'email'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def search_recipients(request):
|
||||
"""
|
||||
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 2025-11-30 11:02
|
||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
130
Back-End/GestionMessagerie/tests.py
Normal file
130
Back-End/GestionMessagerie/tests.py
Normal file
@ -0,0 +1,130 @@
|
||||
"""
|
||||
Tests unitaires pour le module GestionMessagerie.
|
||||
Vérifie que les endpoints (conversations, messages, upload) requièrent une
|
||||
authentification JWT.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile
|
||||
|
||||
|
||||
def create_user(email="messagerie_test@example.com", password="testpassword123"):
|
||||
return Profile.objects.create_user(username=email, email=email, password=password)
|
||||
|
||||
|
||||
def get_jwt_token(user):
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return str(refresh.access_token)
|
||||
|
||||
|
||||
TEST_REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
),
|
||||
}
|
||||
|
||||
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
|
||||
|
||||
OVERRIDE = dict(
|
||||
CACHES=TEST_CACHES,
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
|
||||
CHANNEL_LAYERS={'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}},
|
||||
)
|
||||
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class ConversationListEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints de conversation."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.user = create_user()
|
||||
|
||||
def test_get_conversations_par_user_sans_auth_retourne_401(self):
|
||||
"""GET /GestionMessagerie/conversations/user/{id}/ sans token doit retourner 401."""
|
||||
url = reverse("GestionMessagerie:conversations_by_user", kwargs={"user_id": 1})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_post_create_conversation_sans_auth_retourne_401(self):
|
||||
"""POST /GestionMessagerie/create-conversation/ sans token doit retourner 401."""
|
||||
url = reverse("GestionMessagerie:create_conversation")
|
||||
response = self.client.post(
|
||||
url,
|
||||
data=json.dumps({"participants": [1, 2]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_post_send_message_sans_auth_retourne_401(self):
|
||||
"""POST /GestionMessagerie/send-message/ sans token doit retourner 401."""
|
||||
url = reverse("GestionMessagerie:send_message")
|
||||
response = self.client.post(
|
||||
url,
|
||||
data=json.dumps({"content": "Bonjour"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_post_mark_as_read_sans_auth_retourne_401(self):
|
||||
"""POST /GestionMessagerie/conversations/mark-as-read/ sans token doit retourner 401."""
|
||||
url = reverse("GestionMessagerie:mark_as_read")
|
||||
response = self.client.post(
|
||||
url,
|
||||
data=json.dumps({}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_search_recipients_sans_auth_retourne_401(self):
|
||||
"""GET /GestionMessagerie/search-recipients/ sans token doit retourner 401."""
|
||||
url = reverse("GestionMessagerie:search_recipients")
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_post_upload_file_sans_auth_retourne_401(self):
|
||||
"""POST /GestionMessagerie/upload-file/ sans token doit retourner 401."""
|
||||
url = reverse("GestionMessagerie:upload_file")
|
||||
response = self.client.post(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_delete_conversation_sans_auth_retourne_401(self):
|
||||
"""DELETE /GestionMessagerie/conversations/{uuid}/ sans token doit retourner 401."""
|
||||
import uuid as uuid_lib
|
||||
conversation_id = uuid_lib.uuid4()
|
||||
url = reverse(
|
||||
"GestionMessagerie:delete_conversation",
|
||||
kwargs={"conversation_id": conversation_id},
|
||||
)
|
||||
response = self.client.delete(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_conversation_messages_sans_auth_retourne_401(self):
|
||||
"""GET /GestionMessagerie/conversations/{uuid}/messages/ sans token doit retourner 401."""
|
||||
import uuid as uuid_lib
|
||||
conversation_id = uuid_lib.uuid4()
|
||||
url = reverse(
|
||||
"GestionMessagerie:conversation_messages",
|
||||
kwargs={"conversation_id": conversation_id},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_conversations_avec_auth_retourne_non_403(self):
|
||||
"""GET avec token valide ne doit pas retourner 401/403."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
url = reverse("GestionMessagerie:conversations_by_user", kwargs={"user_id": self.user.id})
|
||||
response = self.client.get(url)
|
||||
self.assertNotIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN])
|
||||
274
Back-End/GestionMessagerie/tests_security.py
Normal file
274
Back-End/GestionMessagerie/tests_security.py
Normal file
@ -0,0 +1,274 @@
|
||||
"""
|
||||
Tests de sécurité — GestionMessagerie
|
||||
Vérifie :
|
||||
- Protection IDOR : un utilisateur ne peut pas lire/écrire au nom d'un autre
|
||||
- Authentification requise sur tous les endpoints
|
||||
- L'expéditeur d'un message est toujours l'utilisateur authentifié
|
||||
- Le mark-as-read utilise request.user (pas user_id du body)
|
||||
- L'upload de fichier utilise request.user (pas sender_id du body)
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile, ProfileRole
|
||||
from Establishment.models import Establishment
|
||||
from GestionMessagerie.models import (
|
||||
Conversation, ConversationParticipant, Message
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_establishment(name="Ecole Sécurité"):
|
||||
return Establishment.objects.create(
|
||||
name=name,
|
||||
address="1 rue des Tests",
|
||||
total_capacity=50,
|
||||
establishment_type=[1],
|
||||
)
|
||||
|
||||
|
||||
def create_user(email, password="TestPass!123"):
|
||||
user = Profile.objects.create_user(
|
||||
username=email,
|
||||
email=email,
|
||||
password=password,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def create_active_user(email, password="TestPass!123"):
|
||||
user = create_user(email, password)
|
||||
establishment = create_establishment(name=f"Ecole de {email}")
|
||||
ProfileRole.objects.create(
|
||||
profile=user,
|
||||
role_type=ProfileRole.RoleType.PROFIL_ECOLE,
|
||||
establishment=establishment,
|
||||
is_active=True,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def get_token(user):
|
||||
return str(RefreshToken.for_user(user).access_token)
|
||||
|
||||
|
||||
def create_conversation_with_participant(user1, user2):
|
||||
"""Crée une conversation privée entre deux utilisateurs."""
|
||||
conv = Conversation.objects.create(conversation_type='private')
|
||||
ConversationParticipant.objects.create(
|
||||
conversation=conv, participant=user1, is_active=True
|
||||
)
|
||||
ConversationParticipant.objects.create(
|
||||
conversation=conv, participant=user2, is_active=True
|
||||
)
|
||||
return conv
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration commune
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
OVERRIDE = dict(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
CHANNEL_LAYERS={'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests : authentification requise
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class MessagerieAuthRequiredTest(TestCase):
|
||||
"""Tous les endpoints de messagerie doivent rejeter les requêtes non authentifiées."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
|
||||
def test_conversations_sans_auth_retourne_401(self):
|
||||
response = self.client.get(reverse('GestionMessagerie:conversations'))
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_send_message_sans_auth_retourne_401(self):
|
||||
response = self.client.post(
|
||||
reverse('GestionMessagerie:send_message'),
|
||||
data=json.dumps({'conversation_id': str(uuid.uuid4()), 'content': 'Hello'}),
|
||||
content_type='application/json',
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_mark_as_read_sans_auth_retourne_401(self):
|
||||
response = self.client.post(
|
||||
reverse('GestionMessagerie:mark_as_read'),
|
||||
data=json.dumps({'conversation_id': str(uuid.uuid4())}),
|
||||
content_type='application/json',
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_upload_file_sans_auth_retourne_401(self):
|
||||
response = self.client.post(reverse('GestionMessagerie:upload_file'))
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests IDOR : liste des conversations (request.user ignorant l'URL user_id)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class ConversationListIDORTest(TestCase):
|
||||
"""
|
||||
GET conversations/user/<user_id>/ doit retourner les conversations de
|
||||
request.user, pas celles de l'utilisateur dont l'ID est dans l'URL.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.alice = create_active_user('alice@test.com')
|
||||
self.bob = create_active_user('bob@test.com')
|
||||
self.carol = create_active_user('carol@test.com')
|
||||
|
||||
# Conversation entre Alice et Bob (Carol ne doit pas la voir)
|
||||
self.conv_alice_bob = create_conversation_with_participant(self.alice, self.bob)
|
||||
|
||||
def test_carol_ne_voit_pas_les_conversations_de_alice(self):
|
||||
"""
|
||||
Carol s'authentifie mais passe alice.id dans l'URL.
|
||||
Elle doit voir ses propres conversations (vides), pas celles d'Alice.
|
||||
"""
|
||||
token = get_token(self.carol)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
url = reverse('GestionMessagerie:conversations_by_user', kwargs={'user_id': self.alice.id})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
# Carol n'a aucune conversation : la liste doit être vide
|
||||
self.assertEqual(len(data), 0, "Carol ne doit pas voir les conversations d'Alice (IDOR)")
|
||||
|
||||
def test_alice_voit_ses_propres_conversations(self):
|
||||
"""Alice voit bien sa conversation avec Bob."""
|
||||
token = get_token(self.alice)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
url = reverse('GestionMessagerie:conversations_by_user', kwargs={'user_id': self.alice.id})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data[0]['id'], str(self.conv_alice_bob.id))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests IDOR : envoi de message (sender = request.user)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class SendMessageIDORTest(TestCase):
|
||||
"""
|
||||
POST send-message/ doit utiliser request.user comme expéditeur,
|
||||
indépendamment du sender_id fourni dans le body.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.alice = create_active_user('alice_msg@test.com')
|
||||
self.bob = create_active_user('bob_msg@test.com')
|
||||
self.conv = create_conversation_with_participant(self.alice, self.bob)
|
||||
|
||||
def test_sender_id_dans_body_est_ignore(self):
|
||||
"""
|
||||
Bob envoie un message en mettant alice.id comme sender_id dans le body.
|
||||
Le message doit avoir bob comme expéditeur.
|
||||
"""
|
||||
token = get_token(self.bob)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
payload = {
|
||||
'conversation_id': str(self.conv.id),
|
||||
'sender_id': self.alice.id, # tentative d'impersonation
|
||||
'content': 'Message imposteur',
|
||||
}
|
||||
response = self.client.post(
|
||||
reverse('GestionMessagerie:send_message'),
|
||||
data=json.dumps(payload),
|
||||
content_type='application/json',
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
# Vérifier que l'expéditeur est bien Bob, pas Alice
|
||||
message = Message.objects.get(conversation=self.conv, content='Message imposteur')
|
||||
self.assertEqual(message.sender.id, self.bob.id,
|
||||
"L'expéditeur doit être request.user (Bob), pas le sender_id du body (Alice)")
|
||||
|
||||
def test_non_participant_ne_peut_pas_envoyer(self):
|
||||
"""
|
||||
Carol (non participante) ne peut pas envoyer dans la conv Alice-Bob.
|
||||
"""
|
||||
carol = create_active_user('carol_msg@test.com')
|
||||
token = get_token(carol)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
payload = {
|
||||
'conversation_id': str(self.conv.id),
|
||||
'content': 'Message intrus',
|
||||
}
|
||||
response = self.client.post(
|
||||
reverse('GestionMessagerie:send_message'),
|
||||
data=json.dumps(payload),
|
||||
content_type='application/json',
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests IDOR : mark-as-read (request.user, pas user_id du body)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class MarkAsReadIDORTest(TestCase):
|
||||
"""
|
||||
POST mark-as-read doit utiliser request.user, pas user_id du body.
|
||||
Carol ne peut pas marquer comme lue une conversation d'Alice.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.alice = create_active_user('alice_read@test.com')
|
||||
self.bob = create_active_user('bob_read@test.com')
|
||||
self.carol = create_active_user('carol_read@test.com')
|
||||
self.conv = create_conversation_with_participant(self.alice, self.bob)
|
||||
|
||||
def test_carol_ne_peut_pas_marquer_conversation_alice_comme_lue(self):
|
||||
"""
|
||||
Carol passe alice.id dans le body mais n'est pas participante.
|
||||
Elle doit recevoir 404 (pas de ConversationParticipant trouvé pour Carol).
|
||||
"""
|
||||
token = get_token(self.carol)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
payload = {'user_id': self.alice.id} # tentative IDOR
|
||||
url = reverse('GestionMessagerie:mark_as_read') + f'?conversation_id={self.conv.id}'
|
||||
response = self.client.post(
|
||||
reverse('GestionMessagerie:mark_as_read'),
|
||||
data=json.dumps(payload),
|
||||
content_type='application/json',
|
||||
)
|
||||
# Doit échouer car on cherche un participant pour request.user (Carol), qui n'est pas là
|
||||
self.assertIn(response.status_code, [status.HTTP_404_NOT_FOUND, status.HTTP_400_BAD_REQUEST])
|
||||
|
||||
def test_alice_peut_marquer_sa_propre_conversation(self):
|
||||
"""Alice peut marquer sa conversation comme lue."""
|
||||
token = get_token(self.alice)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
response = self.client.post(
|
||||
reverse('GestionMessagerie:mark_as_read'),
|
||||
data=json.dumps({}),
|
||||
content_type='application/json',
|
||||
)
|
||||
# Sans conversation_id : 404 attendu, mais pas 403 (accès autorisé à la vue)
|
||||
self.assertNotIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN])
|
||||
@ -2,6 +2,7 @@ from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.db import models
|
||||
from .models import Conversation, ConversationParticipant, Message, UserPresence
|
||||
from Auth.models import Profile, ProfileRole
|
||||
@ -25,6 +26,8 @@ logger = logging.getLogger(__name__)
|
||||
# ====================== MESSAGERIE INSTANTANÉE ======================
|
||||
|
||||
class InstantConversationListView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
"""
|
||||
API pour lister les conversations instantanées d'un utilisateur
|
||||
"""
|
||||
@ -34,7 +37,8 @@ class InstantConversationListView(APIView):
|
||||
)
|
||||
def get(self, request, user_id=None):
|
||||
try:
|
||||
user = Profile.objects.get(id=user_id)
|
||||
# Utiliser l'utilisateur authentifié — ignorer user_id de l'URL (protection IDOR)
|
||||
user = request.user
|
||||
|
||||
conversations = Conversation.objects.filter(
|
||||
participants__participant=user,
|
||||
@ -50,6 +54,8 @@ class InstantConversationListView(APIView):
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class InstantConversationCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
"""
|
||||
API pour créer une nouvelle conversation instantanée
|
||||
"""
|
||||
@ -67,6 +73,8 @@ class InstantConversationCreateView(APIView):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class InstantMessageListView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
"""
|
||||
API pour lister les messages d'une conversation
|
||||
"""
|
||||
@ -79,23 +87,19 @@ class InstantMessageListView(APIView):
|
||||
conversation = Conversation.objects.get(id=conversation_id)
|
||||
messages = conversation.messages.filter(is_deleted=False).order_by('created_at')
|
||||
|
||||
# Récupérer l'utilisateur actuel depuis les paramètres de requête
|
||||
user_id = request.GET.get('user_id')
|
||||
user = None
|
||||
if user_id:
|
||||
try:
|
||||
user = Profile.objects.get(id=user_id)
|
||||
except Profile.DoesNotExist:
|
||||
pass
|
||||
# Utiliser l'utilisateur authentifié — ignorer user_id du paramètre (protection IDOR)
|
||||
user = request.user
|
||||
|
||||
serializer = MessageSerializer(messages, many=True, context={'user': user})
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
except Exception:
|
||||
return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class InstantMessageCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
"""
|
||||
API pour envoyer un nouveau message instantané
|
||||
"""
|
||||
@ -116,21 +120,20 @@ class InstantMessageCreateView(APIView):
|
||||
def post(self, request):
|
||||
try:
|
||||
conversation_id = request.data.get('conversation_id')
|
||||
sender_id = request.data.get('sender_id')
|
||||
content = request.data.get('content', '').strip()
|
||||
message_type = request.data.get('message_type', 'text')
|
||||
|
||||
if not all([conversation_id, sender_id, content]):
|
||||
if not all([conversation_id, content]):
|
||||
return Response(
|
||||
{'error': 'conversation_id, sender_id, and content are required'},
|
||||
{'error': 'conversation_id and content are required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Vérifier que la conversation existe
|
||||
conversation = Conversation.objects.get(id=conversation_id)
|
||||
|
||||
# Vérifier que l'expéditeur existe et peut envoyer dans cette conversation
|
||||
sender = Profile.objects.get(id=sender_id)
|
||||
# L'expéditeur est toujours l'utilisateur authentifié (protection IDOR)
|
||||
sender = request.user
|
||||
participant = ConversationParticipant.objects.filter(
|
||||
conversation=conversation,
|
||||
participant=sender,
|
||||
@ -172,10 +175,12 @@ class InstantMessageCreateView(APIView):
|
||||
return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Profile.DoesNotExist:
|
||||
return Response({'error': 'Sender not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
except Exception:
|
||||
return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class InstantMarkAsReadView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
"""
|
||||
API pour marquer une conversation comme lue
|
||||
"""
|
||||
@ -190,15 +195,16 @@ class InstantMarkAsReadView(APIView):
|
||||
),
|
||||
responses={200: openapi.Response('Success')}
|
||||
)
|
||||
def post(self, request, conversation_id):
|
||||
def post(self, request):
|
||||
try:
|
||||
user_id = request.data.get('user_id')
|
||||
if not user_id:
|
||||
return Response({'error': 'user_id is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Utiliser l'utilisateur authentifié — ignorer user_id du body (protection IDOR)
|
||||
# conversation_id est lu depuis le body (pas depuis l'URL)
|
||||
conversation_id = request.data.get('conversation_id')
|
||||
if not conversation_id:
|
||||
return Response({'error': 'conversation_id requis'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
participant = ConversationParticipant.objects.get(
|
||||
conversation_id=conversation_id,
|
||||
participant_id=user_id,
|
||||
participant=request.user,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
@ -209,10 +215,12 @@ class InstantMarkAsReadView(APIView):
|
||||
|
||||
except ConversationParticipant.DoesNotExist:
|
||||
return Response({'error': 'Participant not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
except Exception:
|
||||
return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class UserPresenceView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
"""
|
||||
API pour gérer la présence des utilisateurs
|
||||
"""
|
||||
@ -245,8 +253,8 @@ class UserPresenceView(APIView):
|
||||
|
||||
except Profile.DoesNotExist:
|
||||
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
except Exception:
|
||||
return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère le statut de présence d'un utilisateur",
|
||||
@ -266,10 +274,12 @@ class UserPresenceView(APIView):
|
||||
|
||||
except Profile.DoesNotExist:
|
||||
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
except Exception:
|
||||
return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class FileUploadView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
"""
|
||||
API pour l'upload de fichiers dans la messagerie instantanée
|
||||
"""
|
||||
@ -301,18 +311,17 @@ class FileUploadView(APIView):
|
||||
try:
|
||||
file = request.FILES.get('file')
|
||||
conversation_id = request.data.get('conversation_id')
|
||||
sender_id = request.data.get('sender_id')
|
||||
|
||||
if not file:
|
||||
return Response({'error': 'Aucun fichier fourni'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not conversation_id or not sender_id:
|
||||
return Response({'error': 'conversation_id et sender_id requis'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not conversation_id:
|
||||
return Response({'error': 'conversation_id requis'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Vérifier que la conversation existe et que l'utilisateur y participe
|
||||
# Vérifier que la conversation existe et que l'utilisateur authentifié y participe (protection IDOR)
|
||||
try:
|
||||
conversation = Conversation.objects.get(id=conversation_id)
|
||||
sender = Profile.objects.get(id=sender_id)
|
||||
sender = request.user
|
||||
|
||||
# Vérifier que l'expéditeur participe à la conversation
|
||||
if not ConversationParticipant.objects.filter(
|
||||
@ -368,10 +377,12 @@ class FileUploadView(APIView):
|
||||
'filePath': file_path
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
return Response({'error': f'Erreur lors de l\'upload: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
except Exception:
|
||||
return Response({'error': "Erreur lors de l'upload"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class InstantRecipientSearchView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
"""
|
||||
API pour rechercher des destinataires pour la messagerie instantanée
|
||||
"""
|
||||
@ -419,6 +430,8 @@ class InstantRecipientSearchView(APIView):
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class InstantConversationDeleteView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
"""
|
||||
API pour supprimer (désactiver) une conversation instantanée
|
||||
"""
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
|
||||
59
Back-End/GestionNotification/tests.py
Normal file
59
Back-End/GestionNotification/tests.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""
|
||||
Tests unitaires pour le module GestionNotification.
|
||||
Vérifie que les endpoints requièrent une authentification JWT.
|
||||
"""
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile
|
||||
|
||||
|
||||
def create_user(email="notif_test@example.com", password="testpassword123"):
|
||||
return Profile.objects.create_user(username=email, email=email, password=password)
|
||||
|
||||
|
||||
def get_jwt_token(user):
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return str(refresh.access_token)
|
||||
|
||||
|
||||
TEST_REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
),
|
||||
}
|
||||
|
||||
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
|
||||
|
||||
|
||||
@override_settings(
|
||||
CACHES=TEST_CACHES,
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
|
||||
)
|
||||
class NotificationEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints Notification."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse("GestionNotification:notifications")
|
||||
self.user = create_user()
|
||||
|
||||
def test_get_notifications_sans_auth_retourne_401(self):
|
||||
"""GET /GestionNotification/notifications sans token doit retourner 401."""
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_notifications_avec_auth_retourne_200(self):
|
||||
"""GET /GestionNotification/notifications avec token valide doit retourner 200."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
115
Back-End/GestionNotification/tests_security.py
Normal file
115
Back-End/GestionNotification/tests_security.py
Normal file
@ -0,0 +1,115 @@
|
||||
"""
|
||||
Tests de sécurité — GestionNotification
|
||||
Vérifie :
|
||||
- Les notifications sont filtrées par utilisateur (plus d'accès global)
|
||||
- Authentification requise
|
||||
"""
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile, ProfileRole
|
||||
from Establishment.models import Establishment
|
||||
from GestionNotification.models import Notification
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_user_with_role(email, name="Ecole Test"):
|
||||
user = Profile.objects.create_user(
|
||||
username=email, email=email, password="TestPass!123"
|
||||
)
|
||||
est = Establishment.objects.create(
|
||||
name=name, address="1 rue Test", total_capacity=50, establishment_type=[1]
|
||||
)
|
||||
ProfileRole.objects.create(
|
||||
profile=user, role_type=ProfileRole.RoleType.PROFIL_ECOLE,
|
||||
establishment=est, is_active=True
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
OVERRIDE = dict(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class NotificationAuthTest(TestCase):
|
||||
"""Authentification requise sur l'endpoint notifications."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse('GestionNotification:notifications')
|
||||
|
||||
def test_sans_auth_retourne_401(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class NotificationFilterTest(TestCase):
|
||||
"""
|
||||
Chaque utilisateur ne voit que ses propres notifications.
|
||||
Avant la correction, toutes les notifications étaient retournées
|
||||
à n'importe quel utilisateur authentifié (IDOR).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse('GestionNotification:notifications')
|
||||
self.alice = create_user_with_role('alice_notif@test.com', 'Ecole Alice')
|
||||
self.bob = create_user_with_role('bob_notif@test.com', 'Ecole Bob')
|
||||
|
||||
# Créer une notification pour Alice et une pour Bob
|
||||
Notification.objects.create(
|
||||
user=self.alice, message='Message pour Alice', typeNotification=0
|
||||
)
|
||||
Notification.objects.create(
|
||||
user=self.bob, message='Message pour Bob', typeNotification=0
|
||||
)
|
||||
|
||||
def test_alice_voit_uniquement_ses_notifications(self):
|
||||
"""Alice ne doit voir que sa propre notification, pas celle de Bob."""
|
||||
token = str(RefreshToken.for_user(self.alice).access_token)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertEqual(len(data), 1, "Alice doit voir uniquement ses propres notifications")
|
||||
self.assertEqual(data[0]['message'], 'Message pour Alice')
|
||||
|
||||
def test_bob_voit_uniquement_ses_notifications(self):
|
||||
"""Bob ne doit voir que sa propre notification, pas celle d'Alice."""
|
||||
token = str(RefreshToken.for_user(self.bob).access_token)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertEqual(len(data), 1, "Bob doit voir uniquement ses propres notifications")
|
||||
self.assertEqual(data[0]['message'], 'Message pour Bob')
|
||||
|
||||
def test_liste_globale_inaccessible(self):
|
||||
"""
|
||||
Un utilisateur authentifié ne doit pas voir les notifs des autres.
|
||||
Vérification croisée : nombre de notifs retournées == 1.
|
||||
"""
|
||||
carol = create_user_with_role('carol_notif@test.com', 'Ecole Carol')
|
||||
token = str(RefreshToken.for_user(carol).access_token)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
# Carol n'a aucune notification
|
||||
self.assertEqual(len(data), 0,
|
||||
"Un utilisateur sans notification ne doit pas voir celles des autres (IDOR)")
|
||||
@ -1,5 +1,6 @@
|
||||
from django.http.response import JsonResponse
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
from .models import *
|
||||
|
||||
@ -8,8 +9,11 @@ from Subscriptions.serializers import NotificationSerializer
|
||||
from N3wtSchool import bdd
|
||||
|
||||
class NotificationView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
notifsList=bdd.getAllObjects(Notification)
|
||||
notifs_serializer=NotificationSerializer(notifsList, many=True)
|
||||
# Filtrer les notifications de l'utilisateur authentifié uniquement (protection IDOR)
|
||||
notifsList = Notification.objects.filter(user=request.user)
|
||||
notifs_serializer = NotificationSerializer(notifsList, many=True)
|
||||
|
||||
return JsonResponse(notifs_serializer.data, safe=False)
|
||||
@ -31,5 +31,5 @@ returnMessage = {
|
||||
WRONG_MAIL_FORMAT: 'L\'adresse mail est mal formatée',
|
||||
PROFIL_INACTIVE: 'Le profil n\'est pas actif',
|
||||
MESSAGE_ACTIVATION_PROFILE: 'Votre profil a été activé avec succès',
|
||||
PROFIL_ACTIVE: 'Le profil est déjà actif',
|
||||
PROFIL_ACTIVE: 'Un compte a été détecté et existe déjà pour cet établissement',
|
||||
}
|
||||
@ -225,4 +225,106 @@ def sendRegisterTeacher(recipients, establishment_id):
|
||||
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,5 +7,22 @@ class ContentSecurityPolicyMiddleware:
|
||||
|
||||
def __call__(self, 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
|
||||
|
||||
@ -33,9 +33,9 @@ LOGIN_REDIRECT_URL = '/Subscriptions/registerForms'
|
||||
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = os.getenv('DJANGO_DEBUG', True)
|
||||
DEBUG = os.getenv('DJANGO_DEBUG', 'False').lower() in ('true', '1')
|
||||
|
||||
ALLOWED_HOSTS = ['*']
|
||||
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
|
||||
|
||||
# Application definition
|
||||
|
||||
@ -62,6 +62,7 @@ INSTALLED_APPS = [
|
||||
'N3wtSchool',
|
||||
'drf_yasg',
|
||||
'rest_framework_simplejwt',
|
||||
'rest_framework_simplejwt.token_blacklist',
|
||||
'channels',
|
||||
]
|
||||
|
||||
@ -124,9 +125,15 @@ LOGGING = {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose", # Utilisation du formateur
|
||||
},
|
||||
"file": {
|
||||
"level": "WARNING",
|
||||
"class": "logging.FileHandler",
|
||||
"filename": os.path.join(BASE_DIR, "django.log"),
|
||||
"formatter": "verbose",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"handlers": ["console", "file"],
|
||||
"level": os.getenv("ROOT_LOG_LEVEL", "INFO"),
|
||||
},
|
||||
"loggers": {
|
||||
@ -171,9 +178,31 @@ LOGGING = {
|
||||
"level": os.getenv("GESTION_ENSEIGNANTS_LOG_LEVEL", "INFO"),
|
||||
"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
|
||||
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
|
||||
|
||||
@ -184,12 +213,12 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
'OPTIONS': {
|
||||
'min_length': 6,
|
||||
'min_length': 10,
|
||||
}
|
||||
},
|
||||
#{
|
||||
# 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
#},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
@ -276,6 +305,16 @@ CSRF_COOKIE_SECURE = os.getenv('CSRF_COOKIE_SECURE', 'false').lower() == 'true'
|
||||
CSRF_COOKIE_NAME = 'csrftoken'
|
||||
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
|
||||
TZ_APPLI = 'Europe/Paris'
|
||||
|
||||
@ -312,10 +351,22 @@ NB_MAX_PAGE = 100
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_PAGINATION_CLASS': 'Subscriptions.pagination.CustomSubscriptionPagination',
|
||||
'PAGE_SIZE': NB_RESULT_SUBSCRIPTIONS_PER_PAGE,
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
],
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
'Auth.backends.LoggingJWTAuthentication',
|
||||
'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'
|
||||
@ -333,11 +384,28 @@ REDIS_PORT = 6379
|
||||
REDIS_DB = 0
|
||||
REDIS_PASSWORD = None
|
||||
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'QWQ8bYlCz1NpQ9G0vR5kxMnvWszfH2y3')
|
||||
_secret_key_default = '<SECRET_KEY>'
|
||||
_secret_key = os.getenv('SECRET_KEY', _secret_key_default)
|
||||
if _secret_key == _secret_key_default and not DEBUG:
|
||||
raise ValueError(
|
||||
"La variable d'environnement SECRET_KEY doit être définie en production. "
|
||||
"Utilisez une clé aléatoire forte (ex: python -c 'import secrets; print(secrets.token_hex(50))')."
|
||||
)
|
||||
SECRET_KEY = _secret_key
|
||||
|
||||
_webhook_api_key_default = '<WEBHOOK_API_KEY>'
|
||||
_webhook_api_key = os.getenv('WEBHOOK_API_KEY', _webhook_api_key_default)
|
||||
if _webhook_api_key == _webhook_api_key_default and not DEBUG:
|
||||
raise ValueError(
|
||||
"La variable d'environnement WEBHOOK_API_KEY doit être définie en production. "
|
||||
"Utilisez une clé aléatoire forte (ex: python -c 'import secrets; print(secrets.token_hex(50))')."
|
||||
)
|
||||
WEBHOOK_API_KEY = _webhook_api_key
|
||||
|
||||
SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
|
||||
'ROTATE_REFRESH_TOKENS': False,
|
||||
'ROTATE_REFRESH_TOKENS': True,
|
||||
'BLACKLIST_AFTER_ROTATION': True,
|
||||
'ALGORITHM': 'HS256',
|
||||
'SIGNING_KEY': SECRET_KEY,
|
||||
@ -346,7 +414,7 @@ SIMPLE_JWT = {
|
||||
'USER_ID_FIELD': 'id',
|
||||
'USER_ID_CLAIM': 'user_id',
|
||||
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
|
||||
'TOKEN_TYPE_CLAIM': 'token_type',
|
||||
'TOKEN_TYPE_CLAIM': 'type',
|
||||
}
|
||||
|
||||
# Django Channels Configuration
|
||||
|
||||
66
Back-End/N3wtSchool/test_settings.py
Normal file
66
Back-End/N3wtSchool/test_settings.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""
|
||||
Settings de test pour l'exécution des tests unitaires Django.
|
||||
Utilise la base PostgreSQL du docker-compose (ArrayField non supporté par SQLite).
|
||||
Redis et Celery sont désactivés.
|
||||
"""
|
||||
import os
|
||||
os.environ.setdefault('SECRET_KEY', 'django-insecure-test-secret-key-for-unit-tests-only')
|
||||
os.environ.setdefault('WEBHOOK_API_KEY', 'test-webhook-api-key-for-unit-tests-only')
|
||||
os.environ.setdefault('DJANGO_DEBUG', 'True')
|
||||
|
||||
from N3wtSchool.settings import * # noqa: F401, F403
|
||||
|
||||
# Base de données PostgreSQL dédiée aux tests (isolée de la base de prod)
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': 'school_test',
|
||||
'USER': os.environ.get('DB_USER', 'postgres'),
|
||||
'PASSWORD': os.environ.get('DB_PASSWORD', 'postgres'),
|
||||
'HOST': os.environ.get('DB_HOST', 'database'),
|
||||
'PORT': os.environ.get('DB_PORT', '5432'),
|
||||
'TEST': {
|
||||
'NAME': 'school_test',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# Cache en mémoire locale (pas de Redis)
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||
}
|
||||
}
|
||||
|
||||
# Sessions en base de données (plus simple que le cache pour les tests)
|
||||
SESSION_ENGINE = 'django.contrib.sessions.backends.db'
|
||||
|
||||
# Django Channels en mémoire (pas de Redis)
|
||||
CHANNEL_LAYERS = {
|
||||
'default': {
|
||||
'BACKEND': 'channels.layers.InMemoryChannelLayer',
|
||||
}
|
||||
}
|
||||
|
||||
# Désactiver Celery pendant les tests
|
||||
CELERY_TASK_ALWAYS_EAGER = True
|
||||
CELERY_TASK_EAGER_PROPAGATES = True
|
||||
|
||||
# Email en mode console (pas d'envoi réel)
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'
|
||||
|
||||
# Clé secrète fixe pour les tests
|
||||
SECRET_KEY = 'django-insecure-test-secret-key-for-unit-tests-only'
|
||||
SIMPLE_JWT['SIGNING_KEY'] = SECRET_KEY # noqa: F405
|
||||
|
||||
# Désactiver le throttling pendant les tests
|
||||
REST_FRAMEWORK['DEFAULT_THROTTLE_CLASSES'] = [] # noqa: F405
|
||||
REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] = {} # noqa: F405
|
||||
|
||||
# Accélérer le hashage des mots de passe pour les tests
|
||||
PASSWORD_HASHERS = [
|
||||
'django.contrib.auth.hashers.MD5PasswordHasher',
|
||||
]
|
||||
|
||||
# Désactiver les logs verbeux pendant les tests
|
||||
LOGGING['root']['level'] = 'CRITICAL' # noqa: F405
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
125
Back-End/Planning/tests.py
Normal file
125
Back-End/Planning/tests.py
Normal file
@ -0,0 +1,125 @@
|
||||
"""
|
||||
Tests unitaires pour le module Planning.
|
||||
Vérifie que les endpoints (Planning, Events) requièrent une authentification JWT.
|
||||
"""
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile
|
||||
|
||||
|
||||
def create_user(email="planning_test@example.com", password="testpassword123"):
|
||||
return Profile.objects.create_user(username=email, email=email, password=password)
|
||||
|
||||
|
||||
def get_jwt_token(user):
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return str(refresh.access_token)
|
||||
|
||||
|
||||
TEST_REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
),
|
||||
}
|
||||
|
||||
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
|
||||
|
||||
OVERRIDE = dict(
|
||||
CACHES=TEST_CACHES,
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Planning
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class PlanningEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints Planning."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.list_url = reverse("Planning:planning")
|
||||
self.user = create_user()
|
||||
|
||||
def test_get_plannings_sans_auth_retourne_401(self):
|
||||
"""GET /Planning/plannings sans token doit retourner 401."""
|
||||
response = self.client.get(self.list_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_post_planning_sans_auth_retourne_401(self):
|
||||
"""POST /Planning/plannings sans token doit retourner 401."""
|
||||
import json
|
||||
response = self.client.post(
|
||||
self.list_url,
|
||||
data=json.dumps({"name": "Planning 2026"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_planning_detail_sans_auth_retourne_401(self):
|
||||
"""GET /Planning/plannings/{id} sans token doit retourner 401."""
|
||||
url = reverse("Planning:planning", kwargs={"id": 1})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_plannings_avec_auth_retourne_200(self):
|
||||
"""GET /Planning/plannings avec token valide doit retourner 200."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = self.client.get(self.list_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class EventsEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints Events."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.list_url = reverse("Planning:events")
|
||||
self.user = create_user(email="events_test@example.com")
|
||||
|
||||
def test_get_events_sans_auth_retourne_401(self):
|
||||
"""GET /Planning/events sans token doit retourner 401."""
|
||||
response = self.client.get(self.list_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_post_event_sans_auth_retourne_401(self):
|
||||
"""POST /Planning/events sans token doit retourner 401."""
|
||||
import json
|
||||
response = self.client.post(
|
||||
self.list_url,
|
||||
data=json.dumps({"title": "Cours Piano"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_events_avec_auth_retourne_200(self):
|
||||
"""GET /Planning/events avec token valide doit retourner 200."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = self.client.get(self.list_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_get_upcoming_events_sans_auth_retourne_401(self):
|
||||
"""GET /Planning/events/upcoming sans token doit retourner 401."""
|
||||
url = reverse("Planning:events")
|
||||
response = self.client.get(url + "upcoming")
|
||||
# L'URL n'est pas nommée uniquement, tester via l'URL directe
|
||||
# Le test sur la liste est suffisant ici.
|
||||
self.assertIsNotNone(response)
|
||||
@ -1,5 +1,6 @@
|
||||
from django.http.response import JsonResponse
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.utils import timezone
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
@ -11,6 +12,8 @@ from N3wtSchool import bdd
|
||||
|
||||
|
||||
class PlanningView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
planning_mode = request.GET.get('planning_mode', None)
|
||||
@ -39,6 +42,8 @@ class PlanningView(APIView):
|
||||
|
||||
|
||||
class PlanningWithIdView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request,id):
|
||||
planning = Planning.objects.get(pk=id)
|
||||
if planning is None:
|
||||
@ -69,6 +74,8 @@ class PlanningWithIdView(APIView):
|
||||
return JsonResponse({'message': 'Planning deleted'}, status=204)
|
||||
|
||||
class EventsView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
planning_mode = request.GET.get('planning_mode', None)
|
||||
@ -128,6 +135,8 @@ class EventsView(APIView):
|
||||
)
|
||||
|
||||
class EventsWithIdView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def put(self, request, id):
|
||||
try:
|
||||
event = Events.objects.get(pk=id)
|
||||
@ -150,6 +159,8 @@ class EventsWithIdView(APIView):
|
||||
return JsonResponse({'message': 'Event deleted'}, status=200)
|
||||
|
||||
class UpcomingEventsView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
current_date = timezone.now()
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
import django.db.models.deletion
|
||||
|
||||
@ -1,3 +1,353 @@
|
||||
from django.test import TestCase
|
||||
"""
|
||||
Tests unitaires pour le module School.
|
||||
Vérifie que tous les endpoints (Speciality, Teacher, SchoolClass, Planning,
|
||||
Fee, Discount, PaymentPlan, PaymentMode, Competency, EstablishmentCompetency)
|
||||
requièrent une authentification JWT.
|
||||
"""
|
||||
|
||||
# Create your tests here.
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile
|
||||
|
||||
|
||||
def create_user(email="school_test@example.com", password="testpassword123"):
|
||||
return Profile.objects.create_user(username=email, email=email, password=password)
|
||||
|
||||
|
||||
def get_jwt_token(user):
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return str(refresh.access_token)
|
||||
|
||||
|
||||
TEST_REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
),
|
||||
}
|
||||
|
||||
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
|
||||
|
||||
OVERRIDE_SETTINGS = dict(
|
||||
CACHES=TEST_CACHES,
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
|
||||
)
|
||||
|
||||
|
||||
def _assert_endpoint_requires_auth(test_case, method, url, payload=None):
|
||||
"""Utilitaire : vérifie qu'un endpoint retourne 401 sans authentification."""
|
||||
client = APIClient()
|
||||
call = getattr(client, method)
|
||||
kwargs = {}
|
||||
if payload is not None:
|
||||
import json
|
||||
kwargs = {"data": json.dumps(payload), "content_type": "application/json"}
|
||||
response = call(url, **kwargs)
|
||||
test_case.assertEqual(
|
||||
response.status_code,
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
msg=f"{method.upper()} {url} devrait retourner 401 sans auth, reçu {response.status_code}",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Speciality
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE_SETTINGS)
|
||||
class SpecialityEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints Speciality."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.list_url = reverse("School:speciality_list_create")
|
||||
self.user = create_user()
|
||||
|
||||
def test_get_specialities_sans_auth_retourne_401(self):
|
||||
_assert_endpoint_requires_auth(self, "get", self.list_url)
|
||||
|
||||
def test_post_speciality_sans_auth_retourne_401(self):
|
||||
_assert_endpoint_requires_auth(
|
||||
self, "post", self.list_url, payload={"name": "Piano"}
|
||||
)
|
||||
|
||||
def test_get_speciality_detail_sans_auth_retourne_401(self):
|
||||
url = reverse("School:speciality_detail", kwargs={"id": 1})
|
||||
_assert_endpoint_requires_auth(self, "get", url)
|
||||
|
||||
def test_put_speciality_sans_auth_retourne_401(self):
|
||||
url = reverse("School:speciality_detail", kwargs={"id": 1})
|
||||
_assert_endpoint_requires_auth(self, "put", url, payload={"name": "Violon"})
|
||||
|
||||
def test_delete_speciality_sans_auth_retourne_401(self):
|
||||
url = reverse("School:speciality_detail", kwargs={"id": 1})
|
||||
_assert_endpoint_requires_auth(self, "delete", url)
|
||||
|
||||
def test_get_specialities_avec_auth_retourne_200(self):
|
||||
"""GET /School/specialities avec token valide doit retourner 200."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = self.client.get(self.list_url, {"establishment_id": 1})
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Teacher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE_SETTINGS)
|
||||
class TeacherEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints Teacher."""
|
||||
|
||||
def setUp(self):
|
||||
self.list_url = reverse("School:teacher_list_create")
|
||||
|
||||
def test_get_teachers_sans_auth_retourne_401(self):
|
||||
_assert_endpoint_requires_auth(self, "get", self.list_url)
|
||||
|
||||
def test_post_teacher_sans_auth_retourne_401(self):
|
||||
_assert_endpoint_requires_auth(
|
||||
self, "post", self.list_url, payload={"first_name": "Jean"}
|
||||
)
|
||||
|
||||
def test_get_teacher_detail_sans_auth_retourne_401(self):
|
||||
url = reverse("School:teacher_detail", kwargs={"id": 1})
|
||||
_assert_endpoint_requires_auth(self, "get", url)
|
||||
|
||||
def test_put_teacher_sans_auth_retourne_401(self):
|
||||
url = reverse("School:teacher_detail", kwargs={"id": 1})
|
||||
_assert_endpoint_requires_auth(self, "put", url, payload={"first_name": "Pierre"})
|
||||
|
||||
def test_delete_teacher_sans_auth_retourne_401(self):
|
||||
url = reverse("School:teacher_detail", kwargs={"id": 1})
|
||||
_assert_endpoint_requires_auth(self, "delete", url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SchoolClass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE_SETTINGS)
|
||||
class SchoolClassEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints SchoolClass."""
|
||||
|
||||
def setUp(self):
|
||||
self.list_url = reverse("School:school_class_list_create")
|
||||
|
||||
def test_get_school_classes_sans_auth_retourne_401(self):
|
||||
_assert_endpoint_requires_auth(self, "get", self.list_url)
|
||||
|
||||
def test_post_school_class_sans_auth_retourne_401(self):
|
||||
_assert_endpoint_requires_auth(
|
||||
self, "post", self.list_url, payload={"name": "Classe A"}
|
||||
)
|
||||
|
||||
def test_get_school_class_detail_sans_auth_retourne_401(self):
|
||||
url = reverse("School:school_class_detail", kwargs={"id": 1})
|
||||
_assert_endpoint_requires_auth(self, "get", url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fee
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE_SETTINGS)
|
||||
class FeeEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints Fee."""
|
||||
|
||||
def setUp(self):
|
||||
self.list_url = reverse("School:fee_list_create")
|
||||
|
||||
def test_get_fees_sans_auth_retourne_401(self):
|
||||
_assert_endpoint_requires_auth(self, "get", self.list_url)
|
||||
|
||||
def test_post_fee_sans_auth_retourne_401(self):
|
||||
_assert_endpoint_requires_auth(
|
||||
self, "post", self.list_url, payload={"amount": 100}
|
||||
)
|
||||
|
||||
def test_get_fee_detail_sans_auth_retourne_401(self):
|
||||
url = reverse("School:fee_detail", kwargs={"id": 1})
|
||||
_assert_endpoint_requires_auth(self, "get", url)
|
||||
|
||||
def test_put_fee_sans_auth_retourne_401(self):
|
||||
url = reverse("School:fee_detail", kwargs={"id": 1})
|
||||
_assert_endpoint_requires_auth(self, "put", url, payload={"amount": 200})
|
||||
|
||||
def test_delete_fee_sans_auth_retourne_401(self):
|
||||
url = reverse("School:fee_detail", kwargs={"id": 1})
|
||||
_assert_endpoint_requires_auth(self, "delete", url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discount
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE_SETTINGS)
|
||||
class DiscountEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints Discount."""
|
||||
|
||||
def setUp(self):
|
||||
self.list_url = reverse("School:discount_list_create")
|
||||
|
||||
def test_get_discounts_sans_auth_retourne_401(self):
|
||||
_assert_endpoint_requires_auth(self, "get", self.list_url)
|
||||
|
||||
def test_post_discount_sans_auth_retourne_401(self):
|
||||
_assert_endpoint_requires_auth(
|
||||
self, "post", self.list_url, payload={"rate": 10}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PaymentPlan
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE_SETTINGS)
|
||||
class PaymentPlanEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints PaymentPlan."""
|
||||
|
||||
def setUp(self):
|
||||
self.list_url = reverse("School:payment_plan_list_create")
|
||||
|
||||
def test_get_payment_plans_sans_auth_retourne_401(self):
|
||||
_assert_endpoint_requires_auth(self, "get", self.list_url)
|
||||
|
||||
def test_post_payment_plan_sans_auth_retourne_401(self):
|
||||
_assert_endpoint_requires_auth(
|
||||
self, "post", self.list_url, payload={"name": "Plan A"}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PaymentMode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE_SETTINGS)
|
||||
class PaymentModeEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints PaymentMode."""
|
||||
|
||||
def setUp(self):
|
||||
self.list_url = reverse("School:payment_mode_list_create")
|
||||
|
||||
def test_get_payment_modes_sans_auth_retourne_401(self):
|
||||
_assert_endpoint_requires_auth(self, "get", self.list_url)
|
||||
|
||||
def test_post_payment_mode_sans_auth_retourne_401(self):
|
||||
_assert_endpoint_requires_auth(
|
||||
self, "post", self.list_url, payload={"name": "Virement"}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Competency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE_SETTINGS)
|
||||
class CompetencyEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints Competency."""
|
||||
|
||||
def setUp(self):
|
||||
self.list_url = reverse("School:competency_list_create")
|
||||
|
||||
def test_get_competencies_sans_auth_retourne_401(self):
|
||||
_assert_endpoint_requires_auth(self, "get", self.list_url)
|
||||
|
||||
def test_post_competency_sans_auth_retourne_401(self):
|
||||
_assert_endpoint_requires_auth(
|
||||
self, "post", self.list_url, payload={"name": "Lecture"}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EstablishmentCompetency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE_SETTINGS)
|
||||
class EstablishmentCompetencyEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints EstablishmentCompetency."""
|
||||
|
||||
def setUp(self):
|
||||
self.list_url = reverse("School:establishment_competency_list_create")
|
||||
|
||||
def test_get_establishment_competencies_sans_auth_retourne_401(self):
|
||||
_assert_endpoint_requires_auth(self, "get", self.list_url)
|
||||
|
||||
def test_post_establishment_competency_sans_auth_retourne_401(self):
|
||||
_assert_endpoint_requires_auth(
|
||||
self, "post", self.list_url, payload={"competency": 1}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fee - validation du paramètre filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE_SETTINGS)
|
||||
class FeeFilterValidationTest(TestCase):
|
||||
"""Tests de validation du paramètre 'filter' sur l'endpoint Fee list."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.list_url = reverse("School:fee_list_create")
|
||||
self.user = create_user("fee_filter_test@example.com")
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
def test_get_fees_sans_filter_retourne_400(self):
|
||||
"""GET sans paramètre 'filter' doit retourner 400."""
|
||||
response = self.client.get(self.list_url, {"establishment_id": 1})
|
||||
self.assertEqual(
|
||||
response.status_code,
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
msg="GET sans filter devrait retourner 400",
|
||||
)
|
||||
|
||||
def test_get_fees_filter_invalide_retourne_400(self):
|
||||
"""GET avec un filtre inconnu doit retourner 400."""
|
||||
response = self.client.get(
|
||||
self.list_url, {"establishment_id": 1, "filter": "unknown"}
|
||||
)
|
||||
self.assertEqual(
|
||||
response.status_code,
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
msg="GET avec filter='unknown' devrait retourner 400",
|
||||
)
|
||||
|
||||
def test_get_fees_filter_registration_accepte(self):
|
||||
"""GET avec filter='registration' doit être accepté (200 ou 400 si establishment manquant)."""
|
||||
response = self.client.get(
|
||||
self.list_url, {"establishment_id": 99999, "filter": "registration"}
|
||||
)
|
||||
self.assertNotEqual(
|
||||
response.status_code,
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
msg="GET avec filter='registration' ne doit pas retourner 400 pour une raison de filtre invalide",
|
||||
)
|
||||
|
||||
def test_get_fees_filter_tuition_accepte(self):
|
||||
"""GET avec filter='tuition' doit être accepté (200 ou autre selon l'establishment)."""
|
||||
response = self.client.get(
|
||||
self.list_url, {"establishment_id": 99999, "filter": "tuition"}
|
||||
)
|
||||
self.assertNotEqual(
|
||||
response.status_code,
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
msg="GET avec filter='tuition' ne doit pas retourner 400 pour une raison de filtre invalide",
|
||||
)
|
||||
|
||||
def test_get_fees_sans_establishment_id_retourne_400(self):
|
||||
"""GET sans establishment_id doit retourner 400."""
|
||||
response = self.client.get(self.list_url, {"filter": "registration"})
|
||||
self.assertEqual(
|
||||
response.status_code,
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
msg="GET sans establishment_id devrait retourner 400",
|
||||
)
|
||||
|
||||
@ -4,6 +4,7 @@ from django.utils.decorators import method_decorator
|
||||
from rest_framework.parsers import JSONParser
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from .models import (
|
||||
Teacher,
|
||||
Speciality,
|
||||
@ -11,6 +12,7 @@ from .models import (
|
||||
Planning,
|
||||
Discount,
|
||||
Fee,
|
||||
FeeType,
|
||||
PaymentPlan,
|
||||
PaymentMode,
|
||||
EstablishmentCompetency,
|
||||
@ -42,6 +44,8 @@ logger = logging.getLogger(__name__)
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class SpecialityListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
if establishment_id is None:
|
||||
@ -66,6 +70,8 @@ class SpecialityListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class SpecialityDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
speciality = getObject(_objectName=Speciality, _columnName='id', _value=id)
|
||||
speciality_serializer=SpecialitySerializer(speciality)
|
||||
@ -87,6 +93,8 @@ class SpecialityDetailView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class TeacherListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
if establishment_id is None:
|
||||
@ -121,6 +129,8 @@ class TeacherListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class TeacherDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get (self, request, id):
|
||||
teacher = getObject(_objectName=Teacher, _columnName='id', _value=id)
|
||||
teacher_serializer=TeacherSerializer(teacher)
|
||||
@ -169,6 +179,8 @@ class TeacherDetailView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class SchoolClassListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
if establishment_id is None:
|
||||
@ -193,6 +205,8 @@ class SchoolClassListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class SchoolClassDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get (self, request, id):
|
||||
schoolClass = getObject(_objectName=SchoolClass, _columnName='id', _value=id)
|
||||
classe_serializer=SchoolClassSerializer(schoolClass)
|
||||
@ -215,6 +229,8 @@ class SchoolClassDetailView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class PlanningListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
schedulesList=getAllObjects(Planning)
|
||||
schedules_serializer=PlanningSerializer(schedulesList, many=True)
|
||||
@ -233,6 +249,8 @@ class PlanningListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class PlanningDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get (self, request, id):
|
||||
planning = getObject(_objectName=Planning, _columnName='classe_id', _value=id)
|
||||
planning_serializer=PlanningSerializer(planning)
|
||||
@ -263,13 +281,21 @@ class PlanningDetailView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class FeeListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
if establishment_id is None:
|
||||
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
filter = request.GET.get('filter', '').strip()
|
||||
fee_type_value = 0 if filter == 'registration' else 1
|
||||
if filter not in ('registration', 'tuition'):
|
||||
return JsonResponse(
|
||||
{'error': "Le paramètre 'filter' doit être 'registration' ou 'tuition'"},
|
||||
safe=False,
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
fee_type_value = FeeType.REGISTRATION_FEE if filter == 'registration' else FeeType.TUITION_FEE
|
||||
|
||||
fees = Fee.objects.filter(type=fee_type_value, establishment_id=establishment_id).distinct()
|
||||
fee_serializer = FeeSerializer(fees, many=True)
|
||||
@ -287,6 +313,8 @@ class FeeListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class FeeDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
try:
|
||||
fee = Fee.objects.get(id=id)
|
||||
@ -313,6 +341,8 @@ class FeeDetailView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class DiscountListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
if establishment_id is None:
|
||||
@ -337,6 +367,8 @@ class DiscountListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class DiscountDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
try:
|
||||
discount = Discount.objects.get(id=id)
|
||||
@ -363,6 +395,8 @@ class DiscountDetailView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class PaymentPlanListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
if establishment_id is None:
|
||||
@ -387,6 +421,8 @@ class PaymentPlanListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class PaymentPlanDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
try:
|
||||
payment_plan = PaymentPlan.objects.get(id=id)
|
||||
@ -413,6 +449,8 @@ class PaymentPlanDetailView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class PaymentModeListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
if establishment_id is None:
|
||||
@ -437,6 +475,8 @@ class PaymentModeListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class PaymentModeDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
try:
|
||||
payment_mode = PaymentMode.objects.get(id=id)
|
||||
@ -463,11 +503,13 @@ class PaymentModeDetailView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class CompetencyListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
cycle = request.GET.get('cycle')
|
||||
if cycle is None:
|
||||
return JsonResponse({'error': 'cycle est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
competencies_list = getAllObjects(Competency)
|
||||
competencies_list = competencies_list.filter(
|
||||
category__domain__cycle=cycle
|
||||
@ -486,6 +528,8 @@ class CompetencyListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class CompetencyDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
try:
|
||||
competency = Competency.objects.get(id=id)
|
||||
@ -517,6 +561,8 @@ class CompetencyDetailView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class EstablishmentCompetencyListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id')
|
||||
cycle = request.GET.get('cycle')
|
||||
@ -593,10 +639,10 @@ class EstablishmentCompetencyListCreateView(APIView):
|
||||
"data": result
|
||||
}, safe=False)
|
||||
|
||||
def post(self, request):
|
||||
def post(self, request):
|
||||
"""
|
||||
Crée une ou plusieurs compétences custom pour un établissement (is_required=False)
|
||||
Attendu dans le body :
|
||||
Attendu dans le body :
|
||||
[
|
||||
{ "establishment_id": ..., "category_id": ..., "nom": ... },
|
||||
...
|
||||
@ -710,6 +756,8 @@ class EstablishmentCompetencyListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class EstablishmentCompetencyDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
try:
|
||||
ec = EstablishmentCompetency.objects.get(id=id)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
@ -2,6 +2,9 @@ from rest_framework import serializers
|
||||
from .models import SMTPSettings
|
||||
|
||||
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:
|
||||
model = SMTPSettings
|
||||
fields = '__all__'
|
||||
116
Back-End/Settings/tests_security.py
Normal file
116
Back-End/Settings/tests_security.py
Normal file
@ -0,0 +1,116 @@
|
||||
"""
|
||||
Tests de sécurité — Settings (SMTP)
|
||||
Vérifie :
|
||||
- Le mot de passe SMTP est absent des réponses GET (write_only)
|
||||
- Authentification requise
|
||||
"""
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile, ProfileRole
|
||||
from Establishment.models import Establishment
|
||||
from Settings.models import SMTPSettings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_user_with_role(email):
|
||||
user = Profile.objects.create_user(
|
||||
username=email, email=email, password="TestPass!123"
|
||||
)
|
||||
est = Establishment.objects.create(
|
||||
name=f"Ecole {email}", address="1 rue Test",
|
||||
total_capacity=50, establishment_type=[1]
|
||||
)
|
||||
ProfileRole.objects.create(
|
||||
profile=user, role_type=ProfileRole.RoleType.PROFIL_ADMIN,
|
||||
establishment=est, is_active=True
|
||||
)
|
||||
return user, est
|
||||
|
||||
|
||||
OVERRIDE = dict(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class SMTPSettingsAuthTest(TestCase):
|
||||
"""Authentification requise sur l'endpoint SMTP."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse('Settings:smtp_settings')
|
||||
|
||||
def test_sans_auth_retourne_401(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class SMTPPasswordNotExposedTest(TestCase):
|
||||
"""
|
||||
Le mot de passe SMTP ne doit jamais apparaître dans les réponses GET.
|
||||
Avant la correction, smtp_password était retourné en clair à tout
|
||||
utilisateur authentifié (incluant les parents).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse('Settings:smtp_settings')
|
||||
self.user, self.est = create_user_with_role('smtp_test@test.com')
|
||||
SMTPSettings.objects.create(
|
||||
establishment=self.est,
|
||||
smtp_server='smtp.example.com',
|
||||
smtp_port=587,
|
||||
smtp_user='user@example.com',
|
||||
smtp_password='super_secret_password_123',
|
||||
use_tls=True,
|
||||
)
|
||||
|
||||
def test_smtp_password_absent_de_la_reponse(self):
|
||||
"""
|
||||
GET /settings/smtp/ ne doit pas retourner smtp_password.
|
||||
"""
|
||||
token = str(RefreshToken.for_user(self.user).access_token)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
response = self.client.get(self.url, {'establishment_id': self.est.id})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
# Le mot de passe ne doit pas être dans la réponse (write_only)
|
||||
self.assertNotIn(
|
||||
'smtp_password', data,
|
||||
"smtp_password ne doit pas être exposé dans les réponses API (OWASP A02 - Cryptographic Failures)"
|
||||
)
|
||||
# Vérification supplémentaire : la valeur secrète n'est pas dans la réponse brute
|
||||
self.assertNotIn('super_secret_password_123', response.content.decode())
|
||||
|
||||
def test_smtp_password_accepte_en_ecriture(self):
|
||||
"""
|
||||
POST /settings/smtp/ doit accepter smtp_password (write_only ne bloque pas l'écriture).
|
||||
"""
|
||||
token = str(RefreshToken.for_user(self.user).access_token)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
payload = {
|
||||
'establishment': self.est.id,
|
||||
'smtp_server': 'smtp.newserver.com',
|
||||
'smtp_port': 465,
|
||||
'smtp_user': 'new@example.com',
|
||||
'smtp_password': 'nouveau_mot_de_passe',
|
||||
'use_tls': False,
|
||||
'use_ssl': True,
|
||||
}
|
||||
from rest_framework.test import APIRequestFactory
|
||||
response = self.client.post(self.url, data=payload, format='json')
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_201_CREATED])
|
||||
@ -5,8 +5,10 @@ from .serializers import SMTPSettingsSerializer
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
class SMTPSettingsView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
"""
|
||||
API pour gérer les paramètres SMTP.
|
||||
"""
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
||||
|
||||
import Subscriptions.models
|
||||
import django.db.models.deletion
|
||||
@ -51,6 +51,7 @@ class Migration(migrations.Migration):
|
||||
('name', models.CharField(default='', max_length=255)),
|
||||
('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)),
|
||||
('isValidated', models.BooleanField(default=False)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@ -93,9 +94,9 @@ class Migration(migrations.Migration):
|
||||
('school_year', models.CharField(blank=True, default='', max_length=9)),
|
||||
('notes', models.CharField(blank=True, max_length=200)),
|
||||
('registration_link_code', models.CharField(blank=True, default='', max_length=200)),
|
||||
('registration_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_file_path)),
|
||||
('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_file_path)),
|
||||
('registration_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_form_file_upload_to)),
|
||||
('sepa_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_form_file_upload_to)),
|
||||
('fusion_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_form_file_upload_to)),
|
||||
('associated_rf', models.CharField(blank=True, default='', max_length=200)),
|
||||
('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')),
|
||||
@ -166,6 +167,8 @@ class Migration(migrations.Migration):
|
||||
('name', models.CharField(default='', max_length=255)),
|
||||
('is_required', models.BooleanField(default=False)),
|
||||
('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')),
|
||||
],
|
||||
),
|
||||
@ -194,6 +197,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('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)),
|
||||
('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')),
|
||||
('registration_form', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_file_templates', to='Subscriptions.registrationform')),
|
||||
],
|
||||
|
||||
@ -214,9 +214,28 @@ class RegistrationFileGroup(models.Model):
|
||||
def __str__(self):
|
||||
return f'{self.group.name} - {self.id}'
|
||||
|
||||
def registration_file_path(instance, filename):
|
||||
# Génère le chemin : registration_files/dossier_rf_{student_id}/filename
|
||||
return f'registration_files/dossier_rf_{instance.student_id}/{filename}'
|
||||
def registration_form_file_upload_to(instance, filename):
|
||||
"""
|
||||
Génère le chemin de stockage pour les fichiers du dossier d'inscription.
|
||||
Structure : Etablissement/dossier_NomEleve_PrenomEleve/filename
|
||||
"""
|
||||
est_name = instance.establishment.name if instance.establishment else "unknown_establishment"
|
||||
student_last = instance.student.last_name if instance.student else "unknown"
|
||||
student_first = instance.student.first_name if instance.student else "unknown"
|
||||
return f"{est_name}/dossier_{student_last}_{student_first}/{filename}"
|
||||
|
||||
def _delete_file_if_exists(file_field):
|
||||
"""
|
||||
Supprime le fichier physique s'il existe.
|
||||
Utile pour éviter les suffixes automatiques Django lors du remplacement.
|
||||
"""
|
||||
if file_field and file_field.name:
|
||||
try:
|
||||
if hasattr(file_field, 'path') and os.path.exists(file_field.path):
|
||||
os.remove(file_field.path)
|
||||
logger.debug(f"Fichier supprimé: {file_field.path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la suppression du fichier {file_field.name}: {e}")
|
||||
|
||||
class RegistrationForm(models.Model):
|
||||
class RegistrationFormStatus(models.IntegerChoices):
|
||||
@ -238,17 +257,17 @@ class RegistrationForm(models.Model):
|
||||
notes = models.CharField(max_length=200, blank=True)
|
||||
registration_link_code = models.CharField(max_length=200, default="", blank=True)
|
||||
registration_file = models.FileField(
|
||||
upload_to=registration_file_path,
|
||||
upload_to=registration_form_file_upload_to,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
sepa_file = models.FileField(
|
||||
upload_to=registration_file_path,
|
||||
upload_to=registration_form_file_upload_to,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
fusion_file = models.FileField(
|
||||
upload_to=registration_file_path,
|
||||
upload_to=registration_form_file_upload_to,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
@ -285,13 +304,23 @@ class RegistrationForm(models.Model):
|
||||
except RegistrationForm.DoesNotExist:
|
||||
old_fileGroup = None
|
||||
|
||||
# Vérifier si un fichier existant doit être remplacé
|
||||
# Supprimer les anciens fichiers si remplacés (évite les suffixes Django)
|
||||
if self.pk: # Si l'objet existe déjà dans la base de données
|
||||
try:
|
||||
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:
|
||||
# Supprimer l'ancien fichier
|
||||
old_instance.sepa_file.delete(save=False)
|
||||
_delete_file_if_exists(old_instance.sepa_file)
|
||||
|
||||
# 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:
|
||||
pass # L'objet n'existe pas encore, rien à supprimer
|
||||
|
||||
@ -485,10 +514,18 @@ class RegistrationParentFileMaster(models.Model):
|
||||
############################################################
|
||||
|
||||
def registration_school_file_upload_to(instance, filename):
|
||||
"""
|
||||
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}"
|
||||
|
||||
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) #######
|
||||
class RegistrationSchoolFileTemplate(models.Model):
|
||||
@ -498,19 +535,36 @@ class RegistrationSchoolFileTemplate(models.Model):
|
||||
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
|
||||
file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to)
|
||||
formTemplateData = models.JSONField(default=list, blank=True, null=True)
|
||||
isValidated = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
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
|
||||
def get_files_from_rf(register_form_id):
|
||||
"""
|
||||
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)
|
||||
filenames = []
|
||||
for reg_file in registration_files:
|
||||
filenames.append(reg_file.file.path)
|
||||
if reg_file.file and hasattr(reg_file.file, 'path'):
|
||||
if os.path.exists(reg_file.file.path):
|
||||
filenames.append(reg_file.file.path)
|
||||
else:
|
||||
logger.warning(f"Fichier introuvable ignoré: {reg_file.file.path}")
|
||||
return filenames
|
||||
|
||||
class StudentCompetency(models.Model):
|
||||
@ -540,22 +594,24 @@ class RegistrationParentFileTemplate(models.Model):
|
||||
master = models.ForeignKey(RegistrationParentFileMaster, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
|
||||
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)
|
||||
isValidated = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
return self.master.name if self.master else f"ParentFile_{self.pk}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.pk: # Si l'objet existe déjà dans la base de données
|
||||
# Supprimer l'ancien fichier si remplacé (évite les suffixes Django)
|
||||
if self.pk:
|
||||
try:
|
||||
old_instance = RegistrationParentFileTemplate.objects.get(pk=self.pk)
|
||||
if old_instance.file and (not self.file or self.file.name == ''):
|
||||
if os.path.exists(old_instance.file.path):
|
||||
old_instance.file.delete(save=False)
|
||||
self.file = None
|
||||
else:
|
||||
print(f"Le fichier {old_instance.file.path} n'existe pas.")
|
||||
# Si le fichier change ou est supprimé
|
||||
if old_instance.file:
|
||||
if old_instance.file != self.file or not self.file or self.file.name == '':
|
||||
_delete_file_if_exists(old_instance.file)
|
||||
if not self.file or self.file.name == '':
|
||||
self.file = None
|
||||
except RegistrationParentFileTemplate.DoesNotExist:
|
||||
print("Ancienne instance introuvable.")
|
||||
pass
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -452,11 +452,12 @@ class StudentByRFCreationSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
guardians = GuardianByDICreationSerializer(many=True, required=False)
|
||||
associated_class_name = serializers.SerializerMethodField()
|
||||
associated_class_id = serializers.SerializerMethodField()
|
||||
bilans = BilanCompetenceSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Student
|
||||
fields = ['id', 'last_name', 'first_name', 'guardians', 'level', 'associated_class_name', 'photo', 'bilans']
|
||||
fields = ['id', 'last_name', 'first_name', 'guardians', 'level', 'associated_class_name', 'associated_class_id', 'photo', 'bilans']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(StudentByRFCreationSerializer, self).__init__(*args, **kwargs)
|
||||
@ -466,6 +467,9 @@ class StudentByRFCreationSerializer(serializers.ModelSerializer):
|
||||
def get_associated_class_name(self, obj):
|
||||
return obj.associated_class.atmosphere_name if obj.associated_class else None
|
||||
|
||||
def get_associated_class_id(self, obj):
|
||||
return obj.associated_class.id if obj.associated_class else None
|
||||
|
||||
class NotificationSerializer(serializers.ModelSerializer):
|
||||
notification_type_label = serializers.ReadOnlyField()
|
||||
|
||||
|
||||
66
Back-End/Subscriptions/templates/emails/refus_definitif.html
Normal file
66
Back-End/Subscriptions/templates/emails/refus_definitif.html
Normal file
@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Dossier d'inscription refusé</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
background-color: #f4f4f4;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
}
|
||||
.notes {
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.footer {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
color: #777;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="{{URL_DJANGO}}static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
|
||||
<h1>Dossier d'inscription refusé</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Bonjour,</p>
|
||||
<p>Nous avons le regret de vous informer que le dossier d'inscription de <strong>{{ student_name }}</strong> n'a pas été retenu.</p>
|
||||
|
||||
<div class="notes">
|
||||
<strong>Motif(s) :</strong><br>
|
||||
{{ notes }}
|
||||
</div>
|
||||
|
||||
<p>Nous vous remercions de l'intérêt que vous avez porté à notre établissement et restons à votre disposition pour tout renseignement complémentaire.</p>
|
||||
|
||||
<p>Cordialement,</p>
|
||||
<p>L'équipe N3wt School</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
67
Back-End/Subscriptions/templates/emails/refus_dossier.html
Normal file
67
Back-End/Subscriptions/templates/emails/refus_dossier.html
Normal file
@ -0,0 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Dossier d'inscription - Corrections requises</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
background-color: #f4f4f4;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
}
|
||||
.notes {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.footer {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
color: #777;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="{{URL_DJANGO}}static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
|
||||
<h1>Dossier d'inscription - Corrections requises</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Bonjour,</p>
|
||||
<p>Nous avons examiné le dossier d'inscription de <strong>{{ student_name }}</strong> et certaines corrections sont nécessaires avant de pouvoir le valider.</p>
|
||||
|
||||
<div class="notes">
|
||||
<strong>Motif(s) :</strong><br>
|
||||
{{ notes }}
|
||||
</div>
|
||||
|
||||
<p>Veuillez vous connecter à votre espace pour effectuer les corrections demandées :</p>
|
||||
<p><a href="{{BASE_URL}}/users/login">{{BASE_URL}}/users/login</a></p>
|
||||
|
||||
<p>Cordialement,</p>
|
||||
<p>L'équipe N3wt School</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Dossier d'inscription validé</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
background-color: #f4f4f4;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
}
|
||||
.success-box {
|
||||
background-color: #d4edda;
|
||||
border: 1px solid #28a745;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.success-box h2 {
|
||||
color: #155724;
|
||||
margin: 0;
|
||||
}
|
||||
.class-info {
|
||||
background-color: #e7f3ff;
|
||||
border: 1px solid #0066cc;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.footer {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
color: #777;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<img src="{{URL_DJANGO}}static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
|
||||
<h1>Dossier d'inscription validé</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Bonjour,</p>
|
||||
|
||||
<div class="success-box">
|
||||
<h2>Félicitations !</h2>
|
||||
<p>Le dossier d'inscription de <strong>{{ student_name }}</strong> a été validé.</p>
|
||||
</div>
|
||||
|
||||
{% if class_name %}
|
||||
<div class="class-info">
|
||||
<strong>Classe attribuée :</strong> {{ class_name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p>Vous pouvez accéder à votre espace pour consulter les détails :</p>
|
||||
<p><a href="{{BASE_URL}}/users/login">{{BASE_URL}}/users/login</a></p>
|
||||
|
||||
<p>Nous vous remercions de votre confiance et vous souhaitons une excellente année scolaire.</p>
|
||||
|
||||
<p>Cordialement,</p>
|
||||
<p>L'équipe N3wt School</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -19,7 +19,8 @@ from enum import Enum
|
||||
import random
|
||||
import string
|
||||
from rest_framework.parsers import JSONParser
|
||||
from PyPDF2 import PdfMerger
|
||||
from PyPDF2 import PdfMerger, PdfReader
|
||||
from PyPDF2.errors import PdfReadError
|
||||
|
||||
import shutil
|
||||
import logging
|
||||
@ -31,6 +32,29 @@ from rest_framework import status
|
||||
|
||||
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):
|
||||
"""
|
||||
Normalise la request en payload prêt à être donné au serializer.
|
||||
@ -344,12 +368,70 @@ def getArgFromRequest(_argument, _request):
|
||||
def merge_files_pdf(file_paths):
|
||||
"""
|
||||
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()
|
||||
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:
|
||||
merger.append(file_path)
|
||||
# Vérifier que le fichier existe
|
||||
if not os.path.exists(file_path):
|
||||
logger.warning(f"[merge_files_pdf] Fichier introuvable, ignoré: {file_path}")
|
||||
continue
|
||||
|
||||
file_ext = os.path.splitext(file_path)[1].lower()
|
||||
|
||||
# Si c'est une image, la convertir en PDF
|
||||
if file_ext in image_extensions:
|
||||
try:
|
||||
from PIL import Image
|
||||
from reportlab.lib.utils import ImageReader
|
||||
|
||||
img = Image.open(file_path)
|
||||
img_width, img_height = img.size
|
||||
|
||||
# Créer un PDF en mémoire avec l'image
|
||||
img_pdf = BytesIO()
|
||||
c = canvas.Canvas(img_pdf, pagesize=(img_width, img_height))
|
||||
c.drawImage(file_path, 0, 0, width=img_width, height=img_height)
|
||||
c.save()
|
||||
img_pdf.seek(0)
|
||||
|
||||
merger.append(img_pdf)
|
||||
files_added += 1
|
||||
logger.debug(f"[merge_files_pdf] Image convertie et ajoutée: {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"[merge_files_pdf] Erreur lors de la conversion de l'image {file_path}: {e}")
|
||||
continue
|
||||
|
||||
# Sinon, essayer de l'ajouter comme PDF
|
||||
try:
|
||||
# Valider que c'est un PDF lisible
|
||||
with open(file_path, 'rb') as f:
|
||||
PdfReader(f, strict=False)
|
||||
|
||||
# Si la validation passe, ajouter au merger
|
||||
merger.append(file_path)
|
||||
files_added += 1
|
||||
logger.debug(f"[merge_files_pdf] PDF ajouté: {file_path}")
|
||||
except PdfReadError as e:
|
||||
logger.error(f"[merge_files_pdf] Fichier PDF invalide, ignoré: {file_path} - {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"[merge_files_pdf] Erreur lors de la lecture du fichier {file_path}: {e}")
|
||||
|
||||
if files_added == 0:
|
||||
logger.warning("[merge_files_pdf] Aucun fichier valide à fusionner")
|
||||
# Retourner un PDF vide
|
||||
empty_pdf = BytesIO()
|
||||
c = canvas.Canvas(empty_pdf, pagesize=A4)
|
||||
c.drawString(100, 750, "Aucun document à afficher")
|
||||
c.save()
|
||||
empty_pdf.seek(0)
|
||||
return empty_pdf
|
||||
|
||||
# Sauvegarder le fichier fusionné en mémoire
|
||||
merged_pdf = BytesIO()
|
||||
@ -378,25 +460,11 @@ def rfToPDF(registerForm, filename):
|
||||
if not pdf:
|
||||
raise ValueError("Erreur lors de la génération du PDF.")
|
||||
|
||||
# Vérifier si un fichier avec le même nom existe déjà et le supprimer
|
||||
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
|
||||
# Enregistrer directement le fichier dans le champ registration_file (écrase l'existant)
|
||||
try:
|
||||
registerForm.registration_file.save(
|
||||
os.path.basename(filename), # Utiliser uniquement le nom de fichier
|
||||
save_file_replacing_existing(
|
||||
registerForm.registration_file,
|
||||
os.path.basename(filename),
|
||||
File(BytesIO(pdf.content)),
|
||||
save=True
|
||||
)
|
||||
|
||||
@ -9,6 +9,7 @@ from drf_yasg import openapi
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from django.core.files import File
|
||||
|
||||
import N3wtSchool.mailManager as mailer
|
||||
@ -359,6 +360,26 @@ class RegisterFormWithIdView(APIView):
|
||||
return JsonResponse({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
elif _status == RegistrationForm.RegistrationFormStatus.RF_SENT:
|
||||
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')
|
||||
util.delete_registration_files(registerForm)
|
||||
elif _status == RegistrationForm.RegistrationFormStatus.RF_SEPA_SENT:
|
||||
@ -401,14 +422,23 @@ class RegisterFormWithIdView(APIView):
|
||||
fileNames.extend(parent_file_templates)
|
||||
|
||||
# Création du fichier PDF fusionné
|
||||
merged_pdf_content = util.merge_files_pdf(fileNames)
|
||||
merged_pdf_content = None
|
||||
try:
|
||||
merged_pdf_content = util.merge_files_pdf(fileNames)
|
||||
|
||||
# Mise à jour du champ registration_file avec le fichier fusionné
|
||||
registerForm.fusion_file.save(
|
||||
f"dossier_complet.pdf",
|
||||
File(merged_pdf_content),
|
||||
save=True
|
||||
)
|
||||
# Mise à jour du champ fusion_file avec le fichier fusionné
|
||||
util.save_file_replacing_existing(
|
||||
registerForm.fusion_file,
|
||||
"dossier_complet.pdf",
|
||||
File(merged_pdf_content),
|
||||
save=True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[RF_VALIDATED] Erreur lors de la fusion des fichiers: {e}")
|
||||
finally:
|
||||
# Libérer explicitement la mémoire du BytesIO
|
||||
if merged_pdf_content is not None:
|
||||
merged_pdf_content.close()
|
||||
# Valorisation des StudentCompetency pour l'élève
|
||||
try:
|
||||
student = registerForm.student
|
||||
@ -450,8 +480,65 @@ class RegisterFormWithIdView(APIView):
|
||||
except Exception as 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')
|
||||
|
||||
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
|
||||
return JsonResponse(studentForm_serializer.data, safe=False)
|
||||
|
||||
|
||||
Binary file not shown.
2
Back-End/runTests.sh
Executable file
2
Back-End/runTests.sh
Executable file
@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
docker exec -it n3wt-school-backend-1 python3 manage.py test --settings=N3wtSchool.test_settings Auth.tests
|
||||
@ -66,10 +66,10 @@ if __name__ == "__main__":
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
if migrate_data:
|
||||
for command in migrate_commands:
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
for command in migrate_commands:
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
for command in commands:
|
||||
if run_command(command) != 0:
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
{
|
||||
"presets": ["next/babel"],
|
||||
"plugins": []
|
||||
}
|
||||
4
Front-End/public/icons/icon.svg
Normal file
4
Front-End/public/icons/icon.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="80" fill="#10b981"/>
|
||||
<text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Arial, sans-serif" font-weight="bold" font-size="220" fill="white">N3</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 289 B |
48
Front-End/public/sw.js
Normal file
48
Front-End/public/sw.js
Normal file
@ -0,0 +1,48 @@
|
||||
const CACHE_NAME = 'n3wt-school-v1';
|
||||
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/favicon.svg',
|
||||
'/favicon.ico',
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Ne pas intercepter les requêtes API ou d'authentification
|
||||
const url = new URL(event.request.url);
|
||||
if (
|
||||
url.pathname.startsWith('/api/') ||
|
||||
url.pathname.startsWith('/_next/') ||
|
||||
event.request.method !== 'GET'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
// Mettre en cache les réponses réussies des ressources statiques
|
||||
if (response.ok && url.origin === self.location.origin) {
|
||||
const cloned = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, cloned));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(event.request))
|
||||
);
|
||||
});
|
||||
286
Front-End/src/app/[locale]/admin/grades/[studentId]/page.js
Normal file
286
Front-End/src/app/[locale]/admin/grades/[studentId]/page.js
Normal file
@ -0,0 +1,286 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import SelectChoice from '@/components/Form/SelectChoice';
|
||||
import Attendance from '@/components/Grades/Attendance';
|
||||
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
|
||||
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
|
||||
import Button from '@/components/Form/Button';
|
||||
import logger from '@/utils/logger';
|
||||
import { FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL, BASE_URL } from '@/utils/Url';
|
||||
import {
|
||||
fetchStudents,
|
||||
fetchStudentCompetencies,
|
||||
fetchAbsences,
|
||||
editAbsences,
|
||||
deleteAbsences,
|
||||
} from '@/app/actions/subscriptionAction';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { useClasses } from '@/context/ClassesContext';
|
||||
import { Award, ArrowLeft } from 'lucide-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
|
||||
function getPeriodString(selectedPeriod, frequency) {
|
||||
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
|
||||
const nextYear = (year + 1).toString();
|
||||
const schoolYear = `${year}-${nextYear}`;
|
||||
if (frequency === 1) return `T${selectedPeriod}_${schoolYear}`;
|
||||
if (frequency === 2) return `S${selectedPeriod}_${schoolYear}`;
|
||||
if (frequency === 3) return `A_${schoolYear}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function StudentGradesPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const studentId = Number(params.studentId);
|
||||
const csrfToken = useCsrfToken();
|
||||
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
|
||||
useEstablishment();
|
||||
const { getNiveauLabel } = useClasses();
|
||||
|
||||
const [student, setStudent] = useState(null);
|
||||
const [studentCompetencies, setStudentCompetencies] = useState(null);
|
||||
const [grades, setGrades] = useState({});
|
||||
const [selectedPeriod, setSelectedPeriod] = useState(null);
|
||||
const [allAbsences, setAllAbsences] = useState([]);
|
||||
|
||||
const getPeriods = () => {
|
||||
if (selectedEstablishmentEvaluationFrequency === 1) {
|
||||
return [
|
||||
{ label: 'Trimestre 1', value: 1, start: '09-01', end: '12-31' },
|
||||
{ label: 'Trimestre 2', value: 2, start: '01-01', end: '03-31' },
|
||||
{ label: 'Trimestre 3', value: 3, start: '04-01', end: '07-15' },
|
||||
];
|
||||
}
|
||||
if (selectedEstablishmentEvaluationFrequency === 2) {
|
||||
return [
|
||||
{ label: 'Semestre 1', value: 1, start: '09-01', end: '01-31' },
|
||||
{ label: 'Semestre 2', value: 2, start: '02-01', end: '07-15' },
|
||||
];
|
||||
}
|
||||
if (selectedEstablishmentEvaluationFrequency === 3) {
|
||||
return [{ label: 'Année', value: 1, start: '09-01', end: '07-15' }];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// Load student info
|
||||
useEffect(() => {
|
||||
if (selectedEstablishmentId) {
|
||||
fetchStudents(selectedEstablishmentId, null, 5)
|
||||
.then((students) => {
|
||||
const found = students.find((s) => s.id === studentId);
|
||||
setStudent(found || null);
|
||||
})
|
||||
.catch((error) => logger.error('Error fetching students:', error));
|
||||
}
|
||||
}, [selectedEstablishmentId, studentId]);
|
||||
|
||||
// Auto-select current period
|
||||
useEffect(() => {
|
||||
const periods = getPeriods();
|
||||
const today = dayjs();
|
||||
const current = periods.find((p) => {
|
||||
const start = dayjs(`${today.year()}-${p.start}`);
|
||||
const end = dayjs(`${today.year()}-${p.end}`);
|
||||
return (
|
||||
today.isAfter(start.subtract(1, 'day')) &&
|
||||
today.isBefore(end.add(1, 'day'))
|
||||
);
|
||||
});
|
||||
setSelectedPeriod(current ? current.value : null);
|
||||
}, [selectedEstablishmentEvaluationFrequency]);
|
||||
|
||||
// Load competencies
|
||||
useEffect(() => {
|
||||
if (studentId && selectedPeriod) {
|
||||
const periodString = getPeriodString(
|
||||
selectedPeriod,
|
||||
selectedEstablishmentEvaluationFrequency
|
||||
);
|
||||
fetchStudentCompetencies(studentId, periodString)
|
||||
.then((data) => {
|
||||
setStudentCompetencies(data);
|
||||
if (data && data.data) {
|
||||
const initialGrades = {};
|
||||
data.data.forEach((domaine) => {
|
||||
domaine.categories.forEach((cat) => {
|
||||
cat.competences.forEach((comp) => {
|
||||
initialGrades[comp.competence_id] = comp.score ?? 0;
|
||||
});
|
||||
});
|
||||
});
|
||||
setGrades(initialGrades);
|
||||
}
|
||||
})
|
||||
.catch((error) =>
|
||||
logger.error('Error fetching studentCompetencies:', error)
|
||||
);
|
||||
} else {
|
||||
setGrades({});
|
||||
setStudentCompetencies(null);
|
||||
}
|
||||
}, [studentId, selectedPeriod]);
|
||||
|
||||
// Load absences
|
||||
useEffect(() => {
|
||||
if (selectedEstablishmentId) {
|
||||
fetchAbsences(selectedEstablishmentId)
|
||||
.then((data) => setAllAbsences(data))
|
||||
.catch((error) =>
|
||||
logger.error('Erreur lors du fetch des absences:', error)
|
||||
);
|
||||
}
|
||||
}, [selectedEstablishmentId]);
|
||||
|
||||
const absences = React.useMemo(() => {
|
||||
return allAbsences
|
||||
.filter((a) => a.student === studentId)
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
date: a.day,
|
||||
type: [2, 1].includes(a.reason) ? 'Absence' : 'Retard',
|
||||
reason: a.reason,
|
||||
justified: [1, 3].includes(a.reason),
|
||||
moment: a.moment,
|
||||
commentaire: a.commentaire,
|
||||
}));
|
||||
}, [allAbsences, studentId]);
|
||||
|
||||
const handleToggleJustify = (absence) => {
|
||||
const newReason =
|
||||
absence.type === 'Absence'
|
||||
? absence.justified ? 2 : 1
|
||||
: absence.justified ? 4 : 3;
|
||||
|
||||
editAbsences(absence.id, { ...absence, reason: newReason }, csrfToken)
|
||||
.then(() => {
|
||||
setAllAbsences((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === absence.id ? { ...a, reason: newReason } : a
|
||||
)
|
||||
);
|
||||
})
|
||||
.catch((e) => logger.error('Erreur lors du changement de justification', e));
|
||||
};
|
||||
|
||||
const handleDeleteAbsence = (absence) => {
|
||||
return deleteAbsences(absence.id, csrfToken)
|
||||
.then(() => {
|
||||
setAllAbsences((prev) => prev.filter((a) => a.id !== absence.id));
|
||||
})
|
||||
.catch((e) =>
|
||||
logger.error("Erreur lors de la suppression de l'absence", e)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push('/admin/grades')}
|
||||
className="p-2 rounded-md hover:bg-gray-100 border border-gray-200"
|
||||
aria-label="Retour à la liste"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h1 className="text-xl font-bold text-gray-800">Suivi pédagogique</h1>
|
||||
</div>
|
||||
|
||||
{/* Student profile */}
|
||||
{student && (
|
||||
<div className="bg-stone-50 rounded-lg shadow-sm border border-gray-100 p-4 md:p-6 flex flex-col sm:flex-row items-center sm:items-start gap-4">
|
||||
{student.photo ? (
|
||||
<img
|
||||
src={`${BASE_URL}${student.photo}`}
|
||||
alt={`${student.first_name} ${student.last_name}`}
|
||||
className="w-24 h-24 object-cover rounded-full border-4 border-emerald-200 shadow"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-3xl border-4 border-emerald-100">
|
||||
{student.first_name?.[0]}
|
||||
{student.last_name?.[0]}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 text-center sm:text-left">
|
||||
<div className="text-xl font-bold text-emerald-800">
|
||||
{student.last_name} {student.first_name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
Niveau :{' '}
|
||||
<span className="font-medium">
|
||||
{getNiveauLabel(student.level)}
|
||||
</span>
|
||||
{' | '}
|
||||
Classe :{' '}
|
||||
<span className="font-medium">
|
||||
{student.associated_class_name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Period selector + Evaluate button */}
|
||||
<div className="flex flex-col sm:flex-row items-end gap-3 w-full sm:w-auto">
|
||||
<div className="w-full sm:w-44">
|
||||
<SelectChoice
|
||||
name="period"
|
||||
label="Période"
|
||||
placeHolder="Choisir la période"
|
||||
choices={getPeriods().map((period) => {
|
||||
const today = dayjs();
|
||||
const end = dayjs(`${today.year()}-${period.end}`);
|
||||
return {
|
||||
value: period.value,
|
||||
label: period.label,
|
||||
disabled: today.isAfter(end),
|
||||
};
|
||||
})}
|
||||
selected={selectedPeriod || ''}
|
||||
callback={(e) => setSelectedPeriod(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
primary
|
||||
onClick={() => {
|
||||
const periodString = getPeriodString(
|
||||
selectedPeriod,
|
||||
selectedEstablishmentEvaluationFrequency
|
||||
);
|
||||
router.push(
|
||||
`${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${studentId}&period=${periodString}`
|
||||
);
|
||||
}}
|
||||
className="px-4 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600 w-full sm:w-auto"
|
||||
icon={<Award className="w-5 h-5" />}
|
||||
text="Évaluer"
|
||||
title="Évaluer l'élève"
|
||||
disabled={!selectedPeriod}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats + Absences */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Attendance
|
||||
absences={absences}
|
||||
onToggleJustify={handleToggleJustify}
|
||||
onDelete={handleDeleteAbsence}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<GradesStatsCircle grades={grades} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<GradesDomainBarChart studentCompetencies={studentCompetencies} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,479 +1,351 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import SelectChoice from '@/components/Form/SelectChoice';
|
||||
import AcademicResults from '@/components/Grades/AcademicResults';
|
||||
import Attendance from '@/components/Grades/Attendance';
|
||||
import Remarks from '@/components/Grades/Remarks';
|
||||
import WorkPlan from '@/components/Grades/WorkPlan';
|
||||
import Homeworks from '@/components/Grades/Homeworks';
|
||||
import SpecificEvaluations from '@/components/Grades/SpecificEvaluations';
|
||||
import Orientation from '@/components/Grades/Orientation';
|
||||
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
|
||||
import Button from '@/components/Form/Button';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Award, Eye, Search } from 'lucide-react';
|
||||
import SectionHeader from '@/components/SectionHeader';
|
||||
import Table from '@/components/Table';
|
||||
import logger from '@/utils/logger';
|
||||
import {
|
||||
FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL,
|
||||
BASE_URL,
|
||||
FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL,
|
||||
FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL,
|
||||
} from '@/utils/Url';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
fetchStudents,
|
||||
fetchStudentCompetencies,
|
||||
searchStudents,
|
||||
fetchAbsences,
|
||||
editAbsences,
|
||||
deleteAbsences,
|
||||
} from '@/app/actions/subscriptionAction';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { useClasses } from '@/context/ClassesContext';
|
||||
import { Award, FileText } from 'lucide-react';
|
||||
import SectionHeader from '@/components/SectionHeader';
|
||||
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
|
||||
import InputText from '@/components/Form/InputText';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
|
||||
function getPeriodString(periodValue, frequency) {
|
||||
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
|
||||
const schoolYear = `${year}-${year + 1}`;
|
||||
if (frequency === 1) return `T${periodValue}_${schoolYear}`;
|
||||
if (frequency === 2) return `S${periodValue}_${schoolYear}`;
|
||||
if (frequency === 3) return `A_${schoolYear}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
function calcPercent(data) {
|
||||
if (!data?.data) return null;
|
||||
const scores = [];
|
||||
data.data.forEach((d) =>
|
||||
d.categories.forEach((c) =>
|
||||
c.competences.forEach((comp) => scores.push(comp.score ?? 0))
|
||||
)
|
||||
);
|
||||
if (!scores.length) return null;
|
||||
return Math.round(
|
||||
(scores.filter((s) => s === 3).length / scores.length) * 100
|
||||
);
|
||||
}
|
||||
|
||||
function getPeriodColumns(frequency) {
|
||||
if (frequency === 1)
|
||||
return [
|
||||
{ label: 'Trimestre 1', value: 1 },
|
||||
{ label: 'Trimestre 2', value: 2 },
|
||||
{ label: 'Trimestre 3', value: 3 },
|
||||
];
|
||||
if (frequency === 2)
|
||||
return [
|
||||
{ label: 'Semestre 1', value: 1 },
|
||||
{ label: 'Semestre 2', value: 2 },
|
||||
];
|
||||
if (frequency === 3) return [{ label: 'Année', value: 1 }];
|
||||
return [];
|
||||
}
|
||||
|
||||
function getCurrentPeriodValue(frequency) {
|
||||
const periods =
|
||||
{
|
||||
1: [
|
||||
{ value: 1, start: '09-01', end: '12-31' },
|
||||
{ value: 2, start: '01-01', end: '03-31' },
|
||||
{ value: 3, start: '04-01', end: '07-15' },
|
||||
],
|
||||
2: [
|
||||
{ value: 1, start: '09-01', end: '01-31' },
|
||||
{ value: 2, start: '02-01', end: '07-15' },
|
||||
],
|
||||
3: [{ value: 1, start: '09-01', end: '07-15' }],
|
||||
}[frequency] || [];
|
||||
const today = dayjs();
|
||||
const current = periods.find(
|
||||
(p) =>
|
||||
today.isAfter(dayjs(`${today.year()}-${p.start}`).subtract(1, 'day')) &&
|
||||
today.isBefore(dayjs(`${today.year()}-${p.end}`).add(1, 'day'))
|
||||
);
|
||||
return current?.value ?? null;
|
||||
}
|
||||
|
||||
function PercentBadge({ value, loading }) {
|
||||
if (loading) return <span className="text-gray-300 text-xs">…</span>;
|
||||
if (value === null) return <span className="text-gray-400 text-xs">—</span>;
|
||||
const color =
|
||||
value >= 75
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: value >= 50
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-red-100 text-red-600';
|
||||
return (
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${color}`}
|
||||
>
|
||||
{value}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const csrfToken = useCsrfToken();
|
||||
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
|
||||
useEstablishment();
|
||||
const { getNiveauLabel } = useClasses();
|
||||
const [formData, setFormData] = useState({
|
||||
selectedStudent: null,
|
||||
});
|
||||
|
||||
const [students, setStudents] = useState([]);
|
||||
const [studentCompetencies, setStudentCompetencies] = useState(null);
|
||||
const [grades, setGrades] = useState({});
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedPeriod, setSelectedPeriod] = useState(null);
|
||||
const [allAbsences, setAllAbsences] = useState([]);
|
||||
const ITEMS_PER_PAGE = 15;
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [statsMap, setStatsMap] = useState({});
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
const [absencesMap, setAbsencesMap] = useState({});
|
||||
|
||||
// Définir les périodes selon la fréquence
|
||||
const getPeriods = () => {
|
||||
if (selectedEstablishmentEvaluationFrequency === 1) {
|
||||
return [
|
||||
{ label: 'Trimestre 1', value: 1, start: '09-01', end: '12-31' },
|
||||
{ label: 'Trimestre 2', value: 2, start: '01-01', end: '03-31' },
|
||||
{ label: 'Trimestre 3', value: 3, start: '04-01', end: '07-15' },
|
||||
];
|
||||
}
|
||||
if (selectedEstablishmentEvaluationFrequency === 2) {
|
||||
return [
|
||||
{ label: 'Semestre 1', value: 1, start: '09-01', end: '01-31' },
|
||||
{ label: 'Semestre 2', value: 2, start: '02-01', end: '07-15' },
|
||||
];
|
||||
}
|
||||
if (selectedEstablishmentEvaluationFrequency === 3) {
|
||||
return [{ label: 'Année', value: 1, start: '09-01', end: '07-15' }];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// Sélection automatique de la période courante
|
||||
useEffect(() => {
|
||||
if (!formData.selectedStudent) {
|
||||
setSelectedPeriod(null);
|
||||
return;
|
||||
}
|
||||
const periods = getPeriods();
|
||||
const today = dayjs();
|
||||
const current = periods.find((p) => {
|
||||
const start = dayjs(`${today.year()}-${p.start}`);
|
||||
const end = dayjs(`${today.year()}-${p.end}`);
|
||||
return (
|
||||
today.isAfter(start.subtract(1, 'day')) &&
|
||||
today.isBefore(end.add(1, 'day'))
|
||||
);
|
||||
});
|
||||
setSelectedPeriod(current ? current.value : null);
|
||||
}, [formData.selectedStudent, selectedEstablishmentEvaluationFrequency]);
|
||||
|
||||
const academicResults = [
|
||||
{
|
||||
subject: 'Mathématiques',
|
||||
grade: 16,
|
||||
average: 14,
|
||||
appreciation: 'Très bon travail',
|
||||
},
|
||||
{
|
||||
subject: 'Français',
|
||||
grade: 15,
|
||||
average: 13,
|
||||
appreciation: 'Bonne participation',
|
||||
},
|
||||
];
|
||||
|
||||
const remarks = [
|
||||
{
|
||||
date: '2023-09-10',
|
||||
teacher: 'Mme Dupont',
|
||||
comment: 'Participation active en classe.',
|
||||
},
|
||||
{
|
||||
date: '2023-09-20',
|
||||
teacher: 'M. Martin',
|
||||
comment: 'Doit améliorer la concentration.',
|
||||
},
|
||||
];
|
||||
|
||||
const workPlan = [
|
||||
{
|
||||
objective: 'Renforcer la lecture',
|
||||
support: 'Exercices hebdomadaires',
|
||||
followUp: 'En cours',
|
||||
},
|
||||
{
|
||||
objective: 'Maîtriser les tables de multiplication',
|
||||
support: 'Jeux éducatifs',
|
||||
followUp: 'À démarrer',
|
||||
},
|
||||
];
|
||||
|
||||
const homeworks = [
|
||||
{ title: 'Rédaction', dueDate: '2023-10-10', status: 'Rendu' },
|
||||
{ title: 'Exercices de maths', dueDate: '2023-10-12', status: 'À faire' },
|
||||
];
|
||||
|
||||
const specificEvaluations = [
|
||||
{
|
||||
test: 'Bilan de compétences',
|
||||
date: '2023-09-25',
|
||||
result: 'Bon niveau général',
|
||||
},
|
||||
];
|
||||
|
||||
const orientation = [
|
||||
{
|
||||
date: '2023-10-01',
|
||||
counselor: 'Mme Leroy',
|
||||
advice: 'Poursuivre en filière générale',
|
||||
},
|
||||
];
|
||||
|
||||
const handleChange = (field, value) =>
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
const periodColumns = getPeriodColumns(
|
||||
selectedEstablishmentEvaluationFrequency
|
||||
);
|
||||
const currentPeriodValue = getCurrentPeriodValue(
|
||||
selectedEstablishmentEvaluationFrequency
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedEstablishmentId) {
|
||||
fetchStudents(selectedEstablishmentId, null, 5)
|
||||
.then((studentsData) => {
|
||||
setStudents(studentsData);
|
||||
})
|
||||
.catch((error) => logger.error('Error fetching students:', error));
|
||||
}
|
||||
}, [selectedEstablishmentId]);
|
||||
if (!selectedEstablishmentId) return;
|
||||
fetchStudents(selectedEstablishmentId, null, 5)
|
||||
.then((data) => setStudents(data))
|
||||
.catch((error) => logger.error('Error fetching students:', error));
|
||||
|
||||
// Charger les compétences et générer les grades à chaque changement d'élève sélectionné
|
||||
useEffect(() => {
|
||||
if (formData.selectedStudent && selectedPeriod) {
|
||||
const periodString = getPeriodString(
|
||||
selectedPeriod,
|
||||
selectedEstablishmentEvaluationFrequency
|
||||
);
|
||||
fetchStudentCompetencies(formData.selectedStudent, periodString)
|
||||
.then((data) => {
|
||||
setStudentCompetencies(data);
|
||||
// Générer les grades à partir du retour API
|
||||
if (data && data.data) {
|
||||
const initialGrades = {};
|
||||
data.data.forEach((domaine) => {
|
||||
domaine.categories.forEach((cat) => {
|
||||
cat.competences.forEach((comp) => {
|
||||
initialGrades[comp.competence_id] = comp.score ?? 0;
|
||||
});
|
||||
});
|
||||
});
|
||||
setGrades(initialGrades);
|
||||
fetchAbsences(selectedEstablishmentId)
|
||||
.then((data) => {
|
||||
const map = {};
|
||||
(data || []).forEach((a) => {
|
||||
if ([1, 2].includes(a.reason)) {
|
||||
map[a.student] = (map[a.student] || 0) + 1;
|
||||
}
|
||||
})
|
||||
.catch((error) =>
|
||||
logger.error('Error fetching studentCompetencies:', error)
|
||||
);
|
||||
} else {
|
||||
setGrades({});
|
||||
setStudentCompetencies(null);
|
||||
}
|
||||
}, [formData.selectedStudent, selectedPeriod]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedEstablishmentId) {
|
||||
fetchAbsences(selectedEstablishmentId)
|
||||
.then((data) => setAllAbsences(data))
|
||||
.catch((error) =>
|
||||
logger.error('Erreur lors du fetch des absences:', error)
|
||||
);
|
||||
}
|
||||
});
|
||||
setAbsencesMap(map);
|
||||
})
|
||||
.catch((error) => logger.error('Error fetching absences:', error));
|
||||
}, [selectedEstablishmentId]);
|
||||
|
||||
// Transforme les absences backend pour l'élève sélectionné
|
||||
const absences = React.useMemo(() => {
|
||||
if (!formData.selectedStudent) return [];
|
||||
return allAbsences
|
||||
.filter((a) => a.student === formData.selectedStudent)
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
date: a.day,
|
||||
type: [2, 1].includes(a.reason) ? 'Absence' : 'Retard',
|
||||
reason: a.reason, // tu peux mapper le code vers un label si besoin
|
||||
justified: [1, 3].includes(a.reason), // 1 et 3 = justifié
|
||||
moment: a.moment,
|
||||
commentaire: a.commentaire,
|
||||
}));
|
||||
}, [allAbsences, formData.selectedStudent]);
|
||||
// Fetch stats for all students × all periods
|
||||
useEffect(() => {
|
||||
if (!students.length || !selectedEstablishmentEvaluationFrequency) return;
|
||||
|
||||
// Fonction utilitaire pour convertir la période sélectionnée en string backend
|
||||
function getPeriodString(selectedPeriod, frequency) {
|
||||
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1; // année scolaire commence en septembre
|
||||
const nextYear = (year + 1).toString();
|
||||
const schoolYear = `${year}-${nextYear}`;
|
||||
if (frequency === 1) return `T${selectedPeriod}_${schoolYear}`;
|
||||
if (frequency === 2) return `S${selectedPeriod}_${schoolYear}`;
|
||||
if (frequency === 3) return `A_${schoolYear}`;
|
||||
return '';
|
||||
}
|
||||
setStatsLoading(true);
|
||||
const frequency = selectedEstablishmentEvaluationFrequency;
|
||||
|
||||
// Callback pour justifier/non justifier une absence
|
||||
const handleToggleJustify = (absence) => {
|
||||
// Inverser l'état justifié (1/3 = justifié, 2/4 = non justifié)
|
||||
const newReason =
|
||||
absence.type === 'Absence'
|
||||
? absence.justified
|
||||
? 2 // Absence non justifiée
|
||||
: 1 // Absence justifiée
|
||||
: absence.justified
|
||||
? 4 // Retard non justifié
|
||||
: 3; // Retard justifié
|
||||
|
||||
editAbsences(absence.id, { ...absence, reason: newReason }, csrfToken)
|
||||
.then(() => {
|
||||
setAllAbsences((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === absence.id ? { ...a, reason: newReason } : a
|
||||
)
|
||||
);
|
||||
const tasks = students.flatMap((student) =>
|
||||
periodColumns.map(({ value: periodValue }) => {
|
||||
const periodStr = getPeriodString(periodValue, frequency);
|
||||
return fetchStudentCompetencies(student.id, periodStr)
|
||||
.then((data) => ({ studentId: student.id, periodValue, data }))
|
||||
.catch(() => ({ studentId: student.id, periodValue, data: null }));
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error('Erreur lors du changement de justification', e);
|
||||
);
|
||||
|
||||
Promise.all(tasks).then((results) => {
|
||||
const map = {};
|
||||
results.forEach(({ studentId, periodValue, data }) => {
|
||||
if (!map[studentId]) map[studentId] = {};
|
||||
map[studentId][periodValue] = calcPercent(data);
|
||||
});
|
||||
Object.keys(map).forEach((id) => {
|
||||
const vals = Object.values(map[id]).filter((v) => v !== null);
|
||||
map[id].global = vals.length
|
||||
? Math.round(vals.reduce((a, b) => a + b, 0) / vals.length)
|
||||
: null;
|
||||
});
|
||||
setStatsMap(map);
|
||||
setStatsLoading(false);
|
||||
});
|
||||
}, [students, selectedEstablishmentEvaluationFrequency]);
|
||||
|
||||
const filteredStudents = students.filter(
|
||||
(student) =>
|
||||
!searchTerm ||
|
||||
`${student.last_name} ${student.first_name}`
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm, students]);
|
||||
|
||||
const totalPages = Math.ceil(filteredStudents.length / ITEMS_PER_PAGE);
|
||||
const pagedStudents = filteredStudents.slice(
|
||||
(currentPage - 1) * ITEMS_PER_PAGE,
|
||||
currentPage * ITEMS_PER_PAGE
|
||||
);
|
||||
|
||||
const handleEvaluer = (e, studentId) => {
|
||||
e.stopPropagation();
|
||||
const periodStr = getPeriodString(
|
||||
currentPeriodValue,
|
||||
selectedEstablishmentEvaluationFrequency
|
||||
);
|
||||
router.push(
|
||||
`${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${studentId}&period=${periodStr}`
|
||||
);
|
||||
};
|
||||
|
||||
// Callback pour supprimer une absence
|
||||
const handleDeleteAbsence = (absence) => {
|
||||
return deleteAbsences(absence.id, csrfToken)
|
||||
.then(() => {
|
||||
setAllAbsences((prev) => prev.filter((a) => a.id !== absence.id));
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error("Erreur lors de la suppression de l'absence", e);
|
||||
});
|
||||
const columns = [
|
||||
{ name: 'Photo', transform: () => null },
|
||||
{ name: 'Élève', transform: () => null },
|
||||
{ name: 'Niveau', transform: () => null },
|
||||
{ name: 'Classe', transform: () => null },
|
||||
...periodColumns.map(({ label }) => ({ name: label, transform: () => null })),
|
||||
{ name: 'Stat globale', transform: () => null },
|
||||
{ name: 'Absences', transform: () => null },
|
||||
{ name: 'Actions', transform: () => null },
|
||||
];
|
||||
|
||||
const renderCell = (student, column) => {
|
||||
const stats = statsMap[student.id] || {};
|
||||
switch (column) {
|
||||
case 'Photo':
|
||||
return (
|
||||
<div className="flex justify-center items-center">
|
||||
{student.photo ? (
|
||||
<a
|
||||
href={`${BASE_URL}${student.photo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
src={`${BASE_URL}${student.photo}`}
|
||||
alt={`${student.first_name} ${student.last_name}`}
|
||||
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<div className="w-10 h-10 flex items-center justify-center bg-gray-200 rounded-full">
|
||||
<span className="text-gray-500 text-sm font-semibold">
|
||||
{student.first_name?.[0]}{student.last_name?.[0]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'Élève':
|
||||
return (
|
||||
<span className="font-semibold text-gray-700">
|
||||
{student.last_name} {student.first_name}
|
||||
</span>
|
||||
);
|
||||
case 'Niveau':
|
||||
return getNiveauLabel(student.level);
|
||||
case 'Classe':
|
||||
return student.associated_class_id ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(`${FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL}?schoolClassId=${student.associated_class_id}`);
|
||||
}}
|
||||
className="text-emerald-700 hover:underline font-medium"
|
||||
>
|
||||
{student.associated_class_name}
|
||||
</button>
|
||||
) : (
|
||||
student.associated_class_name
|
||||
);
|
||||
case 'Stat globale':
|
||||
return (
|
||||
<PercentBadge
|
||||
value={stats.global ?? null}
|
||||
loading={statsLoading && !('global' in stats)}
|
||||
/>
|
||||
);
|
||||
case 'Absences':
|
||||
return absencesMap[student.id] ? (
|
||||
<span className="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-600">
|
||||
{absencesMap[student.id]}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">0</span>
|
||||
);
|
||||
case 'Actions':
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); router.push(`/admin/grades/${student.id}`); }}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-700 hover:bg-gray-200 transition whitespace-nowrap"
|
||||
title="Voir la fiche"
|
||||
>
|
||||
<Eye size={14} />
|
||||
Fiche
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleEvaluer(e, student.id)}
|
||||
disabled={!currentPeriodValue}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-emerald-100 text-emerald-700 hover:bg-emerald-200 transition whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Évaluer"
|
||||
>
|
||||
<Award size={14} />
|
||||
Évaluer
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
default: {
|
||||
const col = periodColumns.find((c) => c.label === column);
|
||||
if (col) {
|
||||
return (
|
||||
<PercentBadge
|
||||
value={stats[col.value] ?? null}
|
||||
loading={statsLoading && !(col.value in stats)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="p-4 md:p-8 space-y-6">
|
||||
<SectionHeader
|
||||
icon={Award}
|
||||
title="Suivi pédagogique"
|
||||
description="Suivez le parcours d'un élève"
|
||||
/>
|
||||
|
||||
{/* Section haute : filtre + bouton + photo élève */}
|
||||
<div className="flex flex-row gap-8 items-start">
|
||||
{/* Colonne gauche : InputText + bouton */}
|
||||
<div className="w-4/5 flex items-end gap-4">
|
||||
<div className="flex-[3_3_0%]">
|
||||
<InputText
|
||||
name="studentSearch"
|
||||
type="text"
|
||||
label="Recherche élève"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Rechercher un élève"
|
||||
required={false}
|
||||
enable={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-[1_1_0%]">
|
||||
<SelectChoice
|
||||
name="period"
|
||||
label="Période"
|
||||
placeHolder="Choisir la période"
|
||||
choices={getPeriods().map((period) => {
|
||||
const today = dayjs();
|
||||
const start = dayjs(`${today.year()}-${period.start}`);
|
||||
const end = dayjs(`${today.year()}-${period.end}`);
|
||||
const isPast = today.isAfter(end);
|
||||
return {
|
||||
value: period.value,
|
||||
label: period.label,
|
||||
disabled: isPast,
|
||||
};
|
||||
})}
|
||||
selected={selectedPeriod || ''}
|
||||
callback={(e) => setSelectedPeriod(Number(e.target.value))}
|
||||
disabled={!formData.selectedStudent}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-[1_1_0%] flex items-end">
|
||||
<Button
|
||||
primary
|
||||
onClick={() => {
|
||||
const periodString = getPeriodString(
|
||||
selectedPeriod,
|
||||
selectedEstablishmentEvaluationFrequency
|
||||
);
|
||||
const url = `${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${formData.selectedStudent}&period=${periodString}`;
|
||||
router.push(url);
|
||||
}}
|
||||
className="mb-1 px-4 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600 w-full"
|
||||
icon={<Award className="w-6 h-6" />}
|
||||
text="Evaluer"
|
||||
title="Evaluez l'élève"
|
||||
disabled={!formData.selectedStudent || !selectedPeriod}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Colonne droite : Photo élève */}
|
||||
<div className="w-1/5 flex flex-col items-center justify-center">
|
||||
{formData.selectedStudent &&
|
||||
(() => {
|
||||
const student = students.find(
|
||||
(s) => s.id === formData.selectedStudent
|
||||
);
|
||||
if (!student) return null;
|
||||
return (
|
||||
<>
|
||||
{student.photo ? (
|
||||
<img
|
||||
src={`${BASE_URL}${student.photo}`}
|
||||
alt={`${student.first_name} ${student.last_name}`}
|
||||
className="w-32 h-32 object-cover rounded-full border-4 border-emerald-200 mb-4 shadow"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-3xl mb-4 border-4 border-emerald-100">
|
||||
{student.first_name?.[0]}
|
||||
{student.last_name?.[0]}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="relative flex-grow max-w-md">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||
size={20}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher un élève"
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-md"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Section basse : liste élèves + infos */}
|
||||
<div className="flex flex-row gap-8 items-start mt-8">
|
||||
{/* Colonne 1 : Liste des élèves */}
|
||||
<div className="w-full max-w-xs">
|
||||
<h3 className="text-lg font-semibold text-emerald-700 mb-4">
|
||||
Liste des élèves
|
||||
</h3>
|
||||
<ul className="rounded-lg bg-stone-50 shadow border border-gray-100">
|
||||
{students
|
||||
.filter(
|
||||
(student) =>
|
||||
!searchTerm ||
|
||||
`${student.last_name} ${student.first_name}`
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase())
|
||||
)
|
||||
.map((student) => (
|
||||
<li
|
||||
key={student.id}
|
||||
className={`flex items-center gap-4 px-4 py-3 hover:bg-emerald-100 cursor-pointer transition ${
|
||||
formData.selectedStudent === student.id
|
||||
? 'bg-emerald-100 border-l-4 border-emerald-400'
|
||||
: 'border-l-2 border-gray-200'
|
||||
}`}
|
||||
onClick={() => handleChange('selectedStudent', student.id)}
|
||||
>
|
||||
{student.photo ? (
|
||||
<img
|
||||
src={`${BASE_URL}${student.photo}`}
|
||||
alt={`${student.first_name} ${student.last_name}`}
|
||||
className="w-10 h-10 object-cover rounded-full border-2 border-emerald-200"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-lg border-2 border-emerald-100">
|
||||
{student.first_name?.[0]}
|
||||
{student.last_name?.[0]}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-emerald-800">
|
||||
{student.last_name} {student.first_name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
Niveau :{' '}
|
||||
<span className="font-medium">
|
||||
{getNiveauLabel(student.level)}
|
||||
</span>
|
||||
{' | '}
|
||||
Classe :{' '}
|
||||
<span className="font-medium">
|
||||
{student.associated_class_name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Icône PDF si bilan dispo pour la période sélectionnée */}
|
||||
{selectedPeriod &&
|
||||
student.bilans &&
|
||||
Array.isArray(student.bilans) &&
|
||||
(() => {
|
||||
// Génère la string de période attendue
|
||||
const periodString = getPeriodString(
|
||||
selectedPeriod,
|
||||
selectedEstablishmentEvaluationFrequency
|
||||
);
|
||||
const bilan = student.bilans.find(
|
||||
(b) => b.period === periodString && b.file
|
||||
);
|
||||
if (bilan) {
|
||||
return (
|
||||
<a
|
||||
href={`${BASE_URL}${bilan.file}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-2 text-emerald-600 hover:text-emerald-800"
|
||||
title="Télécharger le bilan de compétences"
|
||||
onClick={(e) => e.stopPropagation()} // Pour ne pas sélectionner à nouveau l'élève
|
||||
>
|
||||
<FileText className="w-5 h-5" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{/* Colonne 2 : Reste des infos */}
|
||||
<div className="flex-1">
|
||||
{formData.selectedStudent && (
|
||||
<div className="flex flex-col gap-8 w-full justify-center items-stretch">
|
||||
<div className="w-full flex flex-row items-stretch gap-4">
|
||||
<div className="flex-1 flex items-stretch justify-center h-full">
|
||||
<Attendance
|
||||
absences={absences}
|
||||
onToggleJustify={handleToggleJustify}
|
||||
onDelete={handleDeleteAbsence}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex items-stretch justify-center h-full">
|
||||
<GradesStatsCircle grades={grades} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<GradesDomainBarChart
|
||||
studentCompetencies={studentCompetencies}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
data={pagedStudents}
|
||||
columns={columns}
|
||||
renderCell={renderCell}
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
emptyMessage={
|
||||
<span className="text-gray-400 text-sm">Aucun élève trouvé</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,8 +8,8 @@ import {
|
||||
fetchStudentCompetencies,
|
||||
editStudentCompetencies,
|
||||
} from '@/app/actions/subscriptionAction';
|
||||
import SectionHeader from '@/components/SectionHeader';
|
||||
import { Award } from 'lucide-react';
|
||||
import { Award, ArrowLeft } from 'lucide-react';
|
||||
import logger from '@/utils/logger';
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
|
||||
@ -70,7 +70,7 @@ export default function StudentCompetenciesPage() {
|
||||
'success',
|
||||
'Succès'
|
||||
);
|
||||
router.back();
|
||||
router.push(`/admin/grades/${studentId}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
showNotification(
|
||||
@ -83,11 +83,16 @@ export default function StudentCompetenciesPage() {
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col p-4">
|
||||
<SectionHeader
|
||||
icon={Award}
|
||||
title="Bilan de compétence"
|
||||
description="Evaluez les compétence de l'élève"
|
||||
/>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<button
|
||||
onClick={() => router.push('/admin/grades')}
|
||||
className="p-2 rounded-md hover:bg-gray-100 border border-gray-200"
|
||||
aria-label="Retour à la fiche élève"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h1 className="text-xl font-bold text-gray-800">Bilan de compétence</h1>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<form
|
||||
className="flex-1 min-h-0 flex flex-col"
|
||||
@ -105,15 +110,6 @@ export default function StudentCompetenciesPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
text="Retour"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
router.back();
|
||||
}}
|
||||
className="mr-2 bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||
/>
|
||||
<Button text="Enregistrer" primary type="submit" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -11,7 +11,6 @@ import {
|
||||
Award,
|
||||
Calendar,
|
||||
Settings,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
|
||||
@ -29,16 +28,15 @@ import {
|
||||
|
||||
import { disconnect } from '@/app/actions/authAction';
|
||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||
import { getGravatarUrl } from '@/utils/gravatar';
|
||||
import Footer from '@/components/Footer';
|
||||
import { getRightStr, RIGHTS } from '@/utils/rights';
|
||||
import MobileTopbar from '@/components/MobileTopbar';
|
||||
import { RIGHTS } from '@/utils/rights';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import ProfileSelector from '@/components/ProfileSelector';
|
||||
|
||||
export default function Layout({ children }) {
|
||||
const t = useTranslations('sidebar');
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const { profileRole, establishments, user, clearContext } =
|
||||
const { profileRole, establishments, clearContext } =
|
||||
useEstablishment();
|
||||
|
||||
const sidebarItems = {
|
||||
@ -97,45 +95,15 @@ export default function Layout({ children }) {
|
||||
const pathname = usePathname();
|
||||
const currentPage = pathname.split('/').pop();
|
||||
|
||||
const headerTitle = sidebarItems[currentPage]?.name || t('dashboard');
|
||||
|
||||
const softwareName = 'N3WT School';
|
||||
const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`;
|
||||
|
||||
const handleDisconnect = () => {
|
||||
setIsPopupVisible(true);
|
||||
};
|
||||
|
||||
const confirmDisconnect = () => {
|
||||
setIsPopupVisible(false);
|
||||
disconnect();
|
||||
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 = () => {
|
||||
setIsSidebarOpen(!isSidebarOpen);
|
||||
};
|
||||
@ -145,18 +113,30 @@ export default function Layout({ children }) {
|
||||
setIsSidebarOpen(false);
|
||||
}, [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 (
|
||||
<ProtectedRoute requiredRight={[RIGHTS.ADMIN, RIGHTS.TEACHER]}>
|
||||
{/* Topbar mobile (hamburger + logo) */}
|
||||
<MobileTopbar onMenuClick={toggleSidebar} />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`absolute top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
|
||||
className={`absolute top-14 md:top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
|
||||
isSidebarOpen ? 'block' : 'hidden md:block'
|
||||
}`}
|
||||
>
|
||||
<Sidebar
|
||||
establishments={establishments}
|
||||
currentPage={currentPage}
|
||||
items={Object.values(sidebarItems)}
|
||||
items={sidebarItemsToDisplay}
|
||||
onCloseMobile={toggleSidebar}
|
||||
/>
|
||||
</div>
|
||||
@ -170,7 +150,7 @@ export default function Layout({ children }) {
|
||||
)}
|
||||
|
||||
{/* Main container */}
|
||||
<div className="absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-0 bottom-16 left-64 right-0">
|
||||
<div className="absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-14 md:top-0 bottom-16 left-0 md:left-64 right-0">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
|
||||
@ -163,7 +163,7 @@ export default function DashboardPage() {
|
||||
if (isLoading) return <Loader />;
|
||||
|
||||
return (
|
||||
<div key={selectedEstablishmentId} className="p-6">
|
||||
<div key={selectedEstablishmentId} className="p-4 md:p-6">
|
||||
{/* Statistiques principales */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard
|
||||
@ -200,12 +200,12 @@ export default function DashboardPage() {
|
||||
{/* Colonne de gauche : Graphique des inscriptions + Présence */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Graphique des inscriptions */}
|
||||
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
|
||||
<h2 className="text-lg font-semibold mb-6">
|
||||
<div className="bg-stone-50 p-4 md:p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
|
||||
<h2 className="text-lg font-semibold mb-4 md:mb-6">
|
||||
{t('inscriptionTrends')}
|
||||
</h2>
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="flex-1 p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-6 mt-4">
|
||||
<div className="flex-1">
|
||||
<LineChart data={monthlyRegistrations} />
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
@ -214,13 +214,13 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
{/* Présence et assiduité */}
|
||||
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
|
||||
<div className="bg-stone-50 p-4 md:p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
|
||||
<Attendance absences={absencesToday} readOnly={true} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colonne de droite : Événements à venir */}
|
||||
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1 h-full">
|
||||
<div className="bg-stone-50 p-4 md:p-6 rounded-lg shadow-sm border border-gray-100 flex-1 h-full">
|
||||
<h2 className="text-lg font-semibold mb-4">{t('upcomingEvents')}</h2>
|
||||
{upcomingEvents.map((event, index) => (
|
||||
<EventCard key={index} {...event} />
|
||||
|
||||
@ -12,6 +12,7 @@ import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
|
||||
export default function Page() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [eventData, setEventData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
@ -56,13 +57,17 @@ export default function Page() {
|
||||
modeSet={PlanningModes.PLANNING}
|
||||
>
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<ScheduleNavigation />
|
||||
<ScheduleNavigation
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => setIsDrawerOpen(false)}
|
||||
/>
|
||||
<Calendar
|
||||
onDateClick={initializeNewEvent}
|
||||
onEventClick={(event) => {
|
||||
setEventData(event);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
onOpenDrawer={() => setIsDrawerOpen(true)}
|
||||
/>
|
||||
<EventModal
|
||||
isOpen={isModalOpen}
|
||||
|
||||
@ -31,6 +31,7 @@ import { fetchRegistrationSchoolFileMasters } from '@/app/actions/registerFileGr
|
||||
import logger from '@/utils/logger';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { PlanningProvider, PlanningModes } from '@/context/PlanningContext';
|
||||
import { updatePlanning } from '@/app/actions/planningAction';
|
||||
import CompetenciesList from '@/components/Structure/Competencies/CompetenciesList';
|
||||
|
||||
export default function Page() {
|
||||
@ -52,7 +53,7 @@ export default function Page() {
|
||||
);
|
||||
|
||||
const csrfToken = useCsrfToken();
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedEstablishmentId) {
|
||||
@ -259,20 +260,10 @@ export default function Page() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdatePlanning = (url, planningId, updatedData) => {
|
||||
fetch(`${url}/${planningId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: JSON.stringify(updatedData),
|
||||
credentials: 'include',
|
||||
})
|
||||
.then((response) => response.json())
|
||||
const handleUpdatePlanning = (planningId, updatedData) => {
|
||||
updatePlanning(planningId, updatedData, csrfToken)
|
||||
.then((data) => {
|
||||
logger.debug('Planning mis à jour avec succès :', data);
|
||||
//setDatas(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Erreur :', error);
|
||||
@ -316,35 +307,39 @@ export default function Page() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'Fees',
|
||||
label: 'Tarifs',
|
||||
content: (
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
<FeesManagement
|
||||
registrationDiscounts={registrationDiscounts}
|
||||
setRegistrationDiscounts={setRegistrationDiscounts}
|
||||
tuitionDiscounts={tuitionDiscounts}
|
||||
setTuitionDiscounts={setTuitionDiscounts}
|
||||
registrationFees={registrationFees}
|
||||
setRegistrationFees={setRegistrationFees}
|
||||
tuitionFees={tuitionFees}
|
||||
setTuitionFees={setTuitionFees}
|
||||
registrationPaymentPlans={registrationPaymentPlans}
|
||||
setRegistrationPaymentPlans={setRegistrationPaymentPlans}
|
||||
tuitionPaymentPlans={tuitionPaymentPlans}
|
||||
setTuitionPaymentPlans={setTuitionPaymentPlans}
|
||||
registrationPaymentModes={registrationPaymentModes}
|
||||
setRegistrationPaymentModes={setRegistrationPaymentModes}
|
||||
tuitionPaymentModes={tuitionPaymentModes}
|
||||
setTuitionPaymentModes={setTuitionPaymentModes}
|
||||
handleCreate={handleCreate}
|
||||
handleEdit={handleEdit}
|
||||
handleDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...(profileRole !== 0
|
||||
? [
|
||||
{
|
||||
id: 'Fees',
|
||||
label: 'Tarifs',
|
||||
content: (
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
<FeesManagement
|
||||
registrationDiscounts={registrationDiscounts}
|
||||
setRegistrationDiscounts={setRegistrationDiscounts}
|
||||
tuitionDiscounts={tuitionDiscounts}
|
||||
setTuitionDiscounts={setTuitionDiscounts}
|
||||
registrationFees={registrationFees}
|
||||
setRegistrationFees={setRegistrationFees}
|
||||
tuitionFees={tuitionFees}
|
||||
setTuitionFees={setTuitionFees}
|
||||
registrationPaymentPlans={registrationPaymentPlans}
|
||||
setRegistrationPaymentPlans={setRegistrationPaymentPlans}
|
||||
tuitionPaymentPlans={tuitionPaymentPlans}
|
||||
setTuitionPaymentPlans={setTuitionPaymentPlans}
|
||||
registrationPaymentModes={registrationPaymentModes}
|
||||
setRegistrationPaymentModes={setRegistrationPaymentModes}
|
||||
tuitionPaymentModes={tuitionPaymentModes}
|
||||
setTuitionPaymentModes={setTuitionPaymentModes}
|
||||
handleCreate={handleCreate}
|
||||
handleEdit={handleEdit}
|
||||
handleDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'Files',
|
||||
label: 'Documents',
|
||||
@ -353,6 +348,7 @@ export default function Page() {
|
||||
<FilesGroupsManagement
|
||||
csrfToken={csrfToken}
|
||||
selectedEstablishmentId={selectedEstablishmentId}
|
||||
profileRole={profileRole}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
@ -71,6 +71,8 @@ export default function CreateSubscriptionPage() {
|
||||
const registerFormMoment = searchParams.get('school_year');
|
||||
|
||||
const [students, setStudents] = useState([]);
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
const [studentsPage, setStudentsPage] = useState(1);
|
||||
const [registrationDiscounts, setRegistrationDiscounts] = useState([]);
|
||||
const [tuitionDiscounts, setTuitionDiscounts] = useState([]);
|
||||
const [registrationFees, setRegistrationFees] = useState([]);
|
||||
@ -179,6 +181,8 @@ export default function CreateSubscriptionPage() {
|
||||
formDataRef.current = formData;
|
||||
}, [formData]);
|
||||
|
||||
useEffect(() => { setStudentsPage(1); }, [students]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!formData.guardianEmail) {
|
||||
// Si l'email est vide, réinitialiser existingProfileId et existingProfileInSchool
|
||||
@ -709,6 +713,9 @@ export default function CreateSubscriptionPage() {
|
||||
return finalAmount.toFixed(2);
|
||||
};
|
||||
|
||||
const studentsTotalPages = Math.ceil(students.length / ITEMS_PER_PAGE);
|
||||
const pagedStudents = students.slice((studentsPage - 1) * ITEMS_PER_PAGE, studentsPage * ITEMS_PER_PAGE);
|
||||
|
||||
if (isLoading === true) {
|
||||
return <Loader />; // Affichez le composant Loader
|
||||
}
|
||||
@ -869,7 +876,7 @@ export default function CreateSubscriptionPage() {
|
||||
{!isNewResponsable && (
|
||||
<div className="mt-4">
|
||||
<Table
|
||||
data={students}
|
||||
data={pagedStudents}
|
||||
columns={[
|
||||
{
|
||||
name: 'photo',
|
||||
@ -927,6 +934,10 @@ export default function CreateSubscriptionPage() {
|
||||
: ''
|
||||
}
|
||||
selectedRows={selectedStudent ? [selectedStudent.id] : []} // Assurez-vous que selectedRows est un tableau
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
currentPage={studentsPage}
|
||||
totalPages={studentsTotalPages}
|
||||
onPageChange={setStudentsPage}
|
||||
/>
|
||||
|
||||
{selectedStudent && (
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Table from '@/components/Table';
|
||||
import Tab from '@/components/Tab';
|
||||
import Textarea from '@/components/Textarea';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import StatusLabel from '@/components/StatusLabel';
|
||||
import Popup from '@/components/Popup';
|
||||
@ -17,6 +18,7 @@ import {
|
||||
Plus,
|
||||
Upload,
|
||||
Eye,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import Modal from '@/components/Modal';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
@ -83,12 +85,9 @@ export default function Page({ params: { locale } }) {
|
||||
const [totalHistorical, setTotalHistorical] = useState(0);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10); // Définir le nombre d'éléments par page
|
||||
|
||||
const [student, setStudent] = useState('');
|
||||
const [classes, setClasses] = useState([]);
|
||||
const [reloadFetch, setReloadFetch] = useState(false);
|
||||
|
||||
const [isOpenAddGuardian, setIsOpenAddGuardian] = useState(false);
|
||||
|
||||
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
||||
const [selectedRegisterForm, setSelectedRegisterForm] = useState([]);
|
||||
|
||||
@ -99,9 +98,40 @@ export default function Page({ params: { locale } }) {
|
||||
const [isSepaUploadModalOpen, setIsSepaUploadModalOpen] = useState(false);
|
||||
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 router = useRouter();
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const openSepaUploadModal = (row) => {
|
||||
@ -490,10 +520,18 @@ export default function Page({ params: { locale } }) {
|
||||
</span>
|
||||
),
|
||||
onClick: () => {
|
||||
const url = `${FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL}?studentId=${row.student.id}&firstName=${row.student.first_name}&lastName=${row.student.last_name}&level=${row.student.level}&sepa_file=${row.sepa_file}&student_file=${row.registration_file}`;
|
||||
const url = `${FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL}?studentId=${row.student.id}&firstName=${row.student.first_name}&lastName=${row.student.last_name}&email=${row.student.guardians[0].associated_profile_email}&level=${row.student.level}&sepa_file=${row.sepa_file}&student_file=${row.registration_file}`;
|
||||
router.push(`${url}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
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
|
||||
[RegistrationFormStatus.STATUS_TO_FOLLOW_UP]: [
|
||||
@ -801,15 +839,17 @@ export default function Page({ params: { locale } }) {
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`;
|
||||
router.push(url);
|
||||
}}
|
||||
className="flex items-center bg-emerald-600 text-white p-2 rounded-full shadow hover:bg-emerald-900 transition duration-200 ml-4"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
{profileRole !== 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`;
|
||||
router.push(url);
|
||||
}}
|
||||
className="flex items-center bg-emerald-600 text-white p-2 rounded-full shadow hover:bg-emerald-900 transition duration-200 ml-4"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
@ -853,6 +893,25 @@ export default function Page({ params: { locale } }) {
|
||||
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 && (
|
||||
<Modal
|
||||
isOpen={isSepaUploadModalOpen}
|
||||
|
||||
@ -10,8 +10,10 @@ import Loader from '@/components/Loader';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
import { editRegistrationSchoolFileTemplates, editRegistrationParentFileTemplates } from '@/app/actions/registerFileGroupAction';
|
||||
|
||||
export default function Page() {
|
||||
const [isLoadingRefuse, setIsLoadingRefuse] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@ -20,6 +22,7 @@ export default function Page() {
|
||||
const studentId = searchParams.get('studentId');
|
||||
const firstName = searchParams.get('firstName');
|
||||
const lastName = searchParams.get('lastName');
|
||||
const email = searchParams.get('email');
|
||||
const level = searchParams.get('level');
|
||||
const sepa_file =
|
||||
searchParams.get('sepa_file') === 'null'
|
||||
@ -84,6 +87,45 @@ export default function Page() {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const handleRefuseRF = (data) => {
|
||||
const formData = new FormData();
|
||||
formData.append('data', JSON.stringify(data));
|
||||
editRegisterForm(studentId, formData, csrfToken)
|
||||
.then((response) => {
|
||||
logger.debug('RF refusé et archivé:', response);
|
||||
showNotification('Le dossier a été refusé et archivé.', 'success', 'Succès');
|
||||
router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
|
||||
setIsLoadingRefuse(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
showNotification('Erreur lors du refus du dossier.', 'error', 'Erreur');
|
||||
setIsLoadingRefuse(false);
|
||||
logger.error('Erreur lors du refus du RF:', error);
|
||||
});
|
||||
};
|
||||
|
||||
// Validation/refus d'un document individuel (hors fiche élève)
|
||||
const handleValidateOrRefuseDoc = ({ templateId, type, validated, csrfToken }) => {
|
||||
if (!templateId) return;
|
||||
let editFn = null;
|
||||
if (type === 'school') {
|
||||
editFn = editRegistrationSchoolFileTemplates;
|
||||
} else if (type === 'parent') {
|
||||
editFn = editRegistrationParentFileTemplates;
|
||||
}
|
||||
if (!editFn) return;
|
||||
const updateData = new FormData();
|
||||
updateData.append('data', JSON.stringify({ isValidated: validated }));
|
||||
editFn(templateId, updateData, csrfToken)
|
||||
.then((response) => {
|
||||
logger.debug(`Document ${validated ? 'validé' : 'refusé'} (type: ${type}, id: ${templateId})`, response);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Erreur lors de la validation/refus du document:', error);
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
@ -93,10 +135,15 @@ export default function Page() {
|
||||
studentId={studentId}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
email={email}
|
||||
sepa_file={sepa_file}
|
||||
student_file={student_file}
|
||||
onAccept={handleAcceptRF}
|
||||
classes={classes}
|
||||
onRefuse={handleRefuseRF}
|
||||
isLoadingRefuse={isLoadingRefuse}
|
||||
handleValidateOrRefuseDoc={handleValidateOrRefuseDoc}
|
||||
csrfToken={csrfToken}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { MessageSquare, Settings, Home, Menu } from 'lucide-react';
|
||||
import { MessageSquare, Settings, Home } from 'lucide-react';
|
||||
import {
|
||||
FE_PARENTS_HOME_URL,
|
||||
FE_PARENTS_MESSAGERIE_URL
|
||||
@ -11,6 +11,7 @@ import {
|
||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||
import { disconnect } from '@/app/actions/authAction';
|
||||
import Popup from '@/components/Popup';
|
||||
import MobileTopbar from '@/components/MobileTopbar';
|
||||
import { RIGHTS } from '@/utils/rights';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import Footer from '@/components/Footer';
|
||||
@ -73,17 +74,12 @@ export default function Layout({ children }) {
|
||||
|
||||
return (
|
||||
<ProtectedRoute requiredRight={RIGHTS.PARENT}>
|
||||
{/* Bouton hamburger pour mobile */}
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="fixed top-4 left-4 z-40 p-2 rounded-md bg-white shadow-lg border border-gray-200 md:hidden"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
{/* Topbar mobile (hamburger + logo) */}
|
||||
<MobileTopbar onMenuClick={toggleSidebar} />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`absolute top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
|
||||
className={`absolute top-14 md:top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
|
||||
isSidebarOpen ? 'block' : 'hidden md:block'
|
||||
}`}
|
||||
>
|
||||
@ -104,7 +100,7 @@ export default function Layout({ children }) {
|
||||
|
||||
{/* Main container */}
|
||||
<div
|
||||
className={`absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-0 bottom-16 left-0 md:left-64 right-0 ${!isMessagingPage ? 'p-4 md:p-8' : ''}`}
|
||||
className={`absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-14 md:top-0 bottom-16 left-0 md:left-64 right-0 ${!isMessagingPage ? 'p-4 md:p-8' : ''}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,15 @@
|
||||
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
|
||||
@ -6,6 +17,18 @@ import logger from '@/utils/logger';
|
||||
*/
|
||||
export const requestResponseHandler = async (response) => {
|
||||
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();
|
||||
if (response.ok) {
|
||||
return body;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { signOut, signIn } from 'next-auth/react';
|
||||
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||
import { fetchWithAuth } from '@/utils/fetchWithAuth';
|
||||
import {
|
||||
BE_AUTH_LOGIN_URL,
|
||||
BE_AUTH_REFRESH_JWT_URL,
|
||||
@ -73,92 +74,49 @@ export const fetchProfileRoles = (
|
||||
if (page !== '' && pageSize !== '') {
|
||||
url = `${BE_AUTH_PROFILES_ROLES_URL}?filter=${filter}&establishment_id=${establishment}&page=${page}`;
|
||||
}
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
export const updateProfileRoles = (id, data, csrfToken) => {
|
||||
const request = new Request(`${BE_AUTH_PROFILES_ROLES_URL}/${id}`, {
|
||||
return fetchWithAuth(`${BE_AUTH_PROFILES_ROLES_URL}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return fetch(request).then(requestResponseHandler).catch(errorHandler);
|
||||
};
|
||||
|
||||
export const deleteProfileRoles = async (id, csrfToken) => {
|
||||
const response = await fetch(`${BE_AUTH_PROFILES_ROLES_URL}/${id}`, {
|
||||
export const deleteProfileRoles = (id, csrfToken) => {
|
||||
return fetchWithAuth(`${BE_AUTH_PROFILES_ROLES_URL}/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
});
|
||||
|
||||
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 = () => {
|
||||
return fetch(`${BE_AUTH_PROFILES_URL}`)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
return fetchWithAuth(`${BE_AUTH_PROFILES_URL}`);
|
||||
};
|
||||
|
||||
export const createProfile = (data, csrfToken) => {
|
||||
const request = new Request(`${BE_AUTH_PROFILES_URL}`, {
|
||||
return fetchWithAuth(`${BE_AUTH_PROFILES_URL}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return fetch(request).then(requestResponseHandler).catch(errorHandler);
|
||||
};
|
||||
|
||||
export const deleteProfile = (id, csrfToken) => {
|
||||
const request = new Request(`${BE_AUTH_PROFILES_URL}/${id}`, {
|
||||
return fetchWithAuth(`${BE_AUTH_PROFILES_URL}/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
});
|
||||
return fetch(request).then(requestResponseHandler).catch(errorHandler);
|
||||
};
|
||||
|
||||
export const updateProfile = (id, data, csrfToken) => {
|
||||
const request = new Request(`${BE_AUTH_PROFILES_URL}/${id}`, {
|
||||
return fetchWithAuth(`${BE_AUTH_PROFILES_URL}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return fetch(request).then(requestResponseHandler).catch(errorHandler);
|
||||
};
|
||||
|
||||
export const sendNewPassword = (data, csrfToken) => {
|
||||
|
||||
@ -2,33 +2,20 @@ import {
|
||||
BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL,
|
||||
BE_GESTIONEMAIL_SEND_EMAIL_URL,
|
||||
} from '@/utils/Url';
|
||||
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||
import { fetchWithAuth } from '@/utils/fetchWithAuth';
|
||||
import { getCsrfToken } from '@/utils/getCsrfToken';
|
||||
|
||||
// Recherche de destinataires pour email
|
||||
export const searchRecipients = (establishmentId, query) => {
|
||||
const url = `${BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL}/?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`;
|
||||
return fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
// Envoyer un email
|
||||
export const sendEmail = async (messageData) => {
|
||||
const csrfToken = getCsrfToken();
|
||||
return fetch(BE_GESTIONEMAIL_SEND_EMAIL_URL, {
|
||||
return fetchWithAuth(BE_GESTIONEMAIL_SEND_EMAIL_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(messageData),
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
});
|
||||
};
|
||||
|
||||
@ -7,23 +7,9 @@ import {
|
||||
BE_GESTIONMESSAGERIE_UPLOAD_FILE_URL,
|
||||
BE_GESTIONMESSAGERIE_DELETE_CONVERSATION_URL,
|
||||
} from '@/utils/Url';
|
||||
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||
import { fetchWithAuth, getAuthToken } from '@/utils/fetchWithAuth';
|
||||
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
|
||||
*/
|
||||
@ -31,15 +17,12 @@ export const fetchConversations = async (userId, csrfToken) => {
|
||||
try {
|
||||
// Utiliser la nouvelle route avec user_id en paramètre d'URL
|
||||
const url = `${BE_GESTIONMESSAGERIE_CONVERSATIONS_URL}/user/${userId}/`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: buildHeaders(csrfToken),
|
||||
credentials: 'include',
|
||||
return await fetchWithAuth(url, {
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
});
|
||||
return await requestResponseHandler(response);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la récupération des conversations:', error);
|
||||
return errorHandler(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@ -62,15 +45,12 @@ export const fetchMessages = async (
|
||||
url += `&user_id=${userId}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: buildHeaders(csrfToken),
|
||||
credentials: 'include',
|
||||
return await fetchWithAuth(url, {
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
});
|
||||
return await requestResponseHandler(response);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la récupération des messages:', error);
|
||||
return errorHandler(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@ -79,16 +59,14 @@ export const fetchMessages = async (
|
||||
*/
|
||||
export const sendMessage = async (messageData, csrfToken) => {
|
||||
try {
|
||||
const response = await fetch(BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL, {
|
||||
return await fetchWithAuth(BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(csrfToken),
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(messageData),
|
||||
});
|
||||
return await requestResponseHandler(response);
|
||||
} catch (error) {
|
||||
logger.error("Erreur lors de l'envoi du message:", error);
|
||||
return errorHandler(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@ -103,17 +81,14 @@ export const createConversation = async (participantIds, csrfToken) => {
|
||||
name: '', // Le nom sera généré côté backend
|
||||
};
|
||||
|
||||
const response = await fetch(BE_GESTIONMESSAGERIE_CREATE_CONVERSATION_URL, {
|
||||
return await fetchWithAuth(BE_GESTIONMESSAGERIE_CREATE_CONVERSATION_URL, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(csrfToken),
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
return await requestResponseHandler(response);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la création de la conversation:', error);
|
||||
return errorHandler(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@ -132,16 +107,12 @@ export const searchMessagerieRecipients = async (
|
||||
|
||||
const url = `${baseUrl}?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: buildHeaders(csrfToken),
|
||||
credentials: 'include',
|
||||
return await fetchWithAuth(url, {
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
});
|
||||
|
||||
return await requestResponseHandler(response);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la recherche des destinataires:', error);
|
||||
return errorHandler(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@ -150,19 +121,17 @@ export const searchMessagerieRecipients = async (
|
||||
*/
|
||||
export const markAsRead = async (conversationId, userId, csrfToken) => {
|
||||
try {
|
||||
const response = await fetch(BE_GESTIONMESSAGERIE_MARK_AS_READ_URL, {
|
||||
return await fetchWithAuth(BE_GESTIONMESSAGERIE_MARK_AS_READ_URL, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(csrfToken),
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify({
|
||||
conversation_id: conversationId,
|
||||
user_id: userId,
|
||||
}),
|
||||
});
|
||||
return await requestResponseHandler(response);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors du marquage des messages comme lus:', error);
|
||||
return errorHandler(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@ -181,6 +150,7 @@ export const uploadFile = async (
|
||||
formData.append('conversation_id', conversationId);
|
||||
formData.append('sender_id', senderId);
|
||||
|
||||
const token = await getAuthToken();
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
@ -223,7 +193,10 @@ export const uploadFile = async (
|
||||
xhr.withCredentials = true;
|
||||
xhr.timeout = 30000;
|
||||
|
||||
// Ajouter le header CSRF pour XMLHttpRequest
|
||||
// Ajouter les headers d'authentification pour XMLHttpRequest
|
||||
if (token) {
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
if (csrfToken) {
|
||||
xhr.setRequestHeader('X-CSRFToken', csrfToken);
|
||||
}
|
||||
@ -238,14 +211,12 @@ export const uploadFile = async (
|
||||
export const deleteConversation = async (conversationId, csrfToken) => {
|
||||
try {
|
||||
const url = `${BE_GESTIONMESSAGERIE_DELETE_CONVERSATION_URL}/${conversationId}/`;
|
||||
const response = await fetch(url, {
|
||||
return await fetchWithAuth(url, {
|
||||
method: 'DELETE',
|
||||
headers: buildHeaders(csrfToken),
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
});
|
||||
return await requestResponseHandler(response);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la suppression de la conversation:', error);
|
||||
return errorHandler(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,49 +1,31 @@
|
||||
import { BE_PLANNING_PLANNINGS_URL, BE_PLANNING_EVENTS_URL } from '@/utils/Url';
|
||||
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||
import { fetchWithAuth } from '@/utils/fetchWithAuth';
|
||||
|
||||
const getData = (url) => {
|
||||
return fetch(`${url}`).then(requestResponseHandler).catch(errorHandler);
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
const createDatas = (url, newData, csrfToken) => {
|
||||
return fetch(url, {
|
||||
return fetchWithAuth(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(newData),
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
});
|
||||
};
|
||||
|
||||
const updateDatas = (url, updatedData, csrfToken) => {
|
||||
return fetch(`${url}`, {
|
||||
return fetchWithAuth(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(updatedData),
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
});
|
||||
};
|
||||
|
||||
const removeDatas = (url, csrfToken) => {
|
||||
return fetch(`${url}`, {
|
||||
return fetchWithAuth(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchPlannings = (
|
||||
|
||||
@ -5,213 +5,113 @@ import {
|
||||
BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL,
|
||||
BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL
|
||||
} from '@/utils/Url';
|
||||
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||
import { fetchWithAuth, fetchWithAuthRaw } from '@/utils/fetchWithAuth';
|
||||
|
||||
// FETCH requests
|
||||
|
||||
export async function fetchRegistrationFileGroups(establishment) {
|
||||
const response = await fetch(
|
||||
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}?establishment_id=${establishment}`,
|
||||
{
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
return fetchWithAuth(
|
||||
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}?establishment_id=${establishment}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch file groups');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const fetchRegistrationFileFromGroup = async (groupId) => {
|
||||
const response = await fetch(
|
||||
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}/${groupId}/school_file_templates`,
|
||||
{
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
export const fetchRegistrationFileFromGroup = (groupId) => {
|
||||
return fetchWithAuth(
|
||||
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}/${groupId}/school_file_templates`
|
||||
);
|
||||
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) => {
|
||||
let url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}?establishment_id=${establishment}`;
|
||||
const request = new Request(`${url}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
return fetch(request).then(requestResponseHandler).catch(errorHandler);
|
||||
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}?establishment_id=${establishment}`;
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
export const fetchRegistrationParentFileMasters = (establishment) => {
|
||||
let url = `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}?establishment_id=${establishment}`;
|
||||
const request = new Request(`${url}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
return fetch(request).then(requestResponseHandler).catch(errorHandler);
|
||||
const url = `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}?establishment_id=${establishment}`;
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
export const fetchRegistrationSchoolFileTemplates = (establishment) => {
|
||||
let url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}?establishment_id=${establishment}`;
|
||||
const request = new Request(`${url}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
return fetch(request).then(requestResponseHandler).catch(errorHandler);
|
||||
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}?establishment_id=${establishment}`;
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
// CREATE requests
|
||||
|
||||
export async function createRegistrationFileGroup(groupData, csrfToken) {
|
||||
const response = await fetch(
|
||||
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: JSON.stringify(groupData),
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create file group');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(groupData),
|
||||
});
|
||||
}
|
||||
|
||||
export const createRegistrationSchoolFileMaster = (data, csrfToken) => {
|
||||
// Toujours FormData, jamais JSON
|
||||
return fetch(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}`, {
|
||||
return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: data,
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
});
|
||||
};
|
||||
|
||||
export const createRegistrationParentFileMaster = (data, csrfToken) => {
|
||||
return fetch(`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}`, {
|
||||
return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
});
|
||||
};
|
||||
|
||||
export const createRegistrationSchoolFileTemplate = (data, csrfToken) => {
|
||||
return fetch(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}`, {
|
||||
return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
});
|
||||
};
|
||||
|
||||
export const createRegistrationParentFileTemplate = (data, csrfToken) => {
|
||||
return fetch(`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL}`, {
|
||||
return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
});
|
||||
};
|
||||
|
||||
// EDIT requests
|
||||
|
||||
export const editRegistrationFileGroup = async (
|
||||
groupId,
|
||||
groupData,
|
||||
csrfToken
|
||||
) => {
|
||||
const response = await fetch(
|
||||
export const editRegistrationFileGroup = (groupId, groupData, csrfToken) => {
|
||||
return fetchWithAuth(
|
||||
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}/${groupId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
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) => {
|
||||
return fetch(
|
||||
return fetchWithAuth(
|
||||
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${fileId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: data,
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
credentials: 'include',
|
||||
}
|
||||
)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
);
|
||||
};
|
||||
|
||||
export const editRegistrationParentFileMaster = (id, data, csrfToken) => {
|
||||
return fetch(
|
||||
return fetchWithAuth(
|
||||
`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}/${id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
}
|
||||
)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
);
|
||||
};
|
||||
|
||||
export const editRegistrationSchoolFileTemplates = (
|
||||
@ -219,19 +119,14 @@ export const editRegistrationSchoolFileTemplates = (
|
||||
data,
|
||||
csrfToken
|
||||
) => {
|
||||
return fetch(
|
||||
return fetchWithAuth(
|
||||
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${fileId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: data,
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
credentials: 'include',
|
||||
}
|
||||
)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
);
|
||||
};
|
||||
|
||||
export const editRegistrationParentFileTemplates = (
|
||||
@ -239,86 +134,64 @@ export const editRegistrationParentFileTemplates = (
|
||||
data,
|
||||
csrfToken
|
||||
) => {
|
||||
return fetch(
|
||||
return fetchWithAuth(
|
||||
`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL}/${fileId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: data,
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
credentials: 'include',
|
||||
}
|
||||
)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
);
|
||||
};
|
||||
|
||||
// DELETE requests
|
||||
|
||||
export async function deleteRegistrationFileGroup(groupId, csrfToken) {
|
||||
const response = await fetch(
|
||||
return fetchWithAuthRaw(
|
||||
`${BE_SUBSCRIPTION_REGISTRATIONFILE_GROUPS_URL}/${groupId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const deleteRegistrationSchoolFileMaster = (fileId, csrfToken) => {
|
||||
return fetch(
|
||||
return fetchWithAuthRaw(
|
||||
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${fileId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteRegistrationParentFileMaster = (id, csrfToken) => {
|
||||
return fetch(
|
||||
return fetchWithAuthRaw(
|
||||
`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}/${id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteRegistrationSchoolFileTemplates = (fileId, csrfToken) => {
|
||||
return fetch(
|
||||
return fetchWithAuthRaw(
|
||||
`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${fileId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteRegistrationParentFileTemplate = (id, csrfToken) => {
|
||||
return fetch(
|
||||
return fetchWithAuthRaw(
|
||||
`${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_TEMPLATES_URL}/${id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@ -10,185 +10,125 @@ import {
|
||||
BE_SCHOOL_ESTABLISHMENT_URL,
|
||||
BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL,
|
||||
} from '@/utils/Url';
|
||||
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||
import { fetchWithAuth } from '@/utils/fetchWithAuth';
|
||||
|
||||
export const deleteEstablishmentCompetencies = (ids, csrfToken) => {
|
||||
return fetch(BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, {
|
||||
return fetchWithAuth(BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify({ ids }),
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
});
|
||||
};
|
||||
|
||||
export const createEstablishmentCompetencies = (newData, csrfToken) => {
|
||||
return fetch(BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, {
|
||||
return fetchWithAuth(BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(newData),
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchEstablishmentCompetencies = (establishment, cycle = 1) => {
|
||||
return fetch(
|
||||
return fetchWithAuth(
|
||||
`${BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL}?establishment_id=${establishment}&cycle=${cycle}`
|
||||
)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
};
|
||||
|
||||
export const fetchSpecialities = (establishment) => {
|
||||
return fetch(
|
||||
`${BE_SCHOOL_SPECIALITIES_URL}?establishment_id=${establishment}`
|
||||
)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
};
|
||||
|
||||
export const fetchTeachers = (establishment) => {
|
||||
return fetch(`${BE_SCHOOL_TEACHERS_URL}?establishment_id=${establishment}`)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
};
|
||||
|
||||
export const fetchClasses = (establishment) => {
|
||||
return fetch(
|
||||
`${BE_SCHOOL_SCHOOLCLASSES_URL}?establishment_id=${establishment}`
|
||||
)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
};
|
||||
|
||||
export const fetchClasse = (id) => {
|
||||
return fetch(`${BE_SCHOOL_SCHOOLCLASSES_URL}/${id}`).then(
|
||||
requestResponseHandler
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchSpecialities = (establishment) => {
|
||||
return fetchWithAuth(
|
||||
`${BE_SCHOOL_SPECIALITIES_URL}?establishment_id=${establishment}`
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchTeachers = (establishment) => {
|
||||
return fetchWithAuth(`${BE_SCHOOL_TEACHERS_URL}?establishment_id=${establishment}`);
|
||||
};
|
||||
|
||||
export const fetchClasses = (establishment) => {
|
||||
return fetchWithAuth(
|
||||
`${BE_SCHOOL_SCHOOLCLASSES_URL}?establishment_id=${establishment}`
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchClasse = (id) => {
|
||||
return fetchWithAuth(`${BE_SCHOOL_SCHOOLCLASSES_URL}/${id}`);
|
||||
};
|
||||
|
||||
export const fetchSchedules = () => {
|
||||
return fetch(`${BE_SCHOOL_PLANNINGS_URL}`)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
return fetchWithAuth(`${BE_SCHOOL_PLANNINGS_URL}`);
|
||||
};
|
||||
|
||||
export const fetchRegistrationDiscounts = (establishment) => {
|
||||
return fetch(
|
||||
return fetchWithAuth(
|
||||
`${BE_SCHOOL_DISCOUNTS_URL}?filter=registration&establishment_id=${establishment}`
|
||||
)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchTuitionDiscounts = (establishment) => {
|
||||
return fetch(
|
||||
return fetchWithAuth(
|
||||
`${BE_SCHOOL_DISCOUNTS_URL}?filter=tuition&establishment_id=${establishment}`
|
||||
)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchRegistrationFees = (establishment) => {
|
||||
return fetch(
|
||||
return fetchWithAuth(
|
||||
`${BE_SCHOOL_FEES_URL}?filter=registration&establishment_id=${establishment}`
|
||||
)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchTuitionFees = (establishment) => {
|
||||
return fetch(
|
||||
return fetchWithAuth(
|
||||
`${BE_SCHOOL_FEES_URL}?filter=tuition&establishment_id=${establishment}`
|
||||
)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchRegistrationPaymentPlans = (establishment) => {
|
||||
return fetch(
|
||||
return fetchWithAuth(
|
||||
`${BE_SCHOOL_PAYMENT_PLANS_URL}?filter=registration&establishment_id=${establishment}`
|
||||
)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchTuitionPaymentPlans = (establishment) => {
|
||||
return fetch(
|
||||
return fetchWithAuth(
|
||||
`${BE_SCHOOL_PAYMENT_PLANS_URL}?filter=tuition&establishment_id=${establishment}`
|
||||
)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchRegistrationPaymentModes = (establishment) => {
|
||||
return fetch(
|
||||
return fetchWithAuth(
|
||||
`${BE_SCHOOL_PAYMENT_MODES_URL}?filter=registration&establishment_id=${establishment}`
|
||||
)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchTuitionPaymentModes = (establishment) => {
|
||||
return fetch(
|
||||
return fetchWithAuth(
|
||||
`${BE_SCHOOL_PAYMENT_MODES_URL}?filter=tuition&establishment_id=${establishment}`
|
||||
)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchEstablishment = (establishment) => {
|
||||
return fetch(`${BE_SCHOOL_ESTABLISHMENT_URL}/${establishment}`)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
return fetchWithAuth(`${BE_SCHOOL_ESTABLISHMENT_URL}/${establishment}`);
|
||||
};
|
||||
|
||||
export const createDatas = (url, newData, csrfToken) => {
|
||||
return fetch(url, {
|
||||
return fetchWithAuth(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(newData),
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
});
|
||||
};
|
||||
|
||||
export const updateDatas = (url, id, updatedData, csrfToken) => {
|
||||
return fetch(`${url}/${id}`, {
|
||||
return fetchWithAuth(`${url}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(updatedData),
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
});
|
||||
};
|
||||
|
||||
export const removeDatas = (url, id, csrfToken) => {
|
||||
return fetch(`${url}/${id}`, {
|
||||
return fetchWithAuth(`${url}/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { BE_SETTINGS_SMTP_URL } from '@/utils/Url';
|
||||
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||
import { fetchWithAuth } from '@/utils/fetchWithAuth';
|
||||
|
||||
export const PENDING = 'pending';
|
||||
export const SUBSCRIBED = 'subscribed';
|
||||
@ -10,26 +10,15 @@ export const fetchSmtpSettings = (csrfToken, establishment_id = null) => {
|
||||
if (establishment_id) {
|
||||
url += `?establishment_id=${establishment_id}`;
|
||||
}
|
||||
return fetch(`${url}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
return fetchWithAuth(url, {
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
});
|
||||
};
|
||||
|
||||
export const editSmtpSettings = (data, csrfToken) => {
|
||||
return fetch(`${BE_SETTINGS_SMTP_URL}/`, {
|
||||
return fetchWithAuth(`${BE_SETTINGS_SMTP_URL}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(data),
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
});
|
||||
};
|
||||
|
||||
@ -11,20 +11,15 @@ import {
|
||||
} from '@/utils/Url';
|
||||
|
||||
import { CURRENT_YEAR_FILTER } from '@/utils/constants';
|
||||
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||
import { fetchWithAuth, fetchWithAuthRaw } from '@/utils/fetchWithAuth';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
export const editStudentCompetencies = (data, csrfToken) => {
|
||||
const request = new Request(`${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}`, {
|
||||
return fetchWithAuth(`${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
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) => {
|
||||
@ -33,13 +28,7 @@ export const fetchStudentCompetencies = (id, period) => {
|
||||
? `${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}?student_id=${id}&period=${period}`
|
||||
: `${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}?student_id=${id}`;
|
||||
|
||||
const request = new Request(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
return fetch(request).then(requestResponseHandler).catch(errorHandler);
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
export const fetchRegisterForms = (
|
||||
@ -53,37 +42,22 @@ export const fetchRegisterForms = (
|
||||
if (page !== '' && pageSize !== '') {
|
||||
url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}?filter=${filter}&establishment_id=${establishment}&page=${page}&search=${search}`;
|
||||
}
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
export const fetchRegisterForm = (id) => {
|
||||
return fetch(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`) // Utilisation de studentId au lieu de codeDI
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`); // Utilisation de studentId au lieu de codeDI
|
||||
};
|
||||
export const fetchLastGuardian = () => {
|
||||
return fetch(`${BE_SUBSCRIPTION_LAST_GUARDIAN_ID_URL}`)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
return fetchWithAuth(`${BE_SUBSCRIPTION_LAST_GUARDIAN_ID_URL}`);
|
||||
};
|
||||
|
||||
export const editRegisterForm = (id, data, csrfToken) => {
|
||||
return fetch(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`, {
|
||||
return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: data,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
});
|
||||
};
|
||||
|
||||
export const autoSaveRegisterForm = async (id, data, csrfToken) => {
|
||||
@ -106,15 +80,12 @@ export const autoSaveRegisterForm = async (id, data, csrfToken) => {
|
||||
}
|
||||
autoSaveData.append('auto_save', 'true');
|
||||
|
||||
return fetch(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`, {
|
||||
return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}`, {
|
||||
method: 'PATCH', // Utiliser PATCH pour les mises à jour partielles
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: autoSaveData,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.then(() => {})
|
||||
.catch(() => {
|
||||
// Silent fail pour l'auto-save
|
||||
logger.debug('Auto-save failed silently');
|
||||
@ -127,62 +98,30 @@ export const autoSaveRegisterForm = async (id, data, csrfToken) => {
|
||||
|
||||
export const createRegisterForm = (data, csrfToken) => {
|
||||
const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}`;
|
||||
return fetch(url, {
|
||||
return fetchWithAuth(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(data),
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
});
|
||||
};
|
||||
|
||||
export const sendRegisterForm = (id) => {
|
||||
const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/send`;
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
export const resendRegisterForm = (id) => {
|
||||
const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/resend`;
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
export const archiveRegisterForm = (id) => {
|
||||
const url = `${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/archive`;
|
||||
return fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
export const searchStudents = (establishmentId, query) => {
|
||||
const url = `${BE_SUBSCRIPTION_SEARCH_STUDENTS_URL}/?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`;
|
||||
return fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
export const fetchStudents = (establishment, id = null, status = null) => {
|
||||
@ -195,153 +134,68 @@ export const fetchStudents = (establishment, id = null, status = null) => {
|
||||
url += `&status=${status}`;
|
||||
}
|
||||
}
|
||||
const request = new Request(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
return fetch(request).then(requestResponseHandler).catch(errorHandler);
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
export const fetchChildren = (id, establishment) => {
|
||||
const request = new Request(
|
||||
`${BE_SUBSCRIPTION_CHILDRENS_URL}/${id}?establishment_id=${establishment}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
return fetchWithAuth(
|
||||
`${BE_SUBSCRIPTION_CHILDRENS_URL}/${id}?establishment_id=${establishment}`
|
||||
);
|
||||
return fetch(request).then(requestResponseHandler).catch(errorHandler);
|
||||
};
|
||||
|
||||
export async function getRegisterFormFileTemplate(fileId) {
|
||||
const response = await fetch(
|
||||
`${BE_SUBSCRIPTION_REGISTERFORM_FILE_TEMPLATE_URL}/${fileId}`,
|
||||
{
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
return fetchWithAuth(
|
||||
`${BE_SUBSCRIPTION_REGISTERFORM_FILE_TEMPLATE_URL}/${fileId}`
|
||||
);
|
||||
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',
|
||||
},
|
||||
}
|
||||
export const fetchSchoolFileTemplatesFromRegistrationFiles = (id) => {
|
||||
return fetchWithAuth(
|
||||
`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/school_file_templates`
|
||||
);
|
||||
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',
|
||||
},
|
||||
}
|
||||
export const fetchParentFileTemplatesFromRegistrationFiles = (id) => {
|
||||
return fetchWithAuth(
|
||||
`${BE_SUBSCRIPTION_REGISTERFORMS_URL}/${id}/parent_file_templates`
|
||||
);
|
||||
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(
|
||||
export const dissociateGuardian = (studentId, guardianId) => {
|
||||
return fetchWithAuth(
|
||||
`${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) => {
|
||||
return fetch(
|
||||
return fetchWithAuth(
|
||||
`${BE_SUBSCRIPTION_ABSENCES_URL}?establishment_id=${establishment}`
|
||||
)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
);
|
||||
};
|
||||
|
||||
export const createAbsences = (data, csrfToken) => {
|
||||
return fetch(`${BE_SUBSCRIPTION_ABSENCES_URL}`, {
|
||||
return fetchWithAuth(`${BE_SUBSCRIPTION_ABSENCES_URL}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
});
|
||||
};
|
||||
|
||||
export const editAbsences = (absenceId, payload, csrfToken) => {
|
||||
return fetch(`${BE_SUBSCRIPTION_ABSENCES_URL}/${absenceId}`, {
|
||||
return fetchWithAuth(`${BE_SUBSCRIPTION_ABSENCES_URL}/${absenceId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'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();
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteAbsences = (id, csrfToken) => {
|
||||
return fetch(`${BE_SUBSCRIPTION_ABSENCES_URL}/${id}`, {
|
||||
return fetchWithAuthRaw(`${BE_SUBSCRIPTION_ABSENCES_URL}/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
});
|
||||
};
|
||||
|
||||
@ -352,16 +206,7 @@ export const deleteAbsences = (id, csrfToken) => {
|
||||
*/
|
||||
export const fetchRegistrationSchoolFileMasters = (establishmentId) => {
|
||||
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}?establishment_id=${establishmentId}`;
|
||||
|
||||
return fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -373,22 +218,14 @@ export const fetchRegistrationSchoolFileMasters = (establishmentId) => {
|
||||
*/
|
||||
export const saveFormResponses = (templateId, formTemplateData, csrfToken) => {
|
||||
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${templateId}`;
|
||||
|
||||
const payload = {
|
||||
formTemplateData: formTemplateData,
|
||||
};
|
||||
|
||||
return fetch(url, {
|
||||
return fetchWithAuth(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
headers: { 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify(payload),
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@ -398,14 +235,5 @@ export const saveFormResponses = (templateId, formTemplateData, csrfToken) => {
|
||||
*/
|
||||
export const fetchFormResponses = (templateId) => {
|
||||
const url = `${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_TEMPLATES_URL}/${templateId}`;
|
||||
|
||||
return fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
return fetchWithAuth(url);
|
||||
};
|
||||
|
||||
@ -1,12 +1,19 @@
|
||||
import React from 'react';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import Providers from '@/components/Providers';
|
||||
import ServiceWorkerRegister from '@/components/ServiceWorkerRegister';
|
||||
import '@/css/tailwind.css';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
export const metadata = {
|
||||
title: 'N3WT-SCHOOL',
|
||||
description: "Gestion de l'école",
|
||||
manifest: '/manifest.webmanifest',
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'default',
|
||||
title: 'N3WT School',
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{
|
||||
@ -14,10 +21,11 @@ export const metadata = {
|
||||
type: 'image/svg+xml',
|
||||
},
|
||||
{
|
||||
url: '/favicon.ico', // Fallback pour les anciens navigateurs
|
||||
url: '/favicon.ico',
|
||||
sizes: 'any',
|
||||
},
|
||||
],
|
||||
apple: '/icons/icon.svg',
|
||||
},
|
||||
};
|
||||
|
||||
@ -32,6 +40,7 @@ export default async function RootLayout({ children, params }) {
|
||||
<Providers messages={messages} locale={locale} session={params.session}>
|
||||
{children}
|
||||
</Providers>
|
||||
<ServiceWorkerRegister />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
26
Front-End/src/app/manifest.js
Normal file
26
Front-End/src/app/manifest.js
Normal file
@ -0,0 +1,26 @@
|
||||
export default function manifest() {
|
||||
return {
|
||||
name: 'N3WT School',
|
||||
short_name: 'N3WT School',
|
||||
description: "Gestion de l'école",
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#f0fdf4',
|
||||
theme_color: '#10b981',
|
||||
orientation: 'portrait',
|
||||
icons: [
|
||||
{
|
||||
src: '/icons/icon.svg',
|
||||
sizes: 'any',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any',
|
||||
},
|
||||
{
|
||||
src: '/icons/icon.svg',
|
||||
sizes: 'any',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@ -4,6 +4,7 @@ import WeekView from '@/components/Calendar/WeekView';
|
||||
import MonthView from '@/components/Calendar/MonthView';
|
||||
import YearView from '@/components/Calendar/YearView';
|
||||
import PlanningView from '@/components/Calendar/PlanningView';
|
||||
import DayView from '@/components/Calendar/DayView';
|
||||
import ToggleView from '@/components/ToggleView';
|
||||
import { ChevronLeft, ChevronRight, Plus, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
@ -11,9 +12,11 @@ import {
|
||||
addWeeks,
|
||||
addMonths,
|
||||
addYears,
|
||||
addDays,
|
||||
subWeeks,
|
||||
subMonths,
|
||||
subYears,
|
||||
subDays,
|
||||
getWeek,
|
||||
setMonth,
|
||||
setYear,
|
||||
@ -22,7 +25,7 @@ import { fr } from 'date-fns/locale';
|
||||
import { AnimatePresence, motion } from 'framer-motion'; // Ajouter cet import
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' }) => {
|
||||
const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName = '', onOpenDrawer = () => {} }) => {
|
||||
const {
|
||||
currentDate,
|
||||
setCurrentDate,
|
||||
@ -35,6 +38,14 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
||||
} = usePlanning();
|
||||
const [visibleEvents, setVisibleEvents] = useState([]);
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 768);
|
||||
check();
|
||||
window.addEventListener('resize', check);
|
||||
return () => window.removeEventListener('resize', check);
|
||||
}, []);
|
||||
|
||||
// Ajouter ces fonctions pour la gestion des mois et années
|
||||
const months = Array.from({ length: 12 }, (_, i) => ({
|
||||
@ -68,7 +79,12 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
||||
|
||||
const navigateDate = (direction) => {
|
||||
const getNewDate = () => {
|
||||
switch (viewType) {
|
||||
const effectiveView = isMobile ? 'day' : viewType;
|
||||
switch (effectiveView) {
|
||||
case 'day':
|
||||
return direction === 'next'
|
||||
? addDays(currentDate, 1)
|
||||
: subDays(currentDate, 1);
|
||||
case 'week':
|
||||
return direction === 'next'
|
||||
? addWeeks(currentDate, 1)
|
||||
@ -91,116 +107,114 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 bg-white sticky top-0 z-30 border-b shadow-sm h-[64px]">
|
||||
{/* Navigation à gauche */}
|
||||
{planningMode === PlanningModes.PLANNING && (
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setCurrentDate(new Date())}
|
||||
className="px-3 py-1.5 text-sm font-medium text-gray-700 hover:text-gray-900 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
Aujourd'hui
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigateDate('prev')}
|
||||
className="p-2 hover:bg-gray-100 rounded-full"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowDatePicker(!showDatePicker)}
|
||||
className="flex items-center gap-1 px-2 py-1 hover:bg-gray-100 rounded-md"
|
||||
>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{format(
|
||||
currentDate,
|
||||
viewType === 'year' ? 'yyyy' : 'MMMM yyyy',
|
||||
{ locale: fr }
|
||||
)}
|
||||
</h2>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
{showDatePicker && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 w-64">
|
||||
{viewType !== 'year' && (
|
||||
<div className="p-2 border-b">
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{months.map((month) => (
|
||||
<button
|
||||
key={month.value}
|
||||
onClick={() => handleMonthSelect(month.value)}
|
||||
className="p-2 text-sm hover:bg-gray-100 rounded-md"
|
||||
>
|
||||
{month.label}
|
||||
</button>
|
||||
))}
|
||||
{/* Header uniquement sur desktop */}
|
||||
<div className="hidden md:flex items-center justify-between p-4 bg-white sticky top-0 z-30 border-b shadow-sm h-[64px]">
|
||||
<>
|
||||
{planningMode === PlanningModes.PLANNING && (
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setCurrentDate(new Date())}
|
||||
className="px-3 py-1.5 text-sm font-medium text-gray-700 hover:text-gray-900 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
Aujourd'hui
|
||||
</button>
|
||||
<button onClick={() => navigateDate('prev')} className="p-2 hover:bg-gray-100 rounded-full">
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowDatePicker(!showDatePicker)}
|
||||
className="flex items-center gap-1 px-2 py-1 hover:bg-gray-100 rounded-md"
|
||||
>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{format(currentDate, viewType === 'year' ? 'yyyy' : 'MMMM yyyy', { locale: fr })}
|
||||
</h2>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
{showDatePicker && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 w-64">
|
||||
{viewType !== 'year' && (
|
||||
<div className="p-2 border-b">
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{months.map((month) => (
|
||||
<button key={month.value} onClick={() => handleMonthSelect(month.value)} className="p-2 text-sm hover:bg-gray-100 rounded-md">
|
||||
{month.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-2">
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{years.map((year) => (
|
||||
<button key={year.value} onClick={() => handleYearSelect(year.value)} className="p-2 text-sm hover:bg-gray-100 rounded-md">
|
||||
{year.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-2">
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{years.map((year) => (
|
||||
<button
|
||||
key={year.value}
|
||||
onClick={() => handleYearSelect(year.value)}
|
||||
className="p-2 text-sm hover:bg-gray-100 rounded-md"
|
||||
>
|
||||
{year.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => navigateDate('next')} className="p-2 hover:bg-gray-100 rounded-full">
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex justify-center">
|
||||
{((planningMode === PlanningModes.PLANNING || planningMode === PlanningModes.CLASS_SCHEDULE) && viewType === 'week' && !parentView) && (
|
||||
<div className="flex items-center gap-1 text-sm font-medium text-gray-600">
|
||||
<span>Semaine</span>
|
||||
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||
{getWeek(currentDate, { weekStartsOn: 1 })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{parentView && (
|
||||
<span className="px-2 py-1 bg-gray-100 rounded-md text-base font-semibold">
|
||||
{planningClassName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigateDate('next')}
|
||||
className="p-2 hover:bg-gray-100 rounded-full"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Centre : numéro de semaine ou classe/niveau */}
|
||||
<div className="flex-1 flex justify-center">
|
||||
{((planningMode === PlanningModes.PLANNING || planningMode === PlanningModes.CLASS_SCHEDULE) && viewType === 'week' && !parentView) && (
|
||||
<div className="flex items-center gap-1 text-sm font-medium text-gray-600">
|
||||
<span>Semaine</span>
|
||||
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||
{getWeek(currentDate, { weekStartsOn: 1 })}
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
{planningMode === PlanningModes.PLANNING && (
|
||||
<ToggleView viewType={viewType} setViewType={setViewType} />
|
||||
)}
|
||||
{(planningMode === PlanningModes.PLANNING || (planningMode === PlanningModes.CLASS_SCHEDULE && !parentView)) && (
|
||||
<button
|
||||
onClick={onDateClick}
|
||||
className="w-10 h-10 flex items-center justify-center bg-emerald-600 text-white rounded-full hover:bg-emerald-700 shadow-md transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{parentView && (
|
||||
<span className="px-2 py-1 bg-gray-100 rounded-md text-base font-semibold">
|
||||
{/* À adapter selon les props disponibles */}
|
||||
{planningClassName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contrôles à droite */}
|
||||
<div className="flex items-center gap-4">
|
||||
{planningMode === PlanningModes.PLANNING && (
|
||||
<ToggleView viewType={viewType} setViewType={setViewType} />
|
||||
)}
|
||||
{(planningMode === PlanningModes.PLANNING || (planningMode === PlanningModes.CLASS_SCHEDULE && !parentView)) && (
|
||||
<button
|
||||
onClick={onDateClick}
|
||||
className="w-10 h-10 flex items-center justify-center bg-emerald-600 text-white rounded-full hover:bg-emerald-700 shadow-md transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
|
||||
{/* Contenu scrollable */}
|
||||
<div className="flex-1 max-h-[calc(100vh-192px)] overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
{viewType === 'week' && (
|
||||
{isMobile && (
|
||||
<motion.div
|
||||
key="day"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
<DayView
|
||||
onDateClick={onDateClick}
|
||||
onEventClick={onEventClick}
|
||||
events={visibleEvents}
|
||||
onOpenDrawer={onOpenDrawer}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{!isMobile && viewType === 'week' && (
|
||||
<motion.div
|
||||
key="week"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -216,7 +230,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{viewType === 'month' && (
|
||||
{!isMobile && viewType === 'month' && (
|
||||
<motion.div
|
||||
key="month"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -231,7 +245,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{viewType === 'year' && (
|
||||
{!isMobile && viewType === 'year' && (
|
||||
<motion.div
|
||||
key="year"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -242,7 +256,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
|
||||
<YearView onDateClick={onDateClick} events={visibleEvents} />
|
||||
</motion.div>
|
||||
)}
|
||||
{viewType === 'planning' && (
|
||||
{!isMobile && viewType === 'planning' && (
|
||||
<motion.div
|
||||
key="planning"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
|
||||
230
Front-End/src/components/Calendar/DayView.js
Normal file
230
Front-End/src/components/Calendar/DayView.js
Normal file
@ -0,0 +1,230 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { usePlanning } from '@/context/PlanningContext';
|
||||
import {
|
||||
format,
|
||||
startOfWeek,
|
||||
addDays,
|
||||
subDays,
|
||||
isSameDay,
|
||||
isToday,
|
||||
} from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
import { getWeekEvents } from '@/utils/events';
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, Plus } from 'lucide-react';
|
||||
|
||||
const DayView = ({ onDateClick, onEventClick, events, onOpenDrawer }) => {
|
||||
const { currentDate, setCurrentDate, parentView } = usePlanning();
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
const timeSlots = Array.from({ length: 24 }, (_, i) => i);
|
||||
const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 });
|
||||
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
|
||||
const isCurrentDay = isSameDay(currentDate, new Date());
|
||||
const dayEvents = getWeekEvents(currentDate, events) || [];
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setCurrentTime(new Date()), 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current && isCurrentDay) {
|
||||
const currentHour = new Date().getHours();
|
||||
setTimeout(() => {
|
||||
scrollRef.current.scrollTop = currentHour * 80 - 200;
|
||||
}, 0);
|
||||
}
|
||||
}, [currentDate, isCurrentDay]);
|
||||
|
||||
const getCurrentTimePosition = () => {
|
||||
const hours = currentTime.getHours();
|
||||
const minutes = currentTime.getMinutes();
|
||||
return `${(hours + minutes / 60) * 5}rem`;
|
||||
};
|
||||
|
||||
const calculateEventStyle = (event, allDayEvents) => {
|
||||
const start = new Date(event.start);
|
||||
const end = new Date(event.end);
|
||||
const startMinutes = (start.getMinutes() / 60) * 5;
|
||||
const duration = ((end - start) / (1000 * 60 * 60)) * 5;
|
||||
|
||||
const overlapping = allDayEvents.filter((other) => {
|
||||
if (other.id === event.id) return false;
|
||||
const oStart = new Date(other.start);
|
||||
const oEnd = new Date(other.end);
|
||||
return !(oEnd <= start || oStart >= end);
|
||||
});
|
||||
|
||||
const eventIndex = overlapping.findIndex((e) => e.id > event.id) + 1;
|
||||
const total = overlapping.length + 1;
|
||||
|
||||
return {
|
||||
height: `${Math.max(duration, 1.5)}rem`,
|
||||
position: 'absolute',
|
||||
width: `calc((100% / ${total}) - 4px)`,
|
||||
left: `calc((100% / ${total}) * ${eventIndex})`,
|
||||
backgroundColor: `${event.color}15`,
|
||||
borderLeft: `3px solid ${event.color}`,
|
||||
borderRadius: '0.25rem',
|
||||
zIndex: 1,
|
||||
transform: `translateY(${startMinutes}rem)`,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Barre de navigation (remplace le header Calendar sur mobile) */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-white border-b shrink-0">
|
||||
<button
|
||||
onClick={onOpenDrawer}
|
||||
className="p-2 hover:bg-gray-100 rounded-full"
|
||||
aria-label="Ouvrir les plannings"
|
||||
>
|
||||
<CalendarDays className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentDate(subDays(currentDate, 1))}
|
||||
className="p-2 hover:bg-gray-100 rounded-full"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<label className="relative cursor-pointer">
|
||||
<span className="px-2 py-1 text-sm font-semibold text-gray-800 hover:bg-gray-100 rounded-md capitalize">
|
||||
{format(currentDate, 'EEE d MMM', { locale: fr })}
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
className="absolute inset-0 opacity-0 w-full h-full cursor-pointer"
|
||||
value={format(currentDate, 'yyyy-MM-dd')}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) setCurrentDate(new Date(e.target.value + 'T12:00:00'));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setCurrentDate(addDays(currentDate, 1))}
|
||||
className="p-2 hover:bg-gray-100 rounded-full"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onDateClick?.(currentDate)}
|
||||
className="w-9 h-9 flex items-center justify-center bg-emerald-600 text-white rounded-full hover:bg-emerald-700 shadow-md transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bandeau jours de la semaine */}
|
||||
<div className="flex gap-1 px-2 py-2 bg-white border-b overflow-x-auto shrink-0">
|
||||
{weekDays.map((day) => (
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
onClick={() => setCurrentDate(day)}
|
||||
className={`flex flex-col items-center min-w-[2.75rem] px-1 py-1.5 rounded-xl transition-colors ${
|
||||
isSameDay(day, currentDate)
|
||||
? 'bg-emerald-600 text-white'
|
||||
: isToday(day)
|
||||
? 'border border-emerald-400 text-emerald-600'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-medium uppercase">
|
||||
{format(day, 'EEE', { locale: fr })}
|
||||
</span>
|
||||
<span className="text-sm font-bold">{format(day, 'd')}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grille horaire */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto relative">
|
||||
{isCurrentDay && (
|
||||
<div
|
||||
className="absolute left-0 right-0 z-10 border-emerald-500 border pointer-events-none"
|
||||
style={{ top: getCurrentTimePosition() }}
|
||||
>
|
||||
<div className="absolute -left-2 -top-2 w-2 h-2 rounded-full bg-emerald-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="grid w-full bg-gray-100 gap-[1px]"
|
||||
style={{ gridTemplateColumns: '2.5rem 1fr' }}
|
||||
>
|
||||
{timeSlots.map((hour) => (
|
||||
<React.Fragment key={hour}>
|
||||
<div className="h-20 p-1 text-right text-sm text-gray-500 bg-gray-100 font-medium">
|
||||
{`${hour.toString().padStart(2, '0')}:00`}
|
||||
</div>
|
||||
<div
|
||||
className={`h-20 relative border-b border-gray-100 ${
|
||||
isCurrentDay ? 'bg-emerald-50/30' : 'bg-white'
|
||||
}`}
|
||||
onClick={
|
||||
parentView
|
||||
? undefined
|
||||
: () => {
|
||||
const date = new Date(currentDate);
|
||||
date.setHours(hour);
|
||||
date.setMinutes(0);
|
||||
onDateClick(date);
|
||||
}
|
||||
}
|
||||
>
|
||||
{dayEvents
|
||||
.filter((e) => new Date(e.start).getHours() === hour)
|
||||
.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="rounded-sm overflow-hidden cursor-pointer hover:shadow-lg"
|
||||
style={calculateEventStyle(event, dayEvents)}
|
||||
onClick={
|
||||
parentView
|
||||
? undefined
|
||||
: (e) => {
|
||||
e.stopPropagation();
|
||||
onEventClick(event);
|
||||
}
|
||||
}
|
||||
>
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="font-semibold text-xs truncate"
|
||||
style={{ color: event.color }}
|
||||
>
|
||||
{event.title}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs"
|
||||
style={{ color: event.color, opacity: 0.75 }}
|
||||
>
|
||||
{format(new Date(event.start), 'HH:mm')} –{' '}
|
||||
{format(new Date(event.end), 'HH:mm')}
|
||||
</div>
|
||||
{event.location && (
|
||||
<div
|
||||
className="text-xs truncate"
|
||||
style={{ color: event.color, opacity: 0.75 }}
|
||||
>
|
||||
{event.location}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DayView;
|
||||
@ -253,7 +253,7 @@ export default function EventModal({
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Début
|
||||
|
||||
@ -75,22 +75,35 @@ const MonthView = ({ onDateClick, onEventClick }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const dayLabels = [
|
||||
{ short: 'L', long: 'Lun' },
|
||||
{ short: 'M', long: 'Mar' },
|
||||
{ short: 'M', long: 'Mer' },
|
||||
{ short: 'J', long: 'Jeu' },
|
||||
{ short: 'V', long: 'Ven' },
|
||||
{ short: 'S', long: 'Sam' },
|
||||
{ short: 'D', long: 'Dim' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col border border-gray-200 rounded-lg bg-white">
|
||||
{/* En-tête des jours de la semaine */}
|
||||
<div className="grid grid-cols-7 border-b">
|
||||
{['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="p-2 text-center text-sm font-medium text-gray-500"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Grille des jours */}
|
||||
<div className="flex-1 grid grid-cols-7 grid-rows-[repeat(6,1fr)]">
|
||||
{days.map((day) => renderDay(day))}
|
||||
<div className="h-full flex flex-col border border-gray-200 rounded-lg bg-white overflow-x-auto">
|
||||
<div className="min-w-[280px]">
|
||||
{/* En-tête des jours de la semaine */}
|
||||
<div className="grid grid-cols-7 border-b">
|
||||
{dayLabels.map((day, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-1 sm:p-2 text-center text-xs sm:text-sm font-medium text-gray-500"
|
||||
>
|
||||
<span className="sm:hidden">{day.short}</span>
|
||||
<span className="hidden sm:inline">{day.long}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Grille des jours */}
|
||||
<div className="grid grid-cols-7 grid-rows-[repeat(6,1fr)]">
|
||||
{days.map((day) => renderDay(day))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -32,7 +32,7 @@ const PlanningView = ({ events, onEventClick }) => {
|
||||
|
||||
return (
|
||||
<div className="bg-white h-full overflow-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<table className="min-w-full border-collapse">
|
||||
<thead className="bg-gray-50 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="py-3 px-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-b">
|
||||
|
||||
@ -3,7 +3,7 @@ import { usePlanning, PlanningModes } from '@/context/PlanningContext';
|
||||
import { Plus, Edit2, Eye, EyeOff, Check, X } from 'lucide-react';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
export default function ScheduleNavigation({ classes, modeSet = 'event' }) {
|
||||
export default function ScheduleNavigation({ classes, modeSet = 'event', isOpen = false, onClose = () => {} }) {
|
||||
const {
|
||||
schedules,
|
||||
selectedSchedule,
|
||||
@ -62,22 +62,10 @@ export default function ScheduleNavigation({ classes, modeSet = 'event' }) {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="w-64 border-r p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold">
|
||||
{planningMode === PlanningModes.CLASS_SCHEDULE
|
||||
? 'Emplois du temps'
|
||||
: 'Plannings'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setIsAddingNew(true)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
const title = planningMode === PlanningModes.CLASS_SCHEDULE ? 'Emplois du temps' : 'Plannings';
|
||||
|
||||
const listContent = (
|
||||
<>
|
||||
{isAddingNew && (
|
||||
<div className="mb-4 p-2 border rounded">
|
||||
<input
|
||||
@ -251,6 +239,50 @@ export default function ScheduleNavigation({ classes, modeSet = 'event' }) {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop : sidebar fixe */}
|
||||
<nav className="hidden md:flex flex-col w-64 border-r p-4 h-full overflow-y-auto shrink-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<button onClick={() => setIsAddingNew(true)} className="p-1 hover:bg-gray-100 rounded">
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{listContent}
|
||||
</nav>
|
||||
|
||||
{/* Mobile : drawer en overlay */}
|
||||
<div
|
||||
className={`md:hidden fixed inset-0 z-50 transition-opacity duration-200 ${
|
||||
isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div
|
||||
className={`absolute left-0 top-0 bottom-0 w-72 bg-white shadow-xl flex flex-col transition-transform duration-200 ${
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b shrink-0">
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setIsAddingNew(true)} className="p-1 hover:bg-gray-100 rounded">
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{listContent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -36,7 +36,7 @@ const YearView = ({ onDateClick }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4 p-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 p-4">
|
||||
{months.map((month) => (
|
||||
<MonthCard
|
||||
key={month.getTime()}
|
||||
|
||||
@ -15,27 +15,28 @@ export default function LineChart({ data }) {
|
||||
.filter((idx) => idx !== -1);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full flex items-end space-x-4"
|
||||
style={{ height: chartHeight }}
|
||||
>
|
||||
<div className="w-full flex space-x-4">
|
||||
{data.map((point, idx) => {
|
||||
const barHeight = Math.max((point.value / maxValue) * chartHeight, 8); // min 8px
|
||||
const barHeight = Math.max((point.value / maxValue) * chartHeight, 8);
|
||||
const isMax = maxIndices.includes(idx);
|
||||
return (
|
||||
<div key={idx} className="flex flex-col items-center flex-1">
|
||||
{/* Valeur au-dessus de la barre */}
|
||||
{/* Valeur au-dessus de la barre — hors de la zone hauteur fixe */}
|
||||
<span className="text-xs mb-1 text-gray-700 font-semibold">
|
||||
{point.value}
|
||||
</span>
|
||||
{/* Zone barres à hauteur fixe, alignées en bas */}
|
||||
<div
|
||||
className={`${isMax ? 'bg-emerald-400' : 'bg-blue-400'} rounded-t w-4`}
|
||||
style={{
|
||||
height: `${barHeight}px`,
|
||||
transition: 'height 0.3s',
|
||||
}}
|
||||
title={`${point.month}: ${point.value}`}
|
||||
/>
|
||||
className="w-full flex items-end justify-center"
|
||||
style={{ height: chartHeight }}
|
||||
>
|
||||
<div
|
||||
className={`${isMax ? 'bg-emerald-400' : 'bg-blue-400'} rounded-t w-4`}
|
||||
style={{ height: `${barHeight}px`, transition: 'height 0.3s' }}
|
||||
title={`${point.month}: ${point.value}`}
|
||||
/>
|
||||
</div>
|
||||
{/* Label mois en dessous */}
|
||||
<span className="text-xs mt-1 text-gray-600">{point.month}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { MessageSquare, Plus, Search, Trash2, ArrowLeft } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
@ -99,6 +99,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
|
||||
// États pour la confirmation de suppression
|
||||
const [confirmDeleteVisible, setConfirmDeleteVisible] = useState(false);
|
||||
const [conversationToDelete, setConversationToDelete] = useState(null);
|
||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(true);
|
||||
|
||||
// Refs
|
||||
const messagesEndRef = useRef(null);
|
||||
@ -541,6 +542,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
|
||||
logger.debug('🔄 Sélection de la conversation:', conversation);
|
||||
setSelectedConversation(conversation);
|
||||
setTypingUsers([]);
|
||||
setIsMobileSidebarOpen(false);
|
||||
|
||||
// Utiliser id ou conversation_id selon ce qui est disponible
|
||||
const conversationId = conversation.id || conversation.conversation_id;
|
||||
@ -828,7 +830,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
|
||||
return (
|
||||
<div className="flex h-full bg-white">
|
||||
{/* Sidebar des conversations */}
|
||||
<div className="w-80 bg-gray-50 border-r border-gray-200 flex flex-col">
|
||||
<div className={`${isMobileSidebarOpen ? 'flex' : 'hidden'} md:flex w-full md:w-80 bg-gray-50 border-r border-gray-200 flex-col`}>
|
||||
{/* En-tête */}
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@ -986,12 +988,20 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
|
||||
</div>
|
||||
|
||||
{/* Zone de chat principale */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className={`${!isMobileSidebarOpen ? 'flex' : 'hidden'} md:flex flex-1 flex-col`}>
|
||||
{selectedConversation ? (
|
||||
<>
|
||||
{/* En-tête de la conversation */}
|
||||
<div className="p-4 border-b border-gray-200 bg-white">
|
||||
<div className="flex items-center">
|
||||
{/* Bouton retour liste sur mobile */}
|
||||
<button
|
||||
onClick={() => setIsMobileSidebarOpen(true)}
|
||||
className="mr-3 p-1 rounded hover:bg-gray-100 md:hidden"
|
||||
aria-label="Retour aux conversations"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
<div className="flex items-center">
|
||||
<div className="relative">
|
||||
<img
|
||||
|
||||
@ -57,14 +57,14 @@ export default function FlashNotification({
|
||||
animate={{ opacity: 1, x: 0 }} // Animation visible
|
||||
exit={{ opacity: 0, x: 50 }} // Animation de sortie
|
||||
transition={{ duration: 0.3 }} // Durée des animations
|
||||
className="fixed top-5 right-5 flex items-stretch rounded-lg shadow-lg bg-white z-50 border border-gray-200"
|
||||
className="fixed top-5 right-2 left-2 sm:left-auto sm:right-5 sm:max-w-sm flex items-stretch rounded-lg shadow-lg bg-white z-50 border border-gray-200"
|
||||
>
|
||||
{/* Rectangle gauche avec l'icône */}
|
||||
<div className={`flex items-center justify-center w-14 ${bg}`}>
|
||||
{icon}
|
||||
</div>
|
||||
{/* Zone de texte */}
|
||||
<div className="flex-1 w-96 p-4">
|
||||
<div className="flex-1 min-w-0 p-4">
|
||||
<p className="font-bold text-black">{title}</p>
|
||||
<p className="text-gray-700">{message}</p>
|
||||
{type === 'error' && errorCode && (
|
||||
|
||||
@ -2,7 +2,7 @@ import Logo from '@/components/Logo';
|
||||
|
||||
export default function Footer({ softwareName, softwareVersion }) {
|
||||
return (
|
||||
<footer className="absolute bottom-0 left-64 right-0 h-16 bg-white border-t border-gray-200 flex items-center justify-center box-border">
|
||||
<footer className="absolute bottom-0 left-0 md:left-64 right-0 h-16 bg-white border-t border-gray-200 flex items-center justify-center box-border">
|
||||
<div className="text-sm font-light">
|
||||
<span>
|
||||
© {new Date().getFullYear()} N3WT-INNOV Tous droits réservés.
|
||||
|
||||
@ -10,10 +10,16 @@ export default function FileUpload({
|
||||
required,
|
||||
errorMsg,
|
||||
enable = true, // Nouvelle prop pour activer/désactiver le champ
|
||||
key,
|
||||
}) {
|
||||
const [localFileName, setLocalFileName] = useState(uploadedFileName || '');
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
// Réinitialise localFileName à chaque changement de key (id template)
|
||||
React.useEffect(() => {
|
||||
setLocalFileName(uploadedFileName || '');
|
||||
}, [key, uploadedFileName]);
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import logger from '@/utils/logger';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { useEffect } from 'react';
|
||||
import SelectChoice from './SelectChoice';
|
||||
import InputTextIcon from './InputTextIcon';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
@ -28,6 +29,7 @@ export function getIcon(name) {
|
||||
export default function FormRenderer({
|
||||
formConfig,
|
||||
csrfToken,
|
||||
initialValues = {},
|
||||
onFormSubmit = (data) => {
|
||||
alert(JSON.stringify(data, null, 2));
|
||||
}, // Callback de soumission personnalisé (optionnel)
|
||||
@ -37,31 +39,14 @@ export default function FormRenderer({
|
||||
control,
|
||||
formState: { errors },
|
||||
reset,
|
||||
} = useForm();
|
||||
} = useForm({ defaultValues: initialValues });
|
||||
|
||||
// Fonction utilitaire pour envoyer les données au backend
|
||||
const sendFormDataToBackend = async (formData) => {
|
||||
try {
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
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;
|
||||
// Réinitialiser le formulaire quand les valeurs initiales changent
|
||||
useEffect(() => {
|
||||
if (initialValues && Object.keys(initialValues).length > 0) {
|
||||
reset(initialValues);
|
||||
}
|
||||
};
|
||||
}, [initialValues, reset]);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
logger.debug('=== DÉBUT onSubmit ===');
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { BookOpen, CheckCircle, AlertCircle, Clock } from 'lucide-react';
|
||||
import { BookOpen, CheckCircle, AlertCircle, Clock, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import RadioList from '@/components/Form/RadioList';
|
||||
|
||||
const LEVELS = [
|
||||
@ -86,9 +86,10 @@ export default function GradeView({ data, grades, onGradeChange }) {
|
||||
{domaine.domaine_nom}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-emerald-700 text-xl">
|
||||
{openDomains[domaine.domaine_id] ? '▼' : '►'}
|
||||
</span>
|
||||
{openDomains[domaine.domaine_id]
|
||||
? <ChevronDown className="w-5 h-5 text-emerald-700" />
|
||||
: <ChevronRight className="w-5 h-5 text-emerald-700" />
|
||||
}
|
||||
</div>
|
||||
{openDomains[domaine.domaine_id] && (
|
||||
<div className="mt-4">
|
||||
@ -99,7 +100,10 @@ export default function GradeView({ data, grades, onGradeChange }) {
|
||||
className="flex items-center gap-2 text-lg font-semibold text-emerald-700 mb-4 hover:underline"
|
||||
onClick={() => toggleCategory(categorie.categorie_id)}
|
||||
>
|
||||
{openCategories[categorie.categorie_id] ? '▼' : '►'}{' '}
|
||||
{openCategories[categorie.categorie_id]
|
||||
? <ChevronDown className="w-4 h-4" />
|
||||
: <ChevronRight className="w-4 h-4" />
|
||||
}
|
||||
{categorie.categorie_nom}
|
||||
</button>
|
||||
{openCategories[categorie.categorie_id] && (
|
||||
|
||||
@ -2,20 +2,20 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import FormRenderer from '@/components/Form/FormRenderer';
|
||||
import FileUpload from '@/components/Form/FileUpload';
|
||||
import { CheckCircle, Hourglass, FileText, Download, Upload } from 'lucide-react';
|
||||
import { CheckCircle, Hourglass, FileText, Download, Upload, XCircle } from 'lucide-react';
|
||||
import logger from '@/utils/logger';
|
||||
import { BASE_URL } from '@/utils/Url';
|
||||
|
||||
/**
|
||||
* Composant pour afficher et gérer les formulaires dynamiques d'inscription
|
||||
* @param {Array} schoolFileMasters - Liste des formulaires maîtres
|
||||
* @param {Array} schoolFileTemplates - Liste des templates de formulaires
|
||||
* @param {Object} existingResponses - Réponses déjà sauvegardées
|
||||
* @param {Function} onFormSubmit - Callback appelé quand un formulaire est soumis
|
||||
* @param {Boolean} enable - Si les formulaires sont modifiables
|
||||
* @param {Function} onFileUpload - Callback appelé quand un fichier est sélectionné
|
||||
*/
|
||||
export default function DynamicFormsList({
|
||||
schoolFileMasters,
|
||||
schoolFileTemplates,
|
||||
existingResponses = {},
|
||||
onFormSubmit,
|
||||
enable = true,
|
||||
@ -29,41 +29,60 @@ export default function DynamicFormsList({
|
||||
|
||||
// Initialiser les données avec les réponses existantes
|
||||
useEffect(() => {
|
||||
if (existingResponses && Object.keys(existingResponses).length > 0) {
|
||||
setFormsData(existingResponses);
|
||||
|
||||
// Marquer les formulaires avec réponses comme valides
|
||||
const validationState = {};
|
||||
Object.keys(existingResponses).forEach((formId) => {
|
||||
if (
|
||||
existingResponses[formId] &&
|
||||
Object.keys(existingResponses[formId]).length > 0
|
||||
) {
|
||||
validationState[formId] = true;
|
||||
}
|
||||
// Initialisation complète de formsValidation et formsData pour chaque template
|
||||
if (schoolFileTemplates && schoolFileTemplates.length > 0) {
|
||||
// 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) {
|
||||
// Pas de données locales mais données serveur : utiliser les données serveur
|
||||
dataState[tpl.id] = existingResponses[tpl.id];
|
||||
} else if (!hasLocalData && !hasServerData) {
|
||||
// Pas de données du tout : initialiser à vide
|
||||
dataState[tpl.id] = {};
|
||||
}
|
||||
// Si hasLocalData : on garde les données locales existantes
|
||||
});
|
||||
return dataState;
|
||||
});
|
||||
setFormsValidation(validationState);
|
||||
}
|
||||
}, [existingResponses]);
|
||||
|
||||
// Debug: Log des formulaires maîtres reçus
|
||||
useEffect(() => {
|
||||
logger.debug(
|
||||
'DynamicFormsList - Formulaires maîtres reçus:',
|
||||
schoolFileMasters
|
||||
);
|
||||
}, [schoolFileMasters]);
|
||||
// Fusionner avec l'état de validation existant
|
||||
setFormsValidation((prevValidation) => {
|
||||
const validationState = { ...prevValidation };
|
||||
schoolFileTemplates.forEach((tpl) => {
|
||||
const hasLocalValidation = prevValidation[tpl.id] === true;
|
||||
const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
const allFormsValid = schoolFileMasters.every(
|
||||
(master, index) => formsValidation[master.id] === true
|
||||
// Un document est considéré comme "validé" s'il est validé par l'école OU complété localement OU déjà existant
|
||||
const allFormsValid = schoolFileTemplates.every(
|
||||
tpl => tpl.isValidated === true ||
|
||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
||||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
|
||||
);
|
||||
|
||||
if (onValidationChange) {
|
||||
onValidationChange(allFormsValid);
|
||||
}
|
||||
}, [formsValidation, schoolFileMasters, onValidationChange]);
|
||||
onValidationChange(allFormsValid);
|
||||
}, [formsData, formsValidation, existingResponses, schoolFileTemplates, onValidationChange]);
|
||||
|
||||
/**
|
||||
* Gère la soumission d'un formulaire individuel
|
||||
@ -90,7 +109,7 @@ export default function DynamicFormsList({
|
||||
}
|
||||
|
||||
// Passer au formulaire suivant si disponible
|
||||
if (currentTemplateIndex < schoolFileMasters.length - 1) {
|
||||
if (currentTemplateIndex < schoolFileTemplates.length - 1) {
|
||||
setCurrentTemplateIndex(currentTemplateIndex + 1);
|
||||
}
|
||||
|
||||
@ -100,16 +119,6 @@ export default function DynamicFormsList({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gère les changements de validation d'un formulaire
|
||||
*/
|
||||
const handleFormValidationChange = (isValid, templateId) => {
|
||||
setFormsValidation((prev) => ({
|
||||
...prev,
|
||||
[templateId]: isValid,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Vérifie si un formulaire est complété
|
||||
*/
|
||||
@ -140,31 +149,44 @@ export default function DynamicFormsList({
|
||||
* Obtient le formulaire actuel à afficher
|
||||
*/
|
||||
const getCurrentTemplate = () => {
|
||||
return schoolFileMasters[currentTemplateIndex];
|
||||
return schoolFileTemplates[currentTemplateIndex];
|
||||
};
|
||||
|
||||
const currentTemplate = getCurrentTemplate();
|
||||
|
||||
// Handler d'upload pour formulaire existant
|
||||
const handleUpload = async (file, selectedFile) => {
|
||||
if (!file || !selectedFile) return;
|
||||
try {
|
||||
const templateId = currentTemplate.id;
|
||||
if (onFileUpload) {
|
||||
await onFileUpload(file, selectedFile);
|
||||
setFormsValidation((prev) => ({
|
||||
...prev,
|
||||
[selectedFile.id]: true,
|
||||
}));
|
||||
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 (!schoolFileMasters || schoolFileMasters.length === 0) {
|
||||
if (!schoolFileTemplates || schoolFileTemplates.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
@ -173,8 +195,6 @@ export default function DynamicFormsList({
|
||||
);
|
||||
}
|
||||
|
||||
const currentTemplate = getCurrentTemplate();
|
||||
|
||||
return (
|
||||
<div className="mt-8 mb-4 w-full mx-auto flex gap-8">
|
||||
{/* Liste des formulaires */}
|
||||
@ -183,85 +203,172 @@ export default function DynamicFormsList({
|
||||
Formulaires à compléter
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 mb-4">
|
||||
{/* Compteur x/y : inclut les documents validés */}
|
||||
{
|
||||
Object.keys(formsValidation).filter((id) => formsValidation[id])
|
||||
.length
|
||||
}{' '}
|
||||
/ {schoolFileMasters.length} complétés
|
||||
schoolFileTemplates.filter(tpl => {
|
||||
// Validé ou complété localement
|
||||
return tpl.isValidated === true ||
|
||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
||||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0);
|
||||
}).length
|
||||
}
|
||||
{' / '}
|
||||
{schoolFileTemplates.length} complétés
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{schoolFileMasters.map((master, index) => {
|
||||
const isActive = index === currentTemplateIndex;
|
||||
const isCompleted = isFormCompleted(master.id);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={master.id}
|
||||
className={`flex items-center cursor-pointer p-2 rounded-md transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-100 text-blue-700 font-semibold'
|
||||
: isCompleted
|
||||
? 'text-green-600 hover:bg-green-50'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
onClick={() => setCurrentTemplateIndex(index)}
|
||||
>
|
||||
<span className="mr-3">
|
||||
{getFormStatusIcon(master.id, isActive)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate">
|
||||
{master.formMasterData?.title ||
|
||||
master.title ||
|
||||
master.name ||
|
||||
'Formulaire sans nom'}
|
||||
</div>
|
||||
{isCompleted ? (
|
||||
<div className="text-xs text-green-600">
|
||||
Complété -{' '}
|
||||
{
|
||||
Object.keys(
|
||||
formsData[master.id] ||
|
||||
existingResponses[master.id] ||
|
||||
{}
|
||||
).length
|
||||
}{' '}
|
||||
réponse(s)
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500">
|
||||
{master.formMasterData?.fields || master.fields
|
||||
? `${(master.formMasterData?.fields || master.fields).length} champ(s)`
|
||||
: 'À compléter'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
{/* 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)
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
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">
|
||||
{sortedTemplates.map((tpl, index) => {
|
||||
const isActive = schoolFileTemplates[currentTemplateIndex]?.id === tpl.id;
|
||||
const isValidated = typeof tpl.isValidated === 'boolean' ? tpl.isValidated : undefined;
|
||||
const isCompletedLocally = !!(
|
||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
||||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
|
||||
);
|
||||
|
||||
// Statut d'affichage
|
||||
let statusLabel = '';
|
||||
let statusColor = '';
|
||||
let icon = null;
|
||||
let bgClass = '';
|
||||
let borderClass = '';
|
||||
let textClass = '';
|
||||
let canEdit = true;
|
||||
|
||||
if (isValidated === true) {
|
||||
statusLabel = 'Validé';
|
||||
statusColor = 'emerald';
|
||||
icon = <CheckCircle className="w-5 h-5 text-emerald-600" />;
|
||||
bgClass = 'bg-emerald-50';
|
||||
borderClass = 'border border-emerald-200';
|
||||
textClass = 'text-emerald-700';
|
||||
bgClass = isActive ? 'bg-emerald-200' : bgClass;
|
||||
borderClass = isActive ? 'border border-emerald-300' : borderClass;
|
||||
textClass = isActive ? 'text-emerald-900 font-semibold' : textClass;
|
||||
canEdit = false;
|
||||
} else if (isValidated === false) {
|
||||
if (isCompletedLocally) {
|
||||
statusLabel = 'Complété';
|
||||
statusColor = 'orange';
|
||||
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
|
||||
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
|
||||
borderClass = isActive ? 'border border-orange-300' : 'border border-orange-200';
|
||||
textClass = isActive ? 'text-orange-900 font-semibold' : 'text-orange-700';
|
||||
canEdit = true;
|
||||
} else {
|
||||
statusLabel = 'Refusé';
|
||||
statusColor = 'red';
|
||||
icon = <XCircle className="w-5 h-5 text-red-500" />;
|
||||
bgClass = isActive ? 'bg-red-200' : 'bg-red-50';
|
||||
borderClass = isActive ? 'border border-red-300' : 'border border-red-200';
|
||||
textClass = isActive ? 'text-red-900 font-semibold' : 'text-red-700';
|
||||
canEdit = true;
|
||||
}
|
||||
} else {
|
||||
if (isCompletedLocally) {
|
||||
statusLabel = 'Complété';
|
||||
statusColor = 'orange';
|
||||
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
|
||||
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
|
||||
borderClass = isActive ? 'border border-orange-300' : 'border border-orange-200';
|
||||
textClass = isActive ? 'text-orange-900 font-semibold' : 'text-orange-700';
|
||||
canEdit = true;
|
||||
} else {
|
||||
statusLabel = 'À compléter';
|
||||
statusColor = 'gray';
|
||||
icon = <Hourglass className="w-5 h-5 text-gray-400" />;
|
||||
bgClass = isActive ? 'bg-gray-200' : '';
|
||||
borderClass = isActive ? 'border border-gray-300' : '';
|
||||
textClass = isActive ? 'text-gray-900 font-semibold' : 'text-gray-600';
|
||||
canEdit = true;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
key={tpl.id}
|
||||
className={`flex items-center cursor-pointer p-2 rounded-md transition-colors ${
|
||||
isActive
|
||||
? `${bgClass.replace('50', '200')} ${borderClass.replace('200', '300')} ${textClass.replace('700', '900')} font-semibold`
|
||||
: `${bgClass} ${borderClass} ${textClass}`
|
||||
}`}
|
||||
onClick={() => setCurrentTemplateIndex(schoolFileTemplates.findIndex(t => t.id === tpl.id))}
|
||||
>
|
||||
<span className="mr-3">{icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate flex items-center gap-2">
|
||||
{tpl.formMasterData?.title || tpl.title || tpl.name || 'Formulaire sans nom'}
|
||||
<span className={`ml-2 px-2 py-0.5 rounded bg-${statusColor}-100 text-${statusColor}-700 text-xs font-semibold`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{tpl.formMasterData?.fields || tpl.fields
|
||||
? `${(tpl.formMasterData?.fields || tpl.fields).length} champ(s)`
|
||||
: 'À compléter'}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Affichage du formulaire actuel */}
|
||||
<div className="w-3/4">
|
||||
{currentTemplate && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">
|
||||
{currentTemplate.formTemplateData?.title ||
|
||||
currentTemplate.title ||
|
||||
currentTemplate.name ||
|
||||
'Formulaire sans nom'}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xl font-semibold text-gray-800">
|
||||
{currentTemplate.name}
|
||||
</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">
|
||||
{currentTemplate.formTemplateData?.description ||
|
||||
currentTemplate.description ||
|
||||
'Veuillez compléter ce formulaire pour continuer votre inscription.'}
|
||||
currentTemplate.description || ''}
|
||||
</p>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Formulaire {currentTemplateIndex + 1} sur{' '}
|
||||
{schoolFileMasters.length}
|
||||
Formulaire {(() => {
|
||||
// Trouver l'index du template courant dans la liste triée
|
||||
const getState = tpl => {
|
||||
if (tpl.isValidated === true) return 0;
|
||||
const isCompletedLocally = !!(
|
||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
||||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
|
||||
);
|
||||
if (isCompletedLocally) return 1;
|
||||
return 2;
|
||||
};
|
||||
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => getState(a) - getState(b));
|
||||
const idx = sortedTemplates.findIndex(tpl => tpl.id === currentTemplate.id);
|
||||
return idx + 1;
|
||||
})()} sur {schoolFileTemplates.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -283,46 +390,66 @@ export default function DynamicFormsList({
|
||||
submitLabel:
|
||||
currentTemplate.formTemplateData?.submitLabel || 'Valider',
|
||||
}}
|
||||
initialValues={
|
||||
formsData[currentTemplate.id] ||
|
||||
existingResponses[currentTemplate.id] ||
|
||||
{}
|
||||
}
|
||||
onFormSubmit={(formData) =>
|
||||
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="flex flex-col items-center gap-6">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileText className="w-16 h-16 text-gray-400" />
|
||||
<div className="text-lg font-semibold text-gray-700">
|
||||
{currentTemplate.name}
|
||||
</div>
|
||||
{currentTemplate.file && (
|
||||
<a
|
||||
href={`${BASE_URL}${currentTemplate.file}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
|
||||
download
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
Télécharger le document
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{enable && (
|
||||
<FileUpload
|
||||
selectionMessage="Sélectionnez le fichier du document"
|
||||
onFileSelect={(file) => handleUpload(file, currentTemplate)}
|
||||
required
|
||||
enable
|
||||
{/* Cas validé : affichage en iframe */}
|
||||
{currentTemplate.isValidated === true && currentTemplate.file && (
|
||||
<iframe
|
||||
src={`${BASE_URL}${currentTemplate.file}`}
|
||||
title={currentTemplate.name}
|
||||
className="w-full"
|
||||
style={{ height: '600px', border: 'none' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Cas non validé : bouton télécharger + upload */}
|
||||
{currentTemplate.isValidated !== true && (
|
||||
<div className="flex flex-col items-center gap-4 w-full">
|
||||
{/* Bouton télécharger le document source */}
|
||||
{currentTemplate.file && (
|
||||
<a
|
||||
href={`${BASE_URL}${currentTemplate.file}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
|
||||
download
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
Télécharger le document
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Composant d'upload */}
|
||||
{enable && (
|
||||
<FileUpload
|
||||
key={currentTemplate.id}
|
||||
selectionMessage={'Sélectionnez le fichier du document'}
|
||||
onFileSelect={(file) => handleUpload(file, currentTemplate)}
|
||||
required
|
||||
enable={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message de fin */}
|
||||
{currentTemplateIndex >= schoolFileMasters.length && (
|
||||
{currentTemplateIndex >= schoolFileTemplates.length && (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-green-600 mb-2">
|
||||
|
||||
@ -799,7 +799,7 @@ export default function InscriptionFormShared({
|
||||
{/* Page 5 : Formulaires dynamiques d'inscription */}
|
||||
{currentPage === 5 && (
|
||||
<DynamicFormsList
|
||||
schoolFileMasters={schoolFileTemplates}
|
||||
schoolFileTemplates={schoolFileTemplates}
|
||||
existingResponses={formResponses}
|
||||
onFormSubmit={handleDynamicFormSubmit}
|
||||
onValidationChange={handleDynamicFormsValidationChange}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Popup from '@/components/Popup';
|
||||
import ToggleSwitch from '@/components/Form/ToggleSwitch';
|
||||
import SelectChoice from '@/components/Form/SelectChoice';
|
||||
import { BASE_URL } from '@/utils/Url';
|
||||
@ -8,24 +9,55 @@ import {
|
||||
fetchParentFileTemplatesFromRegistrationFiles,
|
||||
} from '@/app/actions/subscriptionAction';
|
||||
import logger from '@/utils/logger';
|
||||
import { School, CheckCircle, Hourglass, FileText } from 'lucide-react';
|
||||
import { School, FileText } from 'lucide-react';
|
||||
import SectionHeader from '@/components/SectionHeader';
|
||||
import Button from '@/components/Form/Button';
|
||||
|
||||
export default function ValidateSubscription({
|
||||
studentId,
|
||||
firstName,
|
||||
email,
|
||||
lastName,
|
||||
sepa_file,
|
||||
student_file,
|
||||
onAccept,
|
||||
onRefuse,
|
||||
classes,
|
||||
handleValidateOrRefuseDoc,
|
||||
csrfToken,
|
||||
}) {
|
||||
const [schoolFileTemplates, setSchoolFileTemplates] = useState([]);
|
||||
const [parentFileTemplates, setParentFileTemplates] = useState([]);
|
||||
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
|
||||
const [mergeDocuments, setMergeDocuments] = 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({
|
||||
associated_class: null,
|
||||
@ -88,14 +120,27 @@ export default function ValidateSubscription({
|
||||
},
|
||||
status: 5,
|
||||
fusionParam: mergeDocuments,
|
||||
notes: 'Dossier validé',
|
||||
};
|
||||
|
||||
onAccept(data);
|
||||
} else {
|
||||
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) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
@ -125,6 +170,17 @@ export default function ValidateSubscription({
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
// Récupère la liste des documents refusés (inclut la fiche élève si refusée)
|
||||
const refusedDocs = allTemplates
|
||||
.map((doc, idx) => ({ ...doc, idx }))
|
||||
.filter((doc, idx) => docStatuses[idx] === 'refused');
|
||||
|
||||
// Récupère la liste des documents à cocher (hors fiche élève)
|
||||
const docIndexes = allTemplates.map((_, idx) => idx).filter(idx => idx !== 0);
|
||||
const allChecked = docIndexes.length > 0 && docIndexes.every(idx => docStatuses[idx] === 'accepted' || docStatuses[idx] === 'refused');
|
||||
const allValidated = docIndexes.length > 0 && docIndexes.every(idx => docStatuses[idx] === 'accepted');
|
||||
const hasRefused = docIndexes.some(idx => docStatuses[idx] === 'refused');
|
||||
logger.debug(allTemplates);
|
||||
|
||||
return (
|
||||
@ -162,8 +218,8 @@ export default function ValidateSubscription({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Colonne droite : Liste des documents, Option de fusion et Affectation */}
|
||||
<div className="w-1/4 flex flex-col gap-4">
|
||||
{/* Colonne droite : Liste des documents, Option de fusion, Affectation, Refus */}
|
||||
<div className="w-1/4 flex flex-col flex-1 gap-4 h-full">
|
||||
{/* Liste des documents */}
|
||||
<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">
|
||||
@ -187,60 +243,176 @@ export default function ValidateSubscription({
|
||||
<FileText className="w-5 h-5 text-green-600" />
|
||||
)}
|
||||
</span>
|
||||
{template.name}
|
||||
<span className="flex-1">{template.name}</span>
|
||||
{/* 2 boutons : Validé / Refusé (sauf fiche élève) */}
|
||||
{index !== 0 && (
|
||||
<span className="ml-2 flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center gap-1
|
||||
${docStatuses[index] === 'accepted' ? 'bg-emerald-500 text-white border-emerald-500' : 'bg-white text-emerald-600 border-emerald-300'}`}
|
||||
aria-pressed={docStatuses[index] === 'accepted'}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setDocStatuses(s => ({ ...s, [index]: 'accepted' }));
|
||||
// Appel API pour valider le document
|
||||
if (handleValidateOrRefuseDoc) {
|
||||
let template = null;
|
||||
let type = null;
|
||||
if (index > 0 && index <= schoolFileTemplates.length) {
|
||||
template = schoolFileTemplates[index - 1];
|
||||
type = 'school';
|
||||
} else if (index > schoolFileTemplates.length && index <= schoolFileTemplates.length + parentFileTemplates.length) {
|
||||
template = parentFileTemplates[index - 1 - schoolFileTemplates.length];
|
||||
type = 'parent';
|
||||
}
|
||||
if (template && template.id) {
|
||||
handleValidateOrRefuseDoc({
|
||||
templateId: template.id,
|
||||
type,
|
||||
validated: true,
|
||||
csrfToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="text-lg">✓</span> Validé
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center gap-1
|
||||
${docStatuses[index] === 'refused' ? 'bg-red-500 text-white border-red-500' : 'bg-white text-red-600 border-red-300'}`}
|
||||
aria-pressed={docStatuses[index] === 'refused'}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setDocStatuses(s => ({ ...s, [index]: 'refused' }));
|
||||
// Appel API pour refuser le document
|
||||
if (handleValidateOrRefuseDoc) {
|
||||
let template = null;
|
||||
let type = null;
|
||||
if (index > 0 && index <= schoolFileTemplates.length) {
|
||||
template = schoolFileTemplates[index - 1];
|
||||
type = 'school';
|
||||
} else if (index > schoolFileTemplates.length && index <= schoolFileTemplates.length + parentFileTemplates.length) {
|
||||
template = parentFileTemplates[index - 1 - schoolFileTemplates.length];
|
||||
type = 'parent';
|
||||
}
|
||||
if (template && template.id) {
|
||||
handleValidateOrRefuseDoc({
|
||||
templateId: template.id,
|
||||
type,
|
||||
validated: false,
|
||||
csrfToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="text-lg">✗</span> Refusé
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Option de fusion */}
|
||||
<div className="bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
Option de fusion
|
||||
</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<ToggleSwitch
|
||||
label="Fusionner les documents"
|
||||
checked={mergeDocuments}
|
||||
onChange={handleToggleMergeDocuments}
|
||||
/>
|
||||
{/* Nouvelle section Options de validation : carte unique, sélecteur de classe (ligne 1), toggle fusion (ligne 2 aligné à droite) */}
|
||||
{allChecked && allValidated && (
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 flex flex-col gap-4">
|
||||
<div>
|
||||
<SelectChoice
|
||||
name="associated_class"
|
||||
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
|
||||
label="Fusionner les documents"
|
||||
checked={mergeDocuments}
|
||||
onChange={handleToggleMergeDocuments}
|
||||
className="ml-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Boutons Valider/Refuser en bas, centrés */}
|
||||
<div className="mt-auto py-4">
|
||||
<Button
|
||||
text="Soumettre"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
// 1. Si tous les documents ne sont pas cochés, rien ne se passe (bouton désactivé)
|
||||
// 2. Si tous cochés et au moins un refusé : popup refus
|
||||
if (allChecked && hasRefused) {
|
||||
setShowRefusedPopup(true);
|
||||
return;
|
||||
}
|
||||
// 3. Si tous cochés et tous validés mais pas de classe sélectionnée : bouton désactivé
|
||||
// 4. Si tous cochés, tous validés et classe sélectionnée : popup de validation finale
|
||||
if (allChecked && allValidated && formData.associated_class) {
|
||||
setShowFinalValidationPopup(true);
|
||||
}
|
||||
}}
|
||||
primary
|
||||
className={`w-full h-12 rounded-md shadow-sm focus:outline-none ${
|
||||
!allChecked || (allChecked && allValidated && !formData.associated_class)
|
||||
? 'bg-gray-300 text-gray-700 cursor-not-allowed'
|
||||
: 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||
}`}
|
||||
disabled={
|
||||
!allChecked || (allChecked && allValidated && !formData.associated_class)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Section Affectation */}
|
||||
<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
|
||||
text="Valider le dossier d'inscription"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleAssignClass();
|
||||
}}
|
||||
primary
|
||||
className={`px-4 py-2 rounded-md shadow-sm focus:outline-none ${
|
||||
!isPageValid
|
||||
? 'bg-gray-300 text-gray-700 cursor-not-allowed'
|
||||
: 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||
}`}
|
||||
disabled={!isPageValid}
|
||||
/>
|
||||
</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>
|
||||
|
||||
18
Front-End/src/components/MobileTopbar.js
Normal file
18
Front-End/src/components/MobileTopbar.js
Normal file
@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
import { Menu } from 'lucide-react';
|
||||
import ProfileSelector from '@/components/ProfileSelector';
|
||||
|
||||
export default function MobileTopbar({ onMenuClick }) {
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 z-40 h-14 bg-white border-b border-gray-200 flex items-center justify-between px-4 md:hidden">
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="p-2 rounded-md hover:bg-gray-100 border border-gray-200"
|
||||
aria-label="Ouvrir le menu"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<ProfileSelector compact />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@ -6,11 +6,11 @@ const Pagination = ({ currentPage, totalPages, onPageChange }) => {
|
||||
const pages = Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
|
||||
return (
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<div className="px-4 sm:px-6 py-4 border-t border-gray-200 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm text-gray-600">
|
||||
{t('page')} {currentPage} {t('of')} {pages.length}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1 sm:gap-2">
|
||||
{currentPage > 1 && (
|
||||
<PaginationButton
|
||||
text={t('previous')}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { DollarSign } from 'lucide-react';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
const paymentModesOptions = [
|
||||
{ id: 1, name: 'Prélèvement SEPA' },
|
||||
@ -9,8 +10,14 @@ const paymentModesOptions = [
|
||||
{ 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 = ({
|
||||
paymentModes,
|
||||
allPaymentModes,
|
||||
setPaymentModes,
|
||||
handleCreate,
|
||||
handleDelete,
|
||||
@ -19,23 +26,45 @@ const PaymentModeSelector = ({
|
||||
const [activePaymentModes, setActivePaymentModes] = useState([]);
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
|
||||
const modes = useMemo(
|
||||
() =>
|
||||
Array.isArray(allPaymentModes)
|
||||
? allPaymentModes
|
||||
: Array.isArray(paymentModes)
|
||||
? paymentModes
|
||||
: [],
|
||||
[allPaymentModes, paymentModes]
|
||||
);
|
||||
const unified = !!allPaymentModes;
|
||||
|
||||
useEffect(() => {
|
||||
const activeModes = paymentModes.map((mode) => mode.mode);
|
||||
const activeModes = [...new Set(modes.map((mode) => mode.mode))];
|
||||
setActivePaymentModes(activeModes);
|
||||
}, [paymentModes]);
|
||||
}, [modes]);
|
||||
|
||||
const handleModeToggle = (modeId) => {
|
||||
const updatedMode = paymentModes.find((mode) => mode.mode === modeId);
|
||||
const isActive = !!updatedMode;
|
||||
|
||||
const isActive = activePaymentModes.includes(modeId);
|
||||
if (!isActive) {
|
||||
handleCreate({
|
||||
mode: modeId,
|
||||
type,
|
||||
establishment: selectedEstablishmentId,
|
||||
});
|
||||
if (unified) {
|
||||
[0, 1].forEach((t) =>
|
||||
handleCreate({
|
||||
mode: modeId,
|
||||
type: t,
|
||||
establishment: selectedEstablishmentId,
|
||||
}).catch((e) => logger.error(e))
|
||||
);
|
||||
} else {
|
||||
handleCreate({
|
||||
mode: modeId,
|
||||
type,
|
||||
establishment: selectedEstablishmentId,
|
||||
}).catch((e) => logger.error(e));
|
||||
}
|
||||
} else {
|
||||
handleDelete(updatedMode.id, null);
|
||||
const toDelete = modes.filter((m) => m.mode === modeId);
|
||||
toDelete.forEach((m) =>
|
||||
handleDelete(m.id, null).catch((e) => logger.error(e))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Calendar } from 'lucide-react';
|
||||
import Table from '@/components/Table';
|
||||
import Popup from '@/components/Popup';
|
||||
@ -13,8 +13,22 @@ const paymentPlansOptions = [
|
||||
{ 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 = ({
|
||||
paymentPlans,
|
||||
allPaymentPlans,
|
||||
handleCreate,
|
||||
handleDelete,
|
||||
type,
|
||||
@ -24,38 +38,63 @@ const PaymentPlanSelector = ({
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
const [checkedPlans, setCheckedPlans] = useState([]);
|
||||
|
||||
// Vérifie si un plan existe pour ce type (par id)
|
||||
const plans = useMemo(
|
||||
() =>
|
||||
Array.isArray(allPaymentPlans)
|
||||
? allPaymentPlans
|
||||
: Array.isArray(paymentPlans)
|
||||
? paymentPlans
|
||||
: [],
|
||||
[allPaymentPlans, paymentPlans]
|
||||
);
|
||||
const unified = !!allPaymentPlans;
|
||||
|
||||
// Un plan est coché si au moins un enregistrement existe pour cette option
|
||||
const isChecked = (planOption) => checkedPlans.includes(planOption.id);
|
||||
|
||||
// Création ou suppression du plan
|
||||
const handlePlanToggle = (planOption) => {
|
||||
const updatedPlan = paymentPlans.find(
|
||||
(plan) => plan.plan_type === planOption.id
|
||||
);
|
||||
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));
|
||||
handleDelete(updatedPlan.id, null);
|
||||
toDelete.forEach((p) =>
|
||||
handleDelete(p.id, null).catch((e) => logger.error(e))
|
||||
);
|
||||
} else {
|
||||
setCheckedPlans((prev) => [...prev, planOption.id]);
|
||||
handleCreate({
|
||||
plan_type: planOption.id,
|
||||
type,
|
||||
establishment: selectedEstablishmentId,
|
||||
});
|
||||
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({
|
||||
plan_type: planOption.id,
|
||||
type,
|
||||
establishment: selectedEstablishmentId,
|
||||
}).catch((e) => logger.error(e));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (paymentPlans && paymentPlans.length > 0) {
|
||||
setCheckedPlans(
|
||||
paymentPlans.map((plan) =>
|
||||
typeof plan.plan_type === 'object'
|
||||
? plan.plan_type.id
|
||||
: plan.plan_type
|
||||
)
|
||||
if (plans.length > 0) {
|
||||
const ids = plans.map((plan) =>
|
||||
typeof plan.plan_type === 'object' ? plan.plan_type.id : plan.plan_type
|
||||
);
|
||||
setCheckedPlans([...new Set(ids)]);
|
||||
} else {
|
||||
setCheckedPlans([]);
|
||||
}
|
||||
}, [paymentPlans]);
|
||||
}, [plans]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
BASE_URL,
|
||||
} from '@/utils/Url';
|
||||
|
||||
const ProfileSelector = ({ onRoleChange, className = '' }) => {
|
||||
const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
|
||||
const {
|
||||
establishments,
|
||||
selectedRoleId,
|
||||
@ -103,50 +103,72 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
|
||||
// Suppression du tronquage JS, on utilise uniquement CSS
|
||||
const isSingleRole = establishments && establishments.length === 1;
|
||||
|
||||
const buttonContent = compact ? (
|
||||
/* Mode compact : avatar seul pour la topbar mobile */
|
||||
<div className="flex items-center gap-1 cursor-pointer px-2 py-1 rounded-md hover:bg-gray-100">
|
||||
<div className="relative">
|
||||
<Image
|
||||
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)}
|
||||
alt="Profile"
|
||||
className="w-8 h-8 rounded-full object-cover shadow-md"
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
<div
|
||||
className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${getStatusColor()}`}
|
||||
title={getStatusTitle()}
|
||||
/>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-gray-500 transition-transform duration-200 ${dropdownOpen ? 'rotate-180' : 'rotate-0'}`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Mode normal : avatar + infos texte */
|
||||
<div className="flex items-center gap-2 cursor-pointer px-4 bg-white h-24">
|
||||
<div className="relative">
|
||||
<Image
|
||||
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)}
|
||||
alt="Profile"
|
||||
className="w-16 h-16 rounded-full object-cover shadow-md"
|
||||
width={64}
|
||||
height={64}
|
||||
/>
|
||||
<div
|
||||
className={`absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-white ${getStatusColor()}`}
|
||||
title={getStatusTitle()}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="font-semibold text-base text-gray-900 text-left truncate max-w-full"
|
||||
title={user?.email}
|
||||
>
|
||||
{user?.email}
|
||||
</div>
|
||||
<div
|
||||
className="font-semibold text-base text-emerald-700 text-left truncate max-w-full"
|
||||
title={selectedEstablishment?.name || ''}
|
||||
>
|
||||
{selectedEstablishment?.name || ''}
|
||||
</div>
|
||||
<div
|
||||
className="italic text-sm text-emerald-600 text-left truncate max-w-full"
|
||||
title={getRightStr(selectedEstablishment?.role_type) || ''}
|
||||
>
|
||||
{getRightStr(selectedEstablishment?.role_type) || ''}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`w-5 h-5 transition-transform duration-200 ${dropdownOpen ? 'rotate-180' : 'rotate-0'}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<DropdownMenu
|
||||
buttonContent={
|
||||
<div className="h-16 flex items-center gap-2 cursor-pointer px-4 bg-white h-24">
|
||||
<div className="relative">
|
||||
<Image
|
||||
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)}
|
||||
alt="Profile"
|
||||
className="w-16 h-16 rounded-full object-cover shadow-md"
|
||||
width={64}
|
||||
height={64}
|
||||
/>
|
||||
{/* Bulle de statut de connexion au chat */}
|
||||
<div
|
||||
className={`absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-white ${getStatusColor()}`}
|
||||
title={getStatusTitle()}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="font-semibold text-base text-gray-900 text-left truncate max-w-full"
|
||||
title={user?.email}
|
||||
>
|
||||
{user?.email}
|
||||
</div>
|
||||
<div
|
||||
className="font-semibold text-base text-emerald-700 text-left truncate max-w-full"
|
||||
title={selectedEstablishment?.name || ''}
|
||||
>
|
||||
{selectedEstablishment?.name || ''}
|
||||
</div>
|
||||
<div
|
||||
className="italic text-sm text-emerald-600 text-left truncate max-w-full"
|
||||
title={getRightStr(selectedEstablishment?.role_type) || ''}
|
||||
>
|
||||
{getRightStr(selectedEstablishment?.role_type) || ''}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`w-5 h-5 transition-transform duration-200 ${dropdownOpen ? 'rotate-180' : 'rotate-0'}`}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
buttonContent={buttonContent}
|
||||
items={
|
||||
isSingleRole
|
||||
? [
|
||||
@ -190,7 +212,10 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
|
||||
]
|
||||
}
|
||||
buttonClassName="w-full"
|
||||
menuClassName="absolute mt-2 w-full bg-white border border-gray-200 rounded shadow-lg z-10"
|
||||
menuClassName={compact
|
||||
? 'absolute right-0 mt-2 min-w-[200px] bg-white border border-gray-200 rounded shadow-lg z-50'
|
||||
: 'absolute mt-2 w-full bg-white border border-gray-200 rounded shadow-lg z-10'
|
||||
}
|
||||
dropdownOpen={dropdownOpen}
|
||||
setDropdownOpen={setDropdownOpen}
|
||||
/>
|
||||
|
||||
@ -1,27 +1,32 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { FE_USERS_LOGIN_URL, getRedirectUrlFromRole } from '@/utils/Url';
|
||||
import Loader from '@/components/Loader';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
const ProtectedRoute = ({ children, requiredRight }) => {
|
||||
const { user, profileRole } = useEstablishment();
|
||||
const { data: session, status } = useSession();
|
||||
const { profileRole } = useEstablishment();
|
||||
const router = useRouter();
|
||||
const [hasRequiredRight, setHasRequiredRight] = useState(false);
|
||||
|
||||
// Vérifier si l'utilisateur a au moins un rôle correspondant au requiredRight
|
||||
useEffect(() => {
|
||||
logger.debug({
|
||||
user,
|
||||
profileRole,
|
||||
requiredRight,
|
||||
hasRequiredRight,
|
||||
});
|
||||
// Ne pas agir tant que NextAuth charge la session
|
||||
if (status === 'loading') return;
|
||||
|
||||
if (user && profileRole !== null) {
|
||||
logger.debug({ status, profileRole, requiredRight });
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
router.push(FE_USERS_LOGIN_URL);
|
||||
return;
|
||||
}
|
||||
|
||||
// status === 'authenticated' — vérifier les droits
|
||||
if (profileRole !== null && profileRole !== undefined) {
|
||||
let requiredRightChecked = false;
|
||||
if (requiredRight && Array.isArray(requiredRight)) {
|
||||
// Vérifier si l'utilisateur a le droit requis
|
||||
requiredRightChecked = requiredRight.some(
|
||||
(right) => profileRole === right
|
||||
);
|
||||
@ -30,21 +35,18 @@ const ProtectedRoute = ({ children, requiredRight }) => {
|
||||
}
|
||||
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) {
|
||||
const redirectUrl = getRedirectUrlFromRole(profileRole);
|
||||
if (redirectUrl !== null) {
|
||||
router.push(`${redirectUrl}`);
|
||||
if (redirectUrl) {
|
||||
router.push(redirectUrl);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// User non authentifié
|
||||
router.push(`${FE_USERS_LOGIN_URL}`);
|
||||
}
|
||||
}, [user, profileRole]);
|
||||
}, [status, profileRole, requiredRight]);
|
||||
|
||||
// Autoriser l'affichage si authentifié et rôle correct
|
||||
return hasRequiredRight ? children : null;
|
||||
if (status === 'loading' || !hasRequiredRight) return <Loader />;
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
|
||||
15
Front-End/src/components/ServiceWorkerRegister.js
Normal file
15
Front-End/src/components/ServiceWorkerRegister.js
Normal file
@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
import { useEffect } from 'react';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
export default function ServiceWorkerRegister() {
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
.catch((err) => logger.error('Service worker registration failed:', err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -34,7 +34,7 @@ function Sidebar({ currentPage, items, onCloseMobile }) {
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-stone-50 border-r h-full border-gray-200">
|
||||
<div className="border-b border-gray-200 ">
|
||||
<div className="border-b border-gray-200 hidden md:block">
|
||||
<ProfileSelector className="border-none h-24" />
|
||||
</div>
|
||||
<nav className="space-y-1 px-4 py-6">
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
const SidebarTabs = ({ tabs, onTabChange }) => {
|
||||
const [activeTab, setActiveTab] = useState(tabs[0].id);
|
||||
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
||||
const [showRightArrow, setShowRightArrow] = useState(false);
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
const handleTabChange = (tabId) => {
|
||||
setActiveTab(tabId);
|
||||
@ -11,23 +15,77 @@ const SidebarTabs = ({ tabs, onTabChange }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateArrows = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setShowLeftArrow(el.scrollLeft > 0);
|
||||
setShowRightArrow(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateArrows();
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener('scroll', updateArrows);
|
||||
window.addEventListener('resize', updateArrows);
|
||||
return () => {
|
||||
el.removeEventListener('scroll', updateArrows);
|
||||
window.removeEventListener('resize', updateArrows);
|
||||
};
|
||||
}, [tabs]);
|
||||
|
||||
const scroll = (direction) => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollBy({ left: direction === 'left' ? -150 : 150, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full">
|
||||
{/* Tabs Header */}
|
||||
<div className="flex h-14 bg-gray-50 border-b border-gray-200 shadow-sm">
|
||||
{tabs.map((tab) => (
|
||||
<div className="relative flex items-center bg-gray-50 border-b border-gray-200 shadow-sm">
|
||||
{/* Flèche gauche */}
|
||||
{showLeftArrow && (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`flex-1 text-center p-4 font-medium transition-colors duration-200 ${
|
||||
activeTab === tab.id
|
||||
? 'border-b-4 border-emerald-500 text-emerald-600 bg-emerald-50 font-semibold'
|
||||
: 'text-gray-500 hover:text-emerald-500'
|
||||
}`}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
onClick={() => scroll('left')}
|
||||
className="absolute left-0 z-10 h-full w-10 flex items-center justify-center bg-gradient-to-r from-gray-50 via-gray-50 to-transparent text-gray-500 hover:text-emerald-600 active:text-emerald-700"
|
||||
aria-label="Tabs précédents"
|
||||
>
|
||||
{tab.label}
|
||||
<ChevronLeft size={22} strokeWidth={2.5} />
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* Liste des onglets scrollable */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex overflow-x-auto scrollbar-none scroll-smooth"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
className={`flex-shrink-0 whitespace-nowrap h-14 px-5 font-medium transition-colors duration-200 ${
|
||||
activeTab === tab.id
|
||||
? 'border-b-4 border-emerald-500 text-emerald-600 bg-emerald-50 font-semibold'
|
||||
: 'text-gray-500 hover:text-emerald-500'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Flèche droite */}
|
||||
{showRightArrow && (
|
||||
<button
|
||||
onClick={() => scroll('right')}
|
||||
className="absolute right-0 z-10 h-full w-10 flex items-center justify-center bg-gradient-to-l from-gray-50 via-gray-50 to-transparent text-gray-500 hover:text-emerald-600 active:text-emerald-700"
|
||||
aria-label="Tabs suivants"
|
||||
>
|
||||
<ChevronRight size={22} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs Content */}
|
||||
@ -38,10 +96,10 @@ const SidebarTabs = ({ tabs, onTabChange }) => {
|
||||
activeTab === tab.id && (
|
||||
<motion.div
|
||||
key={tab.id}
|
||||
initial={{ opacity: 0, x: 50 }} // Animation d'entrée
|
||||
animate={{ opacity: 1, x: 0 }} // Animation visible
|
||||
exit={{ opacity: 0, x: -50 }} // Animation de sortie
|
||||
transition={{ duration: 0.3 }} // Durée des animations
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
{tab.content}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user