diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 031bc7d..01d4331 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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) diff --git a/.github/instructions/run-tests.instruction.md b/.github/instructions/run-tests.instruction.md new file mode 100644 index 0000000..571ff41 --- /dev/null +++ b/.github/instructions/run-tests.instruction.md @@ -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 | diff --git a/.gitignore b/.gitignore index 2a3a117..3d3a5fd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ .env node_modules/ hardcoded-strings-report.md -backend.env \ No newline at end of file +backend.env +*.log \ No newline at end of file diff --git a/Back-End/Auth/backends.py b/Back-End/Auth/backends.py index 474407e..8dff309 100644 --- a/Back-End/Auth/backends.py +++ b/Back-End/Auth/backends.py @@ -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 + diff --git a/Back-End/Auth/migrations/0001_initial.py b/Back-End/Auth/migrations/0001_initial.py index 6ed472e..c0792e2 100644 --- a/Back-End/Auth/migrations/0001_initial.py +++ b/Back-End/Auth/migrations/0001_initial.py @@ -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')), ], diff --git a/Back-End/Auth/tests.py b/Back-End/Auth/tests.py new file mode 100644 index 0000000..9ceca58 --- /dev/null +++ b/Back-End/Auth/tests.py @@ -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)") + diff --git a/Back-End/Auth/views.py b/Back-End/Auth/views.py index b66c904..8b19429 100644 --- a/Back-End/Auth/views.py +++ b/Back-End/Auth/views.py @@ -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} diff --git a/Back-End/Common/migrations/0001_initial.py b/Back-End/Common/migrations/0001_initial.py index b45b14a..551197b 100644 --- a/Back-End/Common/migrations/0001_initial.py +++ b/Back-End/Common/migrations/0001_initial.py @@ -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 diff --git a/Back-End/Common/tests.py b/Back-End/Common/tests.py index 7ce503c..7e864a7 100644 --- a/Back-End/Common/tests.py +++ b/Back-End/Common/tests.py @@ -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) diff --git a/Back-End/Common/views.py b/Back-End/Common/views.py index a8fa8e2..b88a78c 100644 --- a/Back-End/Common/views.py +++ b/Back-End/Common/views.py @@ -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) diff --git a/Back-End/Establishment/migrations/0001_initial.py b/Back-End/Establishment/migrations/0001_initial.py index c718cca..aa3a761 100644 --- a/Back-End/Establishment/migrations/0001_initial.py +++ b/Back-End/Establishment/migrations/0001_initial.py @@ -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 diff --git a/Back-End/Establishment/tests.py b/Back-End/Establishment/tests.py new file mode 100644 index 0000000..7a7dfca --- /dev/null +++ b/Back-End/Establishment/tests.py @@ -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) diff --git a/Back-End/Establishment/views.py b/Back-End/Establishment/views.py index 0f028cb..a7f3c62 100644 --- a/Back-End/Establishment/views.py +++ b/Back-End/Establishment/views.py @@ -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( diff --git a/Back-End/GestionEmail/tests_security.py b/Back-End/GestionEmail/tests_security.py new file mode 100644 index 0000000..6520885 --- /dev/null +++ b/Back-End/GestionEmail/tests_security.py @@ -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) diff --git a/Back-End/GestionEmail/views.py b/Back-End/GestionEmail/views.py index 8ccbabb..9084539 100644 --- a/Back-End/GestionEmail/views.py +++ b/Back-End/GestionEmail/views.py @@ -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. diff --git a/Back-End/GestionMessagerie/migrations/0001_initial.py b/Back-End/GestionMessagerie/migrations/0001_initial.py index c36fba9..8697cb2 100644 --- a/Back-End/GestionMessagerie/migrations/0001_initial.py +++ b/Back-End/GestionMessagerie/migrations/0001_initial.py @@ -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 diff --git a/Back-End/GestionMessagerie/tests.py b/Back-End/GestionMessagerie/tests.py new file mode 100644 index 0000000..f8fcd8d --- /dev/null +++ b/Back-End/GestionMessagerie/tests.py @@ -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]) diff --git a/Back-End/GestionMessagerie/tests_security.py b/Back-End/GestionMessagerie/tests_security.py new file mode 100644 index 0000000..ddb73d5 --- /dev/null +++ b/Back-End/GestionMessagerie/tests_security.py @@ -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// 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]) diff --git a/Back-End/GestionMessagerie/views.py b/Back-End/GestionMessagerie/views.py index 49be6d7..735edc6 100644 --- a/Back-End/GestionMessagerie/views.py +++ b/Back-End/GestionMessagerie/views.py @@ -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 """ diff --git a/Back-End/GestionNotification/migrations/0001_initial.py b/Back-End/GestionNotification/migrations/0001_initial.py index 4500122..ad8449d 100644 --- a/Back-End/GestionNotification/migrations/0001_initial.py +++ b/Back-End/GestionNotification/migrations/0001_initial.py @@ -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 diff --git a/Back-End/GestionNotification/tests.py b/Back-End/GestionNotification/tests.py new file mode 100644 index 0000000..6e8d1f7 --- /dev/null +++ b/Back-End/GestionNotification/tests.py @@ -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) diff --git a/Back-End/GestionNotification/tests_security.py b/Back-End/GestionNotification/tests_security.py new file mode 100644 index 0000000..b94c577 --- /dev/null +++ b/Back-End/GestionNotification/tests_security.py @@ -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)") diff --git a/Back-End/GestionNotification/views.py b/Back-End/GestionNotification/views.py index b43dee8..01ba370 100644 --- a/Back-End/GestionNotification/views.py +++ b/Back-End/GestionNotification/views.py @@ -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) \ No newline at end of file diff --git a/Back-End/N3wtSchool/error.py b/Back-End/N3wtSchool/error.py index a357692..4f30b1f 100644 --- a/Back-End/N3wtSchool/error.py +++ b/Back-End/N3wtSchool/error.py @@ -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', } \ No newline at end of file diff --git a/Back-End/N3wtSchool/middleware.py b/Back-End/N3wtSchool/middleware.py index 2035519..0ab45da 100644 --- a/Back-End/N3wtSchool/middleware.py +++ b/Back-End/N3wtSchool/middleware.py @@ -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 diff --git a/Back-End/N3wtSchool/settings.py b/Back-End/N3wtSchool/settings.py index 8214b73..40e732e 100644 --- a/Back-End/N3wtSchool/settings.py +++ b/Back-End/N3wtSchool/settings.py @@ -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 = 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 = 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 diff --git a/Back-End/N3wtSchool/test_settings.py b/Back-End/N3wtSchool/test_settings.py new file mode 100644 index 0000000..de2d288 --- /dev/null +++ b/Back-End/N3wtSchool/test_settings.py @@ -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 diff --git a/Back-End/Planning/migrations/0001_initial.py b/Back-End/Planning/migrations/0001_initial.py index 3d41fdb..55cb4bf 100644 --- a/Back-End/Planning/migrations/0001_initial.py +++ b/Back-End/Planning/migrations/0001_initial.py @@ -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 diff --git a/Back-End/Planning/tests.py b/Back-End/Planning/tests.py new file mode 100644 index 0000000..69c7503 --- /dev/null +++ b/Back-End/Planning/tests.py @@ -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) diff --git a/Back-End/Planning/views.py b/Back-End/Planning/views.py index a74c536..18bf551 100644 --- a/Back-End/Planning/views.py +++ b/Back-End/Planning/views.py @@ -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) diff --git a/Back-End/School/migrations/0001_initial.py b/Back-End/School/migrations/0001_initial.py index d0eb4e4..a2ab6af 100644 --- a/Back-End/School/migrations/0001_initial.py +++ b/Back-End/School/migrations/0001_initial.py @@ -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 diff --git a/Back-End/School/tests.py b/Back-End/School/tests.py index 7ce503c..7a802f5 100644 --- a/Back-End/School/tests.py +++ b/Back-End/School/tests.py @@ -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", + ) diff --git a/Back-End/School/views.py b/Back-End/School/views.py index 298d662..5353133 100644 --- a/Back-End/School/views.py +++ b/Back-End/School/views.py @@ -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) diff --git a/Back-End/Settings/migrations/0001_initial.py b/Back-End/Settings/migrations/0001_initial.py index ebc8792..944dc3b 100644 --- a/Back-End/Settings/migrations/0001_initial.py +++ b/Back-End/Settings/migrations/0001_initial.py @@ -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 diff --git a/Back-End/Settings/serializers.py b/Back-End/Settings/serializers.py index 380e5e9..9ce7f63 100644 --- a/Back-End/Settings/serializers.py +++ b/Back-End/Settings/serializers.py @@ -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__' \ No newline at end of file diff --git a/Back-End/Settings/tests_security.py b/Back-End/Settings/tests_security.py new file mode 100644 index 0000000..3978b5a --- /dev/null +++ b/Back-End/Settings/tests_security.py @@ -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]) diff --git a/Back-End/Settings/views.py b/Back-End/Settings/views.py index c13c356..658c58c 100644 --- a/Back-End/Settings/views.py +++ b/Back-End/Settings/views.py @@ -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. """ diff --git a/Back-End/Subscriptions/migrations/0001_initial.py b/Back-End/Subscriptions/migrations/0001_initial.py index d74fbcb..228e5b4 100644 --- a/Back-End/Subscriptions/migrations/0001_initial.py +++ b/Back-End/Subscriptions/migrations/0001_initial.py @@ -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')), ], diff --git a/Back-End/Subscriptions/serializers.py b/Back-End/Subscriptions/serializers.py index 3191a7e..a91f6ae 100644 --- a/Back-End/Subscriptions/serializers.py +++ b/Back-End/Subscriptions/serializers.py @@ -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() diff --git a/Back-End/requirements.txt b/Back-End/requirements.txt index 5d93dbd..d16e277 100644 Binary files a/Back-End/requirements.txt and b/Back-End/requirements.txt differ diff --git a/Back-End/runTests.sh b/Back-End/runTests.sh new file mode 100755 index 0000000..8d01c00 --- /dev/null +++ b/Back-End/runTests.sh @@ -0,0 +1,2 @@ +#!/bin/bash +docker exec -it n3wt-school-backend-1 python3 manage.py test --settings=N3wtSchool.test_settings Auth.tests \ No newline at end of file diff --git a/Back-End/start.py b/Back-End/start.py index c265a97..249fcbd 100644 --- a/Back-End/start.py +++ b/Back-End/start.py @@ -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: diff --git a/Front-End/.babelrc b/Front-End/.babelrc deleted file mode 100644 index 9fcef03..0000000 --- a/Front-End/.babelrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "presets": ["next/babel"], - "plugins": [] -} diff --git a/Front-End/public/icons/icon.svg b/Front-End/public/icons/icon.svg new file mode 100644 index 0000000..98c04af --- /dev/null +++ b/Front-End/public/icons/icon.svg @@ -0,0 +1,4 @@ + + + N3 + \ No newline at end of file diff --git a/Front-End/public/sw.js b/Front-End/public/sw.js new file mode 100644 index 0000000..430faa4 --- /dev/null +++ b/Front-End/public/sw.js @@ -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)) + ); +}); diff --git a/Front-End/src/app/[locale]/admin/grades/[studentId]/page.js b/Front-End/src/app/[locale]/admin/grades/[studentId]/page.js new file mode 100644 index 0000000..e00d9f9 --- /dev/null +++ b/Front-End/src/app/[locale]/admin/grades/[studentId]/page.js @@ -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 ( +
+ {/* Header */} +
+ +

Suivi pédagogique

+
+ + {/* Student profile */} + {student && ( +
+ {student.photo ? ( + {`${student.first_name} + ) : ( +
+ {student.first_name?.[0]} + {student.last_name?.[0]} +
+ )} +
+
+ {student.last_name} {student.first_name} +
+
+ Niveau :{' '} + + {getNiveauLabel(student.level)} + + {' | '} + Classe :{' '} + + {student.associated_class_name} + +
+
+ + {/* Period selector + Evaluate button */} +
+
+ { + 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))} + /> +
+
+
+ )} + + {/* Stats + Absences */} +
+
+
+ +
+
+ +
+
+
+ +
+
+
+ ); +} diff --git a/Front-End/src/app/[locale]/admin/grades/page.js b/Front-End/src/app/[locale]/admin/grades/page.js index 4b098e8..7a5783e 100644 --- a/Front-End/src/app/[locale]/admin/grades/page.js +++ b/Front-End/src/app/[locale]/admin/grades/page.js @@ -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 ; + if (value === null) return ; + 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 ( + + {value}% + + ); +} 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 ( +
+ {student.photo ? ( + e.stopPropagation()} + > + {`${student.first_name} + + ) : ( +
+ + {student.first_name?.[0]}{student.last_name?.[0]} + +
+ )} +
+ ); + case 'Élève': + return ( + + {student.last_name} {student.first_name} + + ); + case 'Niveau': + return getNiveauLabel(student.level); + case 'Classe': + return student.associated_class_id ? ( + + ) : ( + student.associated_class_name + ); + case 'Stat globale': + return ( + + ); + case 'Absences': + return absencesMap[student.id] ? ( + + {absencesMap[student.id]} + + ) : ( + 0 + ); + case 'Actions': + return ( +
+ + +
+ ); + default: { + const col = periodColumns.find((c) => c.label === column); + if (col) { + return ( + + ); + } + return null; + } + } }; return ( -
+
- - {/* Section haute : filtre + bouton + photo élève */} -
- {/* Colonne gauche : InputText + bouton */} -
-
- setSearchTerm(e.target.value)} - placeholder="Rechercher un élève" - required={false} - enable={true} - /> -
-
- { - 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} - /> -
-
-
-
- {/* Colonne droite : Photo élève */} -
- {formData.selectedStudent && - (() => { - const student = students.find( - (s) => s.id === formData.selectedStudent - ); - if (!student) return null; - return ( - <> - {student.photo ? ( - {`${student.first_name} - ) : ( -
- {student.first_name?.[0]} - {student.last_name?.[0]} -
- )} - - ); - })()} -
+
+ + setSearchTerm(e.target.value)} + />
- {/* Section basse : liste élèves + infos */} -
- {/* Colonne 1 : Liste des élèves */} -
-

- Liste des élèves -

-
    - {students - .filter( - (student) => - !searchTerm || - `${student.last_name} ${student.first_name}` - .toLowerCase() - .includes(searchTerm.toLowerCase()) - ) - .map((student) => ( -
  • handleChange('selectedStudent', student.id)} - > - {student.photo ? ( - {`${student.first_name} - ) : ( -
    - {student.first_name?.[0]} - {student.last_name?.[0]} -
    - )} -
    -
    - {student.last_name} {student.first_name} -
    -
    - Niveau :{' '} - - {getNiveauLabel(student.level)} - - {' | '} - Classe :{' '} - - {student.associated_class_name} - -
    -
    - {/* 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 ( - e.stopPropagation()} // Pour ne pas sélectionner à nouveau l'élève - > - - - ); - } - return null; - })()} -
  • - ))} -
-
- {/* Colonne 2 : Reste des infos */} -
- {formData.selectedStudent && ( -
-
-
- -
-
- -
-
-
- -
-
- )} -
-
+ Aucun élève trouvé + } + /> ); } diff --git a/Front-End/src/app/[locale]/admin/grades/studentCompetencies/page.js b/Front-End/src/app/[locale]/admin/grades/studentCompetencies/page.js index cca7e8e..5500667 100644 --- a/Front-End/src/app/[locale]/admin/grades/studentCompetencies/page.js +++ b/Front-End/src/app/[locale]/admin/grades/studentCompetencies/page.js @@ -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 (
- +
+ +

Bilan de compétence

+
-
diff --git a/Front-End/src/app/[locale]/admin/layout.js b/Front-End/src/app/[locale]/admin/layout.js index 72d8819..75587bb 100644 --- a/Front-End/src/app/[locale]/admin/layout.js +++ b/Front-End/src/app/[locale]/admin/layout.js @@ -29,6 +29,7 @@ import { import { disconnect } from '@/app/actions/authAction'; import ProtectedRoute from '@/components/ProtectedRoute'; import Footer from '@/components/Footer'; +import MobileTopbar from '@/components/MobileTopbar'; import { RIGHTS } from '@/utils/rights'; import { useEstablishment } from '@/context/EstablishmentContext'; @@ -123,9 +124,12 @@ export default function Layout({ children }) { return ( + {/* Topbar mobile (hamburger + logo) */} + + {/* Sidebar */}
@@ -146,7 +150,7 @@ export default function Layout({ children }) { )} {/* Main container */} -
+
{children}
diff --git a/Front-End/src/app/[locale]/admin/page.js b/Front-End/src/app/[locale]/admin/page.js index 787f3d2..12a12b9 100644 --- a/Front-End/src/app/[locale]/admin/page.js +++ b/Front-End/src/app/[locale]/admin/page.js @@ -163,7 +163,7 @@ export default function DashboardPage() { if (isLoading) return ; return ( -
+
{/* Statistiques principales */}
{/* Graphique des inscriptions */} -
-

+
+

{t('inscriptionTrends')}

-
-
+
+
@@ -214,13 +214,13 @@ export default function DashboardPage() {
{/* Présence et assiduité */} -
+
{/* Colonne de droite : Événements à venir */} -
+

{t('upcomingEvents')}

{upcomingEvents.map((event, index) => ( diff --git a/Front-End/src/app/[locale]/admin/planning/page.js b/Front-End/src/app/[locale]/admin/planning/page.js index 1563a07..cff8307 100644 --- a/Front-End/src/app/[locale]/admin/planning/page.js +++ b/Front-End/src/app/[locale]/admin/planning/page.js @@ -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} >
- + setIsDrawerOpen(false)} + /> { setEventData(event); setIsModalOpen(true); }} + onOpenDrawer={() => setIsDrawerOpen(true)} /> { - 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); diff --git a/Front-End/src/app/[locale]/admin/subscriptions/createSubscription/page.js b/Front-End/src/app/[locale]/admin/subscriptions/createSubscription/page.js index 879ed94..929f518 100644 --- a/Front-End/src/app/[locale]/admin/subscriptions/createSubscription/page.js +++ b/Front-End/src/app/[locale]/admin/subscriptions/createSubscription/page.js @@ -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 ; // Affichez le composant Loader } @@ -869,7 +876,7 @@ export default function CreateSubscriptionPage() { {!isNewResponsable && (

{selectedStudent && ( diff --git a/Front-End/src/app/[locale]/parents/layout.js b/Front-End/src/app/[locale]/parents/layout.js index 9c8003d..22319ae 100644 --- a/Front-End/src/app/[locale]/parents/layout.js +++ b/Front-End/src/app/[locale]/parents/layout.js @@ -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 ( - {/* Bouton hamburger pour mobile */} - + {/* Topbar mobile (hamburger + logo) */} + {/* Sidebar */}
@@ -104,7 +100,7 @@ export default function Layout({ children }) { {/* Main container */}
{children}
diff --git a/Front-End/src/app/actions/actionsHandlers.js b/Front-End/src/app/actions/actionsHandlers.js index 92d94c6..495cd55 100644 --- a/Front-End/src/app/actions/actionsHandlers.js +++ b/Front-End/src/app/actions/actionsHandlers.js @@ -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; diff --git a/Front-End/src/app/actions/authAction.js b/Front-End/src/app/actions/authAction.js index 2e24642..0000025 100644 --- a/Front-End/src/app/actions/authAction.js +++ b/Front-End/src/app/actions/authAction.js @@ -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) => { diff --git a/Front-End/src/app/actions/emailAction.js b/Front-End/src/app/actions/emailAction.js index 45c4583..8fdc3bb 100644 --- a/Front-End/src/app/actions/emailAction.js +++ b/Front-End/src/app/actions/emailAction.js @@ -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); + }); }; diff --git a/Front-End/src/app/actions/messagerieAction.js b/Front-End/src/app/actions/messagerieAction.js index b73a41e..ebd47cd 100644 --- a/Front-End/src/app/actions/messagerieAction.js +++ b/Front-End/src/app/actions/messagerieAction.js @@ -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; } }; diff --git a/Front-End/src/app/actions/planningAction.js b/Front-End/src/app/actions/planningAction.js index 88d856b..02a61cb 100644 --- a/Front-End/src/app/actions/planningAction.js +++ b/Front-End/src/app/actions/planningAction.js @@ -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 = ( diff --git a/Front-End/src/app/actions/registerFileGroupAction.js b/Front-End/src/app/actions/registerFileGroupAction.js index 1927804..7298369 100644 --- a/Front-End/src/app/actions/registerFileGroupAction.js +++ b/Front-End/src/app/actions/registerFileGroupAction.js @@ -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 }, } ); }; diff --git a/Front-End/src/app/actions/schoolAction.js b/Front-End/src/app/actions/schoolAction.js index e051d7b..d151f87 100644 --- a/Front-End/src/app/actions/schoolAction.js +++ b/Front-End/src/app/actions/schoolAction.js @@ -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 }, + }); }; diff --git a/Front-End/src/app/actions/settingsAction.js b/Front-End/src/app/actions/settingsAction.js index 5018589..ba12999 100644 --- a/Front-End/src/app/actions/settingsAction.js +++ b/Front-End/src/app/actions/settingsAction.js @@ -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); + }); }; diff --git a/Front-End/src/app/actions/subscriptionAction.js b/Front-End/src/app/actions/subscriptionAction.js index 9949bb9..9ee3a60 100644 --- a/Front-End/src/app/actions/subscriptionAction.js +++ b/Front-End/src/app/actions/subscriptionAction.js @@ -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); }; diff --git a/Front-End/src/app/layout.js b/Front-End/src/app/layout.js index 19f8d28..1c9643d 100644 --- a/Front-End/src/app/layout.js +++ b/Front-End/src/app/layout.js @@ -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 }) { {children} + ); diff --git a/Front-End/src/app/manifest.js b/Front-End/src/app/manifest.js new file mode 100644 index 0000000..10a6b89 --- /dev/null +++ b/Front-End/src/app/manifest.js @@ -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', + }, + ], + }; +} diff --git a/Front-End/src/components/Calendar/Calendar.js b/Front-End/src/components/Calendar/Calendar.js index 548aa33..2223e38 100644 --- a/Front-End/src/components/Calendar/Calendar.js +++ b/Front-End/src/components/Calendar/Calendar.js @@ -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 (
-
- {/* Navigation à gauche */} - {planningMode === PlanningModes.PLANNING && ( -
- - -
- - {showDatePicker && ( -
- {viewType !== 'year' && ( -
-
- {months.map((month) => ( - - ))} + {/* Header uniquement sur desktop */} +
+ <> + {planningMode === PlanningModes.PLANNING && ( +
+ + +
+ + {showDatePicker && ( +
+ {viewType !== 'year' && ( +
+
+ {months.map((month) => ( + + ))} +
+
+ )} +
+
+ {years.map((year) => ( + + ))} +
)} -
-
- {years.map((year) => ( - - ))} -
-
+
+ +
+ )} + +
+ {((planningMode === PlanningModes.PLANNING || planningMode === PlanningModes.CLASS_SCHEDULE) && viewType === 'week' && !parentView) && ( +
+ Semaine + + {getWeek(currentDate, { weekStartsOn: 1 })} +
)} + {parentView && ( + + {planningClassName} + + )}
- -
- )} - {/* Centre : numéro de semaine ou classe/niveau */} -
- {((planningMode === PlanningModes.PLANNING || planningMode === PlanningModes.CLASS_SCHEDULE) && viewType === 'week' && !parentView) && ( -
- Semaine - - {getWeek(currentDate, { weekStartsOn: 1 })} - +
+ {planningMode === PlanningModes.PLANNING && ( + + )} + {(planningMode === PlanningModes.PLANNING || (planningMode === PlanningModes.CLASS_SCHEDULE && !parentView)) && ( + + )}
- )} - {parentView && ( - - {/* À adapter selon les props disponibles */} - {planningClassName} - - )} -
- - {/* Contrôles à droite */} -
- {planningMode === PlanningModes.PLANNING && ( - - )} - {(planningMode === PlanningModes.PLANNING || (planningMode === PlanningModes.CLASS_SCHEDULE && !parentView)) && ( - - )} -
+
{/* Contenu scrollable */}
- {viewType === 'week' && ( + {isMobile && ( + + + + )} + {!isMobile && viewType === 'week' && ( )} - {viewType === 'month' && ( + {!isMobile && viewType === 'month' && ( )} - {viewType === 'year' && ( + {!isMobile && viewType === 'year' && ( )} - {viewType === 'planning' && ( + {!isMobile && viewType === 'planning' && ( { + 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 ( +
+ {/* Barre de navigation (remplace le header Calendar sur mobile) */} +
+ + +
+ + + +
+ + +
+ + {/* Bandeau jours de la semaine */} +
+ {weekDays.map((day) => ( + + ))} +
+ + {/* Grille horaire */} +
+ {isCurrentDay && ( +
+
+
+ )} + +
+ {timeSlots.map((hour) => ( + +
+ {`${hour.toString().padStart(2, '0')}:00`} +
+
{ + const date = new Date(currentDate); + date.setHours(hour); + date.setMinutes(0); + onDateClick(date); + } + } + > + {dayEvents + .filter((e) => new Date(e.start).getHours() === hour) + .map((event) => ( +
{ + e.stopPropagation(); + onEventClick(event); + } + } + > +
+
+ {event.title} +
+
+ {format(new Date(event.start), 'HH:mm')} –{' '} + {format(new Date(event.end), 'HH:mm')} +
+ {event.location && ( +
+ {event.location} +
+ )} +
+
+ ))} +
+
+ ))} +
+
+
+ ); +}; + +export default DayView; diff --git a/Front-End/src/components/Calendar/EventModal.js b/Front-End/src/components/Calendar/EventModal.js index d89c098..04d8e22 100644 --- a/Front-End/src/components/Calendar/EventModal.js +++ b/Front-End/src/components/Calendar/EventModal.js @@ -253,7 +253,7 @@ export default function EventModal({ )} {/* Dates */} -
+
+
diff --git a/Front-End/src/components/Calendar/ScheduleNavigation.js b/Front-End/src/components/Calendar/ScheduleNavigation.js index e9c60ac..e7670fc 100644 --- a/Front-End/src/components/Calendar/ScheduleNavigation.js +++ b/Front-End/src/components/Calendar/ScheduleNavigation.js @@ -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 ( - + + ); + + return ( + <> + {/* Desktop : sidebar fixe */} + + + {/* Mobile : drawer en overlay */} +
+
+
+
+

{title}

+
+ + +
+
+
+ {listContent} +
+
+
+ ); } diff --git a/Front-End/src/components/Calendar/YearView.js b/Front-End/src/components/Calendar/YearView.js index e6261ed..9c90c18 100644 --- a/Front-End/src/components/Calendar/YearView.js +++ b/Front-End/src/components/Calendar/YearView.js @@ -36,7 +36,7 @@ const YearView = ({ onDateClick }) => { }; return ( -
+
{months.map((month) => ( idx !== -1); return ( -
+
{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 (
- {/* Valeur au-dessus de la barre */} + {/* Valeur au-dessus de la barre — hors de la zone hauteur fixe */} {point.value} + {/* Zone barres à hauteur fixe, alignées en bas */}
+ className="w-full flex items-end justify-center" + style={{ height: chartHeight }} + > +
+
+ {/* Label mois en dessous */} {point.month}
); diff --git a/Front-End/src/components/Chat/InstantChat.js b/Front-End/src/components/Chat/InstantChat.js index f2b9fb7..315ef0e 100644 --- a/Front-End/src/components/Chat/InstantChat.js +++ b/Front-End/src/components/Chat/InstantChat.js @@ -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 (
{/* Sidebar des conversations */} -
+
{/* En-tête */}
@@ -986,12 +988,20 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
{/* Zone de chat principale */} -
+
{selectedConversation ? ( <> {/* En-tête de la conversation */}
+ {/* Bouton retour liste sur mobile */} +
{/* Rectangle gauche avec l'icône */}
{icon}
{/* Zone de texte */} -
+

{title}

{message}

{type === 'error' && errorCode && ( diff --git a/Front-End/src/components/Footer.js b/Front-End/src/components/Footer.js index 3aaed9b..588f4ea 100644 --- a/Front-End/src/components/Footer.js +++ b/Front-End/src/components/Footer.js @@ -2,7 +2,7 @@ import Logo from '@/components/Logo'; export default function Footer({ softwareName, softwareVersion }) { return ( -
+
© {new Date().getFullYear()} N3WT-INNOV Tous droits réservés. diff --git a/Front-End/src/components/Form/FormRenderer.js b/Front-End/src/components/Form/FormRenderer.js index 3a0458f..c34b8dd 100644 --- a/Front-End/src/components/Form/FormRenderer.js +++ b/Front-End/src/components/Form/FormRenderer.js @@ -48,30 +48,6 @@ export default function FormRenderer({ } }, [initialValues, reset]); - // 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; - } - }; - const onSubmit = async (data) => { logger.debug('=== DÉBUT onSubmit ==='); logger.debug('Réponses :', data); diff --git a/Front-End/src/components/Grades/GradeView.js b/Front-End/src/components/Grades/GradeView.js index d245fba..d296ad3 100644 --- a/Front-End/src/components/Grades/GradeView.js +++ b/Front-End/src/components/Grades/GradeView.js @@ -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}
- - {openDomains[domaine.domaine_id] ? '▼' : '►'} - + {openDomains[domaine.domaine_id] + ? + : + }
{openDomains[domaine.domaine_id] && (
@@ -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] + ? + : + } {categorie.categorie_nom} {openCategories[categorie.categorie_id] && ( diff --git a/Front-End/src/components/MobileTopbar.js b/Front-End/src/components/MobileTopbar.js new file mode 100644 index 0000000..4869918 --- /dev/null +++ b/Front-End/src/components/MobileTopbar.js @@ -0,0 +1,18 @@ +'use client'; +import { Menu } from 'lucide-react'; +import ProfileSelector from '@/components/ProfileSelector'; + +export default function MobileTopbar({ onMenuClick }) { + return ( +
+ + +
+ ); +} diff --git a/Front-End/src/components/Pagination.js b/Front-End/src/components/Pagination.js index 9957381..4655495 100644 --- a/Front-End/src/components/Pagination.js +++ b/Front-End/src/components/Pagination.js @@ -6,11 +6,11 @@ const Pagination = ({ currentPage, totalPages, onPageChange }) => { const pages = Array.from({ length: totalPages }, (_, i) => i + 1); return ( -
+
{t('page')} {currentPage} {t('of')} {pages.length}
-
+
{currentPage > 1 && ( + 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)) + ); } }; diff --git a/Front-End/src/components/PaymentPlanSelector.js b/Front-End/src/components/PaymentPlanSelector.js index f2f12e7..eeac07a 100644 --- a/Front-End/src/components/PaymentPlanSelector.js +++ b/Front-End/src/components/PaymentPlanSelector.js @@ -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 (
diff --git a/Front-End/src/components/ProfileSelector.js b/Front-End/src/components/ProfileSelector.js index a3d9520..2cce77b 100644 --- a/Front-End/src/components/ProfileSelector.js +++ b/Front-End/src/components/ProfileSelector.js @@ -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 */ +
+
+ Profile +
+
+ +
+ ) : ( + /* Mode normal : avatar + infos texte */ +
+
+ Profile +
+
+
+
+ {user?.email} +
+
+ {selectedEstablishment?.name || ''} +
+
+ {getRightStr(selectedEstablishment?.role_type) || ''} +
+
+ +
+ ); + return (
-
- Profile - {/* Bulle de statut de connexion au chat */} -
-
-
-
- {user?.email} -
-
- {selectedEstablishment?.name || ''} -
-
- {getRightStr(selectedEstablishment?.role_type) || ''} -
-
- -
- } + 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} /> diff --git a/Front-End/src/components/ProtectedRoute.js b/Front-End/src/components/ProtectedRoute.js index 17a296a..c11623b 100644 --- a/Front-End/src/components/ProtectedRoute.js +++ b/Front-End/src/components/ProtectedRoute.js @@ -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 ; + + return children; }; export default ProtectedRoute; diff --git a/Front-End/src/components/ServiceWorkerRegister.js b/Front-End/src/components/ServiceWorkerRegister.js new file mode 100644 index 0000000..6ee20a6 --- /dev/null +++ b/Front-End/src/components/ServiceWorkerRegister.js @@ -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; +} diff --git a/Front-End/src/components/Sidebar.js b/Front-End/src/components/Sidebar.js index 0a064f6..41da641 100644 --- a/Front-End/src/components/Sidebar.js +++ b/Front-End/src/components/Sidebar.js @@ -34,7 +34,7 @@ function Sidebar({ currentPage, items, onCloseMobile }) { return (
-
+