11 Commits

Author SHA1 Message Date
905fa5dbfb feat: Ajout d'un système de notation par classe et par matière et par élève [N3WTS-6] 2026-04-03 22:10:32 +02:00
edb9ace6ae Merge remote-tracking branch 'origin/develop' into N3WTS-6-Amelioration_Suivi_Eleve-ACA 2026-04-03 17:35:29 +02:00
4e50a0696f Merge pull request 'feat(frontend): refonte mobile planning et ameliorations suivi pedagogique [#NEWTS-4]' (!74) from NEWTS-4-Gestion_Responsive_Tailwind into develop
Reviewed-on: https://git.v0id.ovh/n3wt-innov/n3wt-school/pulls/74
2026-03-16 11:29:41 +00:00
4248a589c5 feat(frontend): refonte mobile planning et ameliorations suivi pedagogique [#NEWTS-4]
Fonction PWA et ajout du responsive design

Planning mobile :
- Nouvelle vue DayView avec bandeau semaine scrollable, date picker natif et navigation integree
- ScheduleNavigation converti en drawer overlay sur mobile, sidebar fixe sur desktop
- Suppression double barre navigation mobile, controles deplaces dans DayView
- Date picker natif via label+input sur mobile

Suivi pedagogique :
- Refactorisation page grades avec composant Table partage
- Colonnes stats par periode, absences, actions (Fiche + Evaluer)
- Lien cliquable sur la classe vers SchoolClassManagement

feat(backend): ajout associated_class_id dans StudentByRFCreationSerializer [#NEWTS-4]

UI global :
- Remplacement fleches texte par icones Lucide ChevronDown/ChevronRight
- Pagination conditionnelle sur tous les tableaux plats
- Layout responsive mobile : cartes separees fond transparent
- Table.js : pagination optionnelle, wrapper md uniquement
2026-03-16 12:27:06 +01:00
7464b19de5 Merge pull request 'NEWTS-9-Fusion_Liste_frais' (!73) from NEWTS-9-Fusion_Liste_frais into develop
Reviewed-on: https://git.v0id.ovh/n3wt-innov/n3wt-school/pulls/73
2026-03-15 11:11:34 +00:00
c96b9562a2 chore(ci): ajout test_settings.py et SKILL run-tests 2026-03-15 12:09:31 +01:00
7576b5a68c test(frontend): ajout tests unitaires Jest composants frais [#NEWTS-9] 2026-03-15 12:09:18 +01:00
e30a41a58b feat(frontend): fusion liste des frais et message compte existant [#NEWTS-9] 2026-03-15 12:09:02 +01:00
c296af2c07 Merge pull request 'NEWTS-12 : Sécurisation Backend' (!72) from NEWTS12-Securisation_Backend into develop
Reviewed-on: https://git.v0id.ovh/n3wt-innov/n3wt-school/pulls/72
2026-03-15 09:11:16 +00:00
fa843097ba feat: Securisation du Backend 2026-03-15 10:07:20 +01:00
6fb3c5cdb4 feat: lister uniquement les élèves inscrits dans une classe [N3WTS-6] 2026-03-14 13:11:30 +01:00
118 changed files with 7602 additions and 2018 deletions

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@ -156,3 +156,46 @@ class EstablishmentCompetency(models.Model):
if self.competency: if self.competency:
return f"{self.establishment.name} - {self.competency.name}" return f"{self.establishment.name} - {self.competency.name}"
return f"{self.establishment.name} - {self.custom_name} (custom)" return f"{self.establishment.name} - {self.custom_name} (custom)"
class Evaluation(models.Model):
"""
Définition d'une évaluation (contrôle, examen, etc.) associée à une matière et une classe.
"""
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
speciality = models.ForeignKey(Speciality, on_delete=models.CASCADE, related_name='evaluations')
school_class = models.ForeignKey(SchoolClass, on_delete=models.CASCADE, related_name='evaluations')
period = models.CharField(max_length=20, help_text="Période ex: T1_2025-2026, S1_2025-2026, A_2025-2026")
date = models.DateField(null=True, blank=True)
max_score = models.DecimalField(max_digits=5, decimal_places=2, default=20)
coefficient = models.DecimalField(max_digits=3, decimal_places=2, default=1)
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='evaluations')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-date', '-created_at']
def __str__(self):
return f"{self.name} - {self.speciality.name} ({self.school_class.atmosphere_name})"
class StudentEvaluation(models.Model):
"""
Note d'un élève pour une évaluation.
"""
student = models.ForeignKey('Subscriptions.Student', on_delete=models.CASCADE, related_name='evaluation_scores')
evaluation = models.ForeignKey(Evaluation, on_delete=models.CASCADE, related_name='student_scores')
score = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
comment = models.TextField(blank=True)
is_absent = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('student', 'evaluation')
def __str__(self):
score_display = 'Absent' if self.is_absent else self.score
return f"{self.student} - {self.evaluation.name}: {score_display}"

View File

@ -10,7 +10,9 @@ from .models import (
PaymentPlan, PaymentPlan,
PaymentMode, PaymentMode,
EstablishmentCompetency, EstablishmentCompetency,
Competency Competency,
Evaluation,
StudentEvaluation
) )
from Auth.models import Profile, ProfileRole from Auth.models import Profile, ProfileRole
from Subscriptions.models import Student from Subscriptions.models import Student
@ -182,12 +184,17 @@ class SchoolClassSerializer(serializers.ModelSerializer):
teachers = serializers.PrimaryKeyRelatedField(queryset=Teacher.objects.all(), many=True, required=False) teachers = serializers.PrimaryKeyRelatedField(queryset=Teacher.objects.all(), many=True, required=False)
establishment = serializers.PrimaryKeyRelatedField(queryset=Establishment.objects.all(), required=False) establishment = serializers.PrimaryKeyRelatedField(queryset=Establishment.objects.all(), required=False)
teachers_details = serializers.SerializerMethodField() teachers_details = serializers.SerializerMethodField()
students = StudentDetailSerializer(many=True, read_only=True) students = serializers.SerializerMethodField()
class Meta: class Meta:
model = SchoolClass model = SchoolClass
fields = '__all__' fields = '__all__'
def get_students(self, obj):
# Filtrer uniquement les étudiants dont le dossier est validé (status = 5)
validated_students = obj.students.filter(registrationform__status=5)
return StudentDetailSerializer(validated_students, many=True).data
def create(self, validated_data): def create(self, validated_data):
teachers_data = validated_data.pop('teachers', []) teachers_data = validated_data.pop('teachers', [])
levels_data = validated_data.pop('levels', []) levels_data = validated_data.pop('levels', [])
@ -300,3 +307,31 @@ class PaymentModeSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = PaymentMode model = PaymentMode
fields = '__all__' fields = '__all__'
class EvaluationSerializer(serializers.ModelSerializer):
speciality_name = serializers.CharField(source='speciality.name', read_only=True)
speciality_color = serializers.CharField(source='speciality.color_code', read_only=True)
school_class_name = serializers.CharField(source='school_class.atmosphere_name', read_only=True)
class Meta:
model = Evaluation
fields = '__all__'
class StudentEvaluationSerializer(serializers.ModelSerializer):
student_name = serializers.SerializerMethodField()
student_first_name = serializers.CharField(source='student.first_name', read_only=True)
student_last_name = serializers.CharField(source='student.last_name', read_only=True)
evaluation_name = serializers.CharField(source='evaluation.name', read_only=True)
max_score = serializers.DecimalField(source='evaluation.max_score', read_only=True, max_digits=5, decimal_places=2)
speciality_name = serializers.CharField(source='evaluation.speciality.name', read_only=True)
speciality_color = serializers.CharField(source='evaluation.speciality.color', read_only=True)
period = serializers.CharField(source='evaluation.period', read_only=True)
class Meta:
model = StudentEvaluation
fields = '__all__'
def get_student_name(self, obj):
return f"{obj.student.last_name} {obj.student.first_name}"

View File

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

View File

@ -11,6 +11,8 @@ from .views import (
PaymentModeListCreateView, PaymentModeDetailView, PaymentModeListCreateView, PaymentModeDetailView,
CompetencyListCreateView, CompetencyDetailView, CompetencyListCreateView, CompetencyDetailView,
EstablishmentCompetencyListCreateView, EstablishmentCompetencyDetailView, EstablishmentCompetencyListCreateView, EstablishmentCompetencyDetailView,
EvaluationListCreateView, EvaluationDetailView,
StudentEvaluationListView, StudentEvaluationBulkUpdateView, StudentEvaluationDetailView,
) )
urlpatterns = [ urlpatterns = [
@ -43,4 +45,13 @@ urlpatterns = [
re_path(r'^establishmentCompetencies$', EstablishmentCompetencyListCreateView.as_view(), name="establishment_competency_list_create"), re_path(r'^establishmentCompetencies$', EstablishmentCompetencyListCreateView.as_view(), name="establishment_competency_list_create"),
re_path(r'^establishmentCompetencies/(?P<id>[0-9]+)$', EstablishmentCompetencyDetailView.as_view(), name="establishment_competency_detail"), re_path(r'^establishmentCompetencies/(?P<id>[0-9]+)$', EstablishmentCompetencyDetailView.as_view(), name="establishment_competency_detail"),
# Evaluations
re_path(r'^evaluations$', EvaluationListCreateView.as_view(), name="evaluation_list_create"),
re_path(r'^evaluations/(?P<id>[0-9]+)$', EvaluationDetailView.as_view(), name="evaluation_detail"),
# Student Evaluations
re_path(r'^studentEvaluations$', StudentEvaluationListView.as_view(), name="student_evaluation_list"),
re_path(r'^studentEvaluations/bulk$', StudentEvaluationBulkUpdateView.as_view(), name="student_evaluation_bulk"),
re_path(r'^studentEvaluations/(?P<id>[0-9]+)$', StudentEvaluationDetailView.as_view(), name="student_evaluation_detail"),
] ]

View File

@ -4,6 +4,7 @@ from django.utils.decorators import method_decorator
from rest_framework.parsers import JSONParser from rest_framework.parsers import JSONParser
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework import status from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from .models import ( from .models import (
Teacher, Teacher,
Speciality, Speciality,
@ -11,10 +12,13 @@ from .models import (
Planning, Planning,
Discount, Discount,
Fee, Fee,
FeeType,
PaymentPlan, PaymentPlan,
PaymentMode, PaymentMode,
EstablishmentCompetency, EstablishmentCompetency,
Competency Competency,
Evaluation,
StudentEvaluation
) )
from .serializers import ( from .serializers import (
TeacherSerializer, TeacherSerializer,
@ -26,7 +30,9 @@ from .serializers import (
PaymentPlanSerializer, PaymentPlanSerializer,
PaymentModeSerializer, PaymentModeSerializer,
EstablishmentCompetencySerializer, EstablishmentCompetencySerializer,
CompetencySerializer CompetencySerializer,
EvaluationSerializer,
StudentEvaluationSerializer
) )
from Common.models import Domain, Category from Common.models import Domain, Category
from N3wtSchool.bdd import delete_object, getAllObjects, getObject from N3wtSchool.bdd import delete_object, getAllObjects, getObject
@ -42,6 +48,8 @@ logger = logging.getLogger(__name__)
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class SpecialityListCreateView(APIView): class SpecialityListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None: if establishment_id is None:
@ -66,6 +74,8 @@ class SpecialityListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class SpecialityDetailView(APIView): class SpecialityDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id): def get(self, request, id):
speciality = getObject(_objectName=Speciality, _columnName='id', _value=id) speciality = getObject(_objectName=Speciality, _columnName='id', _value=id)
speciality_serializer=SpecialitySerializer(speciality) speciality_serializer=SpecialitySerializer(speciality)
@ -87,6 +97,8 @@ class SpecialityDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class TeacherListCreateView(APIView): class TeacherListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None: if establishment_id is None:
@ -121,6 +133,8 @@ class TeacherListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class TeacherDetailView(APIView): class TeacherDetailView(APIView):
permission_classes = [IsAuthenticated]
def get (self, request, id): def get (self, request, id):
teacher = getObject(_objectName=Teacher, _columnName='id', _value=id) teacher = getObject(_objectName=Teacher, _columnName='id', _value=id)
teacher_serializer=TeacherSerializer(teacher) teacher_serializer=TeacherSerializer(teacher)
@ -169,6 +183,8 @@ class TeacherDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class SchoolClassListCreateView(APIView): class SchoolClassListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None: if establishment_id is None:
@ -193,6 +209,8 @@ class SchoolClassListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class SchoolClassDetailView(APIView): class SchoolClassDetailView(APIView):
permission_classes = [IsAuthenticated]
def get (self, request, id): def get (self, request, id):
schoolClass = getObject(_objectName=SchoolClass, _columnName='id', _value=id) schoolClass = getObject(_objectName=SchoolClass, _columnName='id', _value=id)
classe_serializer=SchoolClassSerializer(schoolClass) classe_serializer=SchoolClassSerializer(schoolClass)
@ -215,6 +233,8 @@ class SchoolClassDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class PlanningListCreateView(APIView): class PlanningListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
schedulesList=getAllObjects(Planning) schedulesList=getAllObjects(Planning)
schedules_serializer=PlanningSerializer(schedulesList, many=True) schedules_serializer=PlanningSerializer(schedulesList, many=True)
@ -233,6 +253,8 @@ class PlanningListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class PlanningDetailView(APIView): class PlanningDetailView(APIView):
permission_classes = [IsAuthenticated]
def get (self, request, id): def get (self, request, id):
planning = getObject(_objectName=Planning, _columnName='classe_id', _value=id) planning = getObject(_objectName=Planning, _columnName='classe_id', _value=id)
planning_serializer=PlanningSerializer(planning) planning_serializer=PlanningSerializer(planning)
@ -263,13 +285,21 @@ class PlanningDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class FeeListCreateView(APIView): class FeeListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None: if establishment_id is None:
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
filter = request.GET.get('filter', '').strip() filter = request.GET.get('filter', '').strip()
fee_type_value = 0 if filter == 'registration' else 1 if filter not in ('registration', 'tuition'):
return JsonResponse(
{'error': "Le paramètre 'filter' doit être 'registration' ou 'tuition'"},
safe=False,
status=status.HTTP_400_BAD_REQUEST,
)
fee_type_value = FeeType.REGISTRATION_FEE if filter == 'registration' else FeeType.TUITION_FEE
fees = Fee.objects.filter(type=fee_type_value, establishment_id=establishment_id).distinct() fees = Fee.objects.filter(type=fee_type_value, establishment_id=establishment_id).distinct()
fee_serializer = FeeSerializer(fees, many=True) fee_serializer = FeeSerializer(fees, many=True)
@ -287,6 +317,8 @@ class FeeListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class FeeDetailView(APIView): class FeeDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id): def get(self, request, id):
try: try:
fee = Fee.objects.get(id=id) fee = Fee.objects.get(id=id)
@ -313,6 +345,8 @@ class FeeDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class DiscountListCreateView(APIView): class DiscountListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None: if establishment_id is None:
@ -337,6 +371,8 @@ class DiscountListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class DiscountDetailView(APIView): class DiscountDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id): def get(self, request, id):
try: try:
discount = Discount.objects.get(id=id) discount = Discount.objects.get(id=id)
@ -363,6 +399,8 @@ class DiscountDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class PaymentPlanListCreateView(APIView): class PaymentPlanListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None: if establishment_id is None:
@ -387,6 +425,8 @@ class PaymentPlanListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class PaymentPlanDetailView(APIView): class PaymentPlanDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id): def get(self, request, id):
try: try:
payment_plan = PaymentPlan.objects.get(id=id) payment_plan = PaymentPlan.objects.get(id=id)
@ -413,6 +453,8 @@ class PaymentPlanDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class PaymentModeListCreateView(APIView): class PaymentModeListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None: if establishment_id is None:
@ -437,6 +479,8 @@ class PaymentModeListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class PaymentModeDetailView(APIView): class PaymentModeDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id): def get(self, request, id):
try: try:
payment_mode = PaymentMode.objects.get(id=id) payment_mode = PaymentMode.objects.get(id=id)
@ -463,6 +507,8 @@ class PaymentModeDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class CompetencyListCreateView(APIView): class CompetencyListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
cycle = request.GET.get('cycle') cycle = request.GET.get('cycle')
if cycle is None: if cycle is None:
@ -486,6 +532,8 @@ class CompetencyListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class CompetencyDetailView(APIView): class CompetencyDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id): def get(self, request, id):
try: try:
competency = Competency.objects.get(id=id) competency = Competency.objects.get(id=id)
@ -517,6 +565,8 @@ class CompetencyDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class EstablishmentCompetencyListCreateView(APIView): class EstablishmentCompetencyListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
establishment_id = request.GET.get('establishment_id') establishment_id = request.GET.get('establishment_id')
cycle = request.GET.get('cycle') cycle = request.GET.get('cycle')
@ -710,6 +760,8 @@ class EstablishmentCompetencyListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class EstablishmentCompetencyDetailView(APIView): class EstablishmentCompetencyDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id): def get(self, request, id):
try: try:
ec = EstablishmentCompetency.objects.get(id=id) ec = EstablishmentCompetency.objects.get(id=id)
@ -737,3 +789,179 @@ class EstablishmentCompetencyDetailView(APIView):
return JsonResponse({'message': 'Deleted'}, safe=False) return JsonResponse({'message': 'Deleted'}, safe=False)
except EstablishmentCompetency.DoesNotExist: except EstablishmentCompetency.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
# ===================== EVALUATIONS =====================
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class EvaluationListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
establishment_id = request.GET.get('establishment_id')
school_class_id = request.GET.get('school_class')
period = request.GET.get('period')
if not establishment_id:
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
evaluations = Evaluation.objects.filter(establishment_id=establishment_id)
if school_class_id:
evaluations = evaluations.filter(school_class_id=school_class_id)
if period:
evaluations = evaluations.filter(period=period)
serializer = EvaluationSerializer(evaluations, many=True)
return JsonResponse(serializer.data, safe=False)
def post(self, request):
data = JSONParser().parse(request)
serializer = EvaluationSerializer(data=data)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, safe=False, status=status.HTTP_201_CREATED)
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class EvaluationDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id):
try:
evaluation = Evaluation.objects.get(id=id)
serializer = EvaluationSerializer(evaluation)
return JsonResponse(serializer.data, safe=False)
except Evaluation.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
def put(self, request, id):
try:
evaluation = Evaluation.objects.get(id=id)
except Evaluation.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
data = JSONParser().parse(request)
serializer = EvaluationSerializer(evaluation, data=data, partial=True)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, safe=False)
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, id):
try:
evaluation = Evaluation.objects.get(id=id)
evaluation.delete()
return JsonResponse({'message': 'Deleted'}, safe=False)
except Evaluation.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
# ===================== STUDENT EVALUATIONS =====================
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class StudentEvaluationListView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
student_id = request.GET.get('student_id')
evaluation_id = request.GET.get('evaluation_id')
period = request.GET.get('period')
school_class_id = request.GET.get('school_class_id')
student_evals = StudentEvaluation.objects.all()
if student_id:
student_evals = student_evals.filter(student_id=student_id)
if evaluation_id:
student_evals = student_evals.filter(evaluation_id=evaluation_id)
if period:
student_evals = student_evals.filter(evaluation__period=period)
if school_class_id:
student_evals = student_evals.filter(evaluation__school_class_id=school_class_id)
serializer = StudentEvaluationSerializer(student_evals, many=True)
return JsonResponse(serializer.data, safe=False)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class StudentEvaluationBulkUpdateView(APIView):
"""
Mise à jour en masse des notes des élèves pour une évaluation.
Attendu dans le body :
[
{ "student_id": 1, "evaluation_id": 1, "score": 15.5, "comment": "", "is_absent": false },
...
]
"""
permission_classes = [IsAuthenticated]
def put(self, request):
data = JSONParser().parse(request)
if not isinstance(data, list):
data = [data]
updated = []
errors = []
for item in data:
student_id = item.get('student_id')
evaluation_id = item.get('evaluation_id')
if not student_id or not evaluation_id:
errors.append({'error': 'student_id et evaluation_id sont requis', 'item': item})
continue
try:
student_eval, created = StudentEvaluation.objects.update_or_create(
student_id=student_id,
evaluation_id=evaluation_id,
defaults={
'score': item.get('score'),
'comment': item.get('comment', ''),
'is_absent': item.get('is_absent', False)
}
)
updated.append(StudentEvaluationSerializer(student_eval).data)
except Exception as e:
errors.append({'error': str(e), 'item': item})
return JsonResponse({'updated': updated, 'errors': errors}, safe=False)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class StudentEvaluationDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id):
try:
student_eval = StudentEvaluation.objects.get(id=id)
serializer = StudentEvaluationSerializer(student_eval)
return JsonResponse(serializer.data, safe=False)
except StudentEvaluation.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
def put(self, request, id):
try:
student_eval = StudentEvaluation.objects.get(id=id)
except StudentEvaluation.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
data = JSONParser().parse(request)
serializer = StudentEvaluationSerializer(student_eval, data=data, partial=True)
if serializer.is_valid():
serializer.save()
return JsonResponse(serializer.data, safe=False)
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, id):
try:
student_eval = StudentEvaluation.objects.get(id=id)
student_eval.delete()
return JsonResponse({'message': 'Deleted'}, safe=False)
except StudentEvaluation.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -452,11 +452,12 @@ class StudentByRFCreationSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False) id = serializers.IntegerField(required=False)
guardians = GuardianByDICreationSerializer(many=True, required=False) guardians = GuardianByDICreationSerializer(many=True, required=False)
associated_class_name = serializers.SerializerMethodField() associated_class_name = serializers.SerializerMethodField()
associated_class_id = serializers.SerializerMethodField()
bilans = BilanCompetenceSerializer(many=True, read_only=True) bilans = BilanCompetenceSerializer(many=True, read_only=True)
class Meta: class Meta:
model = Student 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): def __init__(self, *args, **kwargs):
super(StudentByRFCreationSerializer, self).__init__(*args, **kwargs) super(StudentByRFCreationSerializer, self).__init__(*args, **kwargs)
@ -466,6 +467,9 @@ class StudentByRFCreationSerializer(serializers.ModelSerializer):
def get_associated_class_name(self, obj): def get_associated_class_name(self, obj):
return obj.associated_class.atmosphere_name if obj.associated_class else None 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): class NotificationSerializer(serializers.ModelSerializer):
notification_type_label = serializers.ReadOnlyField() notification_type_label = serializers.ReadOnlyField()

Binary file not shown.

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

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

View File

@ -13,8 +13,11 @@ def run_command(command):
test_mode = os.getenv('test_mode', 'false').lower() == 'true' test_mode = os.getenv('test_mode', 'false').lower() == 'true'
flush_data = os.getenv('flush_data', 'false').lower() == 'true' flush_data = os.getenv('flush_data', 'false').lower() == 'true'
#flush_data=True
migrate_data = os.getenv('migrate_data', 'false').lower() == 'true' migrate_data = os.getenv('migrate_data', 'false').lower() == 'true'
migrate_data=True
watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true' watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true'
watch_mode=True
collect_static_cmd = [ collect_static_cmd = [
["python", "manage.py", "collectstatic", "--noinput"] ["python", "manage.py", "collectstatic", "--noinput"]
@ -66,10 +69,10 @@ if __name__ == "__main__":
if run_command(command) != 0: if run_command(command) != 0:
exit(1) exit(1)
if migrate_data:
for command in migrate_commands: for command in migrate_commands:
if run_command(command) != 0: if run_command(command) != 0:
exit(1) exit(1)
for command in commands: for command in commands:
if run_command(command) != 0: if run_command(command) != 0:

View File

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

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="80" fill="#10b981"/>
<text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Arial, sans-serif" font-weight="bold" font-size="220" fill="white">N3</text>
</svg>

After

Width:  |  Height:  |  Size: 289 B

48
Front-End/public/sw.js Normal file
View File

@ -0,0 +1,48 @@
const CACHE_NAME = 'n3wt-school-v1';
const STATIC_ASSETS = [
'/',
'/favicon.svg',
'/favicon.ico',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
// Ne pas intercepter les requêtes API ou d'authentification
const url = new URL(event.request.url);
if (
url.pathname.startsWith('/api/') ||
url.pathname.startsWith('/_next/') ||
event.request.method !== 'GET'
) {
return;
}
event.respondWith(
fetch(event.request)
.then((response) => {
// Mettre en cache les réponses réussies des ressources statiques
if (response.ok && url.origin === self.location.origin) {
const cloned = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, cloned));
}
return response;
})
.catch(() => caches.match(event.request))
);
});

View File

@ -0,0 +1,344 @@
'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 { EvaluationStudentView } from '@/components/Evaluation';
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 {
fetchEvaluations,
fetchStudentEvaluations,
updateStudentEvaluation,
} from '@/app/actions/schoolAction';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useClasses } from '@/context/ClassesContext';
import { Award, ArrowLeft, BookOpen } 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([]);
// Evaluation states
const [evaluations, setEvaluations] = useState([]);
const [studentEvaluationsData, setStudentEvaluationsData] = 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]);
// Load evaluations for the student
useEffect(() => {
if (student?.associated_class_id && selectedPeriod && selectedEstablishmentId) {
const periodString = getPeriodString(
selectedPeriod,
selectedEstablishmentEvaluationFrequency
);
// Load evaluations for the class
fetchEvaluations(selectedEstablishmentId, student.associated_class_id, periodString)
.then((data) => setEvaluations(data))
.catch((error) => logger.error('Erreur lors du fetch des évaluations:', error));
// Load student's evaluation scores
fetchStudentEvaluations(studentId, null, periodString, null)
.then((data) => setStudentEvaluationsData(data))
.catch((error) => logger.error('Erreur lors du fetch des notes:', error));
}
}, [student, selectedPeriod, 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)
);
};
const handleUpdateGrade = async (studentEvalId, data) => {
try {
await updateStudentEvaluation(studentEvalId, data, csrfToken);
// Reload student evaluations
const periodString = getPeriodString(selectedPeriod, selectedEstablishmentEvaluationFrequency);
const updatedData = await fetchStudentEvaluations(studentId, null, periodString, null);
setStudentEvaluationsData(updatedData);
} catch (error) {
logger.error('Erreur lors de la modification de la note:', error);
}
};
return (
<div className="p-4 md:p-8 space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<button
onClick={() => router.push('/admin/grades')}
className="p-2 rounded-md hover:bg-gray-100 border border-gray-200"
aria-label="Retour à la liste"
>
<ArrowLeft size={20} />
</button>
<h1 className="text-xl font-bold text-gray-800">Suivi pédagogique</h1>
</div>
{/* Student profile */}
{student && (
<div className="bg-stone-50 rounded-lg shadow-sm border border-gray-100 p-4 md:p-6 flex flex-col sm:flex-row items-center sm:items-start gap-4">
{student.photo ? (
<img
src={`${BASE_URL}${student.photo}`}
alt={`${student.first_name} ${student.last_name}`}
className="w-24 h-24 object-cover rounded-full border-4 border-emerald-200 shadow"
/>
) : (
<div className="w-24 h-24 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-3xl border-4 border-emerald-100">
{student.first_name?.[0]}
{student.last_name?.[0]}
</div>
)}
<div className="flex-1 text-center sm:text-left">
<div className="text-xl font-bold text-emerald-800">
{student.last_name} {student.first_name}
</div>
<div className="text-sm text-gray-600 mt-1">
Niveau :{' '}
<span className="font-medium">
{getNiveauLabel(student.level)}
</span>
{' | '}
Classe :{' '}
<span className="font-medium">
{student.associated_class_name}
</span>
</div>
</div>
{/* Period selector + Evaluate button */}
<div className="flex flex-col sm:flex-row items-end gap-3 w-full sm:w-auto">
<div className="w-full sm:w-44">
<SelectChoice
name="period"
label="Période"
placeHolder="Choisir la période"
choices={getPeriods().map((period) => {
const today = dayjs();
const end = dayjs(`${today.year()}-${period.end}`);
return {
value: period.value,
label: period.label,
disabled: today.isAfter(end),
};
})}
selected={selectedPeriod || ''}
callback={(e) => setSelectedPeriod(Number(e.target.value))}
/>
</div>
<Button
primary
onClick={() => {
const periodString = getPeriodString(
selectedPeriod,
selectedEstablishmentEvaluationFrequency
);
router.push(
`${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${studentId}&period=${periodString}`
);
}}
className="px-4 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600 w-full sm:w-auto"
icon={<Award className="w-5 h-5" />}
text="Évaluer"
title="Évaluer l'élève"
disabled={!selectedPeriod}
/>
</div>
</div>
)}
{/* Stats + Absences */}
<div className="flex flex-col gap-6">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<Attendance
absences={absences}
onToggleJustify={handleToggleJustify}
onDelete={handleDeleteAbsence}
/>
</div>
<div className="flex-1">
<GradesStatsCircle grades={grades} />
</div>
</div>
<div>
<GradesDomainBarChart studentCompetencies={studentCompetencies} />
</div>
{/* Évaluations par matière */}
<div className="bg-stone-50 rounded-lg shadow-sm border border-gray-200 p-4 md:p-6">
<div className="flex items-center gap-2 mb-4">
<BookOpen className="w-6 h-6 text-emerald-600" />
<h2 className="text-xl font-semibold text-gray-800">
Évaluations par matière
</h2>
</div>
<EvaluationStudentView
evaluations={evaluations}
studentEvaluations={studentEvaluationsData}
editable={true}
onUpdateGrade={handleUpdateGrade}
/>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -8,8 +8,8 @@ import {
fetchStudentCompetencies, fetchStudentCompetencies,
editStudentCompetencies, editStudentCompetencies,
} from '@/app/actions/subscriptionAction'; } from '@/app/actions/subscriptionAction';
import SectionHeader from '@/components/SectionHeader'; import { Award, ArrowLeft } from 'lucide-react';
import { Award } from 'lucide-react'; import logger from '@/utils/logger';
import { useCsrfToken } from '@/context/CsrfContext'; import { useCsrfToken } from '@/context/CsrfContext';
import { useNotification } from '@/context/NotificationContext'; import { useNotification } from '@/context/NotificationContext';
@ -70,7 +70,7 @@ export default function StudentCompetenciesPage() {
'success', 'success',
'Succès' 'Succès'
); );
router.back(); router.push(`/admin/grades/${studentId}`);
}) })
.catch((error) => { .catch((error) => {
showNotification( showNotification(
@ -83,11 +83,16 @@ export default function StudentCompetenciesPage() {
return ( return (
<div className="h-full flex flex-col p-4"> <div className="h-full flex flex-col p-4">
<SectionHeader <div className="flex items-center gap-3 mb-4">
icon={Award} <button
title="Bilan de compétence" onClick={() => router.push('/admin/grades')}
description="Evaluez les compétence de l'élève" className="p-2 rounded-md hover:bg-gray-100 border border-gray-200"
/> aria-label="Retour à la fiche élève"
>
<ArrowLeft size={20} />
</button>
<h1 className="text-xl font-bold text-gray-800">Bilan de compétence</h1>
</div>
<div className="flex-1 min-h-0 flex flex-col"> <div className="flex-1 min-h-0 flex flex-col">
<form <form
className="flex-1 min-h-0 flex flex-col" className="flex-1 min-h-0 flex flex-col"
@ -105,15 +110,6 @@ export default function StudentCompetenciesPage() {
/> />
</div> </div>
<div className="mt-6 flex justify-end"> <div className="mt-6 flex justify-end">
<Button
text="Retour"
type="button"
onClick={(e) => {
e.preventDefault();
router.back();
}}
className="mr-2 bg-gray-200 text-gray-700 hover:bg-gray-300"
/>
<Button text="Enregistrer" primary type="submit" /> <Button text="Enregistrer" primary type="submit" />
</div> </div>
</form> </form>

View File

@ -29,6 +29,7 @@ import {
import { disconnect } from '@/app/actions/authAction'; import { disconnect } from '@/app/actions/authAction';
import ProtectedRoute from '@/components/ProtectedRoute'; import ProtectedRoute from '@/components/ProtectedRoute';
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
import MobileTopbar from '@/components/MobileTopbar';
import { RIGHTS } from '@/utils/rights'; import { RIGHTS } from '@/utils/rights';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
@ -123,9 +124,12 @@ export default function Layout({ children }) {
return ( return (
<ProtectedRoute requiredRight={[RIGHTS.ADMIN, RIGHTS.TEACHER]}> <ProtectedRoute requiredRight={[RIGHTS.ADMIN, RIGHTS.TEACHER]}>
{/* Topbar mobile (hamburger + logo) */}
<MobileTopbar onMenuClick={toggleSidebar} />
{/* Sidebar */} {/* Sidebar */}
<div <div
className={`absolute top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${ className={`absolute top-14 md:top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
isSidebarOpen ? 'block' : 'hidden md:block' isSidebarOpen ? 'block' : 'hidden md:block'
}`} }`}
> >
@ -146,7 +150,7 @@ export default function Layout({ children }) {
)} )}
{/* Main container */} {/* Main container */}
<div className="absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-0 bottom-16 left-64 right-0"> <div className="absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-14 md:top-0 bottom-16 left-0 md:left-64 right-0">
{children} {children}
</div> </div>

View File

@ -163,7 +163,7 @@ export default function DashboardPage() {
if (isLoading) return <Loader />; if (isLoading) return <Loader />;
return ( return (
<div key={selectedEstablishmentId} className="p-6"> <div key={selectedEstablishmentId} className="p-4 md:p-6">
{/* Statistiques principales */} {/* Statistiques principales */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatCard <StatCard
@ -200,12 +200,12 @@ export default function DashboardPage() {
{/* Colonne de gauche : Graphique des inscriptions + Présence */} {/* Colonne de gauche : Graphique des inscriptions + Présence */}
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Graphique des inscriptions */} {/* Graphique des inscriptions */}
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1"> <div className="bg-stone-50 p-4 md:p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
<h2 className="text-lg font-semibold mb-6"> <h2 className="text-lg font-semibold mb-4 md:mb-6">
{t('inscriptionTrends')} {t('inscriptionTrends')}
</h2> </h2>
<div className="flex flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-6 mt-4">
<div className="flex-1 p-6"> <div className="flex-1">
<LineChart data={monthlyRegistrations} /> <LineChart data={monthlyRegistrations} />
</div> </div>
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
@ -214,13 +214,13 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{/* Présence et assiduité */} {/* Présence et assiduité */}
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1"> <div className="bg-stone-50 p-4 md:p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
<Attendance absences={absencesToday} readOnly={true} /> <Attendance absences={absencesToday} readOnly={true} />
</div> </div>
</div> </div>
{/* Colonne de droite : Événements à venir */} {/* Colonne de droite : Événements à venir */}
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1 h-full"> <div className="bg-stone-50 p-4 md:p-6 rounded-lg shadow-sm border border-gray-100 flex-1 h-full">
<h2 className="text-lg font-semibold mb-4">{t('upcomingEvents')}</h2> <h2 className="text-lg font-semibold mb-4">{t('upcomingEvents')}</h2>
{upcomingEvents.map((event, index) => ( {upcomingEvents.map((event, index) => (
<EventCard key={index} {...event} /> <EventCard key={index} {...event} />

View File

@ -12,6 +12,7 @@ import { useEstablishment } from '@/context/EstablishmentContext';
export default function Page() { export default function Page() {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [eventData, setEventData] = useState({ const [eventData, setEventData] = useState({
title: '', title: '',
description: '', description: '',
@ -56,13 +57,17 @@ export default function Page() {
modeSet={PlanningModes.PLANNING} modeSet={PlanningModes.PLANNING}
> >
<div className="flex h-full overflow-hidden"> <div className="flex h-full overflow-hidden">
<ScheduleNavigation /> <ScheduleNavigation
isOpen={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
/>
<Calendar <Calendar
onDateClick={initializeNewEvent} onDateClick={initializeNewEvent}
onEventClick={(event) => { onEventClick={(event) => {
setEventData(event); setEventData(event);
setIsModalOpen(true); setIsModalOpen(true);
}} }}
onOpenDrawer={() => setIsDrawerOpen(true)}
/> />
<EventModal <EventModal
isOpen={isModalOpen} isOpen={isModalOpen}

View File

@ -1,10 +1,10 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Users, Layers, CheckCircle, Clock, XCircle } from 'lucide-react'; import { Users, Layers, CheckCircle, Clock, XCircle, ClipboardList, Plus } from 'lucide-react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
import { fetchClasse } from '@/app/actions/schoolAction'; import { fetchClasse, fetchSpecialities, fetchEvaluations, createEvaluation, updateEvaluation, deleteEvaluation, fetchStudentEvaluations, saveStudentEvaluations, deleteStudentEvaluation } from '@/app/actions/schoolAction';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { useClasses } from '@/context/ClassesContext'; import { useClasses } from '@/context/ClassesContext';
@ -17,10 +17,12 @@ import {
editAbsences, editAbsences,
deleteAbsences, deleteAbsences,
} from '@/app/actions/subscriptionAction'; } from '@/app/actions/subscriptionAction';
import { EvaluationForm, EvaluationList, EvaluationGradeTable } from '@/components/Evaluation';
import { useCsrfToken } from '@/context/CsrfContext'; import { useCsrfToken } from '@/context/CsrfContext';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { useNotification } from '@/context/NotificationContext'; import { useNotification } from '@/context/NotificationContext';
import dayjs from 'dayjs';
export default function Page() { export default function Page() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -38,8 +40,53 @@ export default function Page() {
const [fetchedAbsences, setFetchedAbsences] = useState({}); // Absences récupérées depuis le backend const [fetchedAbsences, setFetchedAbsences] = useState({}); // Absences récupérées depuis le backend
const [formAbsences, setFormAbsences] = useState({}); // Absences modifiées localement const [formAbsences, setFormAbsences] = useState({}); // Absences modifiées localement
// Tab system
const [activeTab, setActiveTab] = useState('attendance'); // 'attendance' ou 'evaluations'
// Evaluation states
const [specialities, setSpecialities] = useState([]);
const [evaluations, setEvaluations] = useState([]);
const [studentEvaluations, setStudentEvaluations] = useState([]);
const [showEvaluationForm, setShowEvaluationForm] = useState(false);
const [selectedEvaluation, setSelectedEvaluation] = useState(null);
const [selectedPeriod, setSelectedPeriod] = useState(null);
const [editingEvaluation, setEditingEvaluation] = useState(null);
const csrfToken = useCsrfToken(); const csrfToken = useCsrfToken();
const { selectedEstablishmentId } = useEstablishment(); const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } = useEstablishment();
// Périodes selon la fréquence d'évaluation
const getPeriods = () => {
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
const nextYear = (year + 1).toString();
const schoolYear = `${year}-${nextYear}`;
if (selectedEstablishmentEvaluationFrequency === 1) {
return [
{ label: 'Trimestre 1', value: `T1_${schoolYear}` },
{ label: 'Trimestre 2', value: `T2_${schoolYear}` },
{ label: 'Trimestre 3', value: `T3_${schoolYear}` },
];
}
if (selectedEstablishmentEvaluationFrequency === 2) {
return [
{ label: 'Semestre 1', value: `S1_${schoolYear}` },
{ label: 'Semestre 2', value: `S2_${schoolYear}` },
];
}
if (selectedEstablishmentEvaluationFrequency === 3) {
return [{ label: 'Année', value: `A_${schoolYear}` }];
}
return [];
};
// Auto-select current period
useEffect(() => {
const periods = getPeriods();
if (periods.length > 0 && !selectedPeriod) {
setSelectedPeriod(periods[0].value);
}
}, [selectedEstablishmentEvaluationFrequency]);
// AbsenceMoment constants // AbsenceMoment constants
const AbsenceMoment = { const AbsenceMoment = {
@ -158,6 +205,87 @@ export default function Page() {
} }
}, [filteredStudents, fetchedAbsences]); }, [filteredStudents, fetchedAbsences]);
// Load specialities for evaluations
useEffect(() => {
if (selectedEstablishmentId) {
fetchSpecialities(selectedEstablishmentId)
.then((data) => setSpecialities(data))
.catch((error) => logger.error('Erreur lors du chargement des matières:', error));
}
}, [selectedEstablishmentId]);
// Load evaluations when tab is active and period is selected
useEffect(() => {
if (activeTab === 'evaluations' && selectedEstablishmentId && schoolClassId && selectedPeriod) {
fetchEvaluations(selectedEstablishmentId, schoolClassId, selectedPeriod)
.then((data) => setEvaluations(data))
.catch((error) => logger.error('Erreur lors du chargement des évaluations:', error));
}
}, [activeTab, selectedEstablishmentId, schoolClassId, selectedPeriod]);
// Load student evaluations when grading
useEffect(() => {
if (selectedEvaluation && schoolClassId) {
fetchStudentEvaluations(null, selectedEvaluation.id, null, schoolClassId)
.then((data) => setStudentEvaluations(data))
.catch((error) => logger.error('Erreur lors du chargement des notes:', error));
}
}, [selectedEvaluation, schoolClassId]);
// Handlers for evaluations
const handleCreateEvaluation = async (data) => {
try {
await createEvaluation(data, csrfToken);
showNotification('Évaluation créée avec succès', 'success', 'Succès');
setShowEvaluationForm(false);
// Reload evaluations
const updatedEvaluations = await fetchEvaluations(selectedEstablishmentId, schoolClassId, selectedPeriod);
setEvaluations(updatedEvaluations);
} catch (error) {
logger.error('Erreur lors de la création:', error);
showNotification('Erreur lors de la création', 'error', 'Erreur');
}
};
const handleEditEvaluation = (evaluation) => {
setEditingEvaluation(evaluation);
setShowEvaluationForm(true);
};
const handleUpdateEvaluation = async (data) => {
try {
await updateEvaluation(editingEvaluation.id, data, csrfToken);
showNotification('Évaluation modifiée avec succès', 'success', 'Succès');
setShowEvaluationForm(false);
setEditingEvaluation(null);
// Reload evaluations
const updatedEvaluations = await fetchEvaluations(selectedEstablishmentId, schoolClassId, selectedPeriod);
setEvaluations(updatedEvaluations);
} catch (error) {
logger.error('Erreur lors de la modification:', error);
showNotification('Erreur lors de la modification', 'error', 'Erreur');
}
};
const handleDeleteEvaluation = async (evaluationId) => {
await deleteEvaluation(evaluationId, csrfToken);
// Reload evaluations
const updatedEvaluations = await fetchEvaluations(selectedEstablishmentId, schoolClassId, selectedPeriod);
setEvaluations(updatedEvaluations);
};
const handleSaveGrades = async (gradesData) => {
await saveStudentEvaluations(gradesData, csrfToken);
// Reload student evaluations
const updatedStudentEvaluations = await fetchStudentEvaluations(null, selectedEvaluation.id, null, schoolClassId);
setStudentEvaluations(updatedStudentEvaluations);
};
const handleDeleteGrade = async (studentEvalId) => {
await deleteStudentEvaluation(studentEvalId, csrfToken);
setStudentEvaluations((prev) => prev.filter((se) => se.id !== studentEvalId));
};
const handleLevelClick = (label) => { const handleLevelClick = (label) => {
setSelectedLevels( setSelectedLevels(
(prev) => (prev) =>
@ -474,48 +602,83 @@ export default function Page() {
</div> </div>
</div> </div>
{/* Affichage de la date du jour */} {/* Tabs Navigation */}
<div className="flex justify-between items-center mb-4 bg-white p-4 rounded-lg shadow-md"> <div className="bg-white rounded-lg shadow-md overflow-hidden">
<div className="flex items-center space-x-3"> <div className="flex border-b border-gray-200">
<div className="flex items-center justify-center w-10 h-10 bg-emerald-100 text-emerald-600 rounded-full"> <button
<Clock className="w-6 h-6" /> onClick={() => setActiveTab('attendance')}
</div> className={`flex-1 py-3 px-4 text-center font-medium transition-colors ${
<h2 className="text-lg font-semibold text-gray-800"> activeTab === 'attendance'
Appel du jour :{' '} ? 'text-emerald-600 border-b-2 border-emerald-600 bg-emerald-50'
<span className="ml-2 text-emerald-600">{today}</span> : 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
</h2> }`}
</div> >
<div className="flex items-center"> <div className="flex items-center justify-center gap-2">
{!isEditingAttendance ? ( <Clock className="w-5 h-5" />
<Button Appel du jour
text="Faire l'appel" </div>
onClick={handleToggleAttendanceMode} </button>
primary <button
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all" onClick={() => setActiveTab('evaluations')}
/> className={`flex-1 py-3 px-4 text-center font-medium transition-colors ${
) : ( activeTab === 'evaluations'
<Button ? 'text-emerald-600 border-b-2 border-emerald-600 bg-emerald-50'
text="Valider l'appel" : 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
onClick={handleValidateAttendance} }`}
primary >
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all" <div className="flex items-center justify-center gap-2">
/> <ClipboardList className="w-5 h-5" />
)} Évaluations
</div>
</button>
</div> </div>
</div> </div>
<Table {/* Tab Content: Attendance */}
columns={[ {activeTab === 'attendance' && (
{ <>
name: 'Nom', {/* Affichage de la date du jour */}
transform: (row) => ( <div className="flex justify-between items-center mb-4 bg-white p-4 rounded-lg shadow-md">
<div className="text-center">{row.last_name}</div> <div className="flex items-center space-x-3">
), <div className="flex items-center justify-center w-10 h-10 bg-emerald-100 text-emerald-600 rounded-full">
}, <Clock className="w-6 h-6" />
{ </div>
name: 'Prénom', <h2 className="text-lg font-semibold text-gray-800">
transform: (row) => ( Appel du jour :{' '}
<div className="text-center">{row.first_name}</div> <span className="ml-2 text-emerald-600">{today}</span>
</h2>
</div>
<div className="flex items-center">
{!isEditingAttendance ? (
<Button
text="Faire l'appel"
onClick={handleToggleAttendanceMode}
primary
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all"
/>
) : (
<Button
text="Valider l'appel"
onClick={handleValidateAttendance}
primary
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all"
/>
)}
</div>
</div>
<Table
columns={[
{
name: 'Nom',
transform: (row) => (
<div className="text-center">{row.last_name}</div>
),
},
{
name: 'Prénom',
transform: (row) => (
<div className="text-center">{row.first_name}</div>
), ),
}, },
{ {
@ -728,6 +891,84 @@ export default function Page() {
]} ]}
data={filteredStudents} // Utiliser les élèves filtrés data={filteredStudents} // Utiliser les élèves filtrés
/> />
</>
)}
{/* Tab Content: Evaluations */}
{activeTab === 'evaluations' && (
<div className="space-y-4">
{/* Header avec sélecteur de période et bouton d'ajout */}
<div className="bg-white p-4 rounded-lg shadow-md">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<ClipboardList className="w-6 h-6 text-emerald-600" />
<h2 className="text-lg font-semibold text-gray-800">
Évaluations de la classe
</h2>
</div>
<div className="flex items-center gap-3 w-full sm:w-auto">
<div className="w-48">
<SelectChoice
name="period"
placeHolder="Période"
choices={getPeriods()}
selected={selectedPeriod || ''}
callback={(e) => setSelectedPeriod(e.target.value)}
/>
</div>
<Button
primary
text="Nouvelle évaluation"
icon={<Plus size={16} />}
onClick={() => setShowEvaluationForm(true)}
/>
</div>
</div>
</div>
{/* Formulaire de création/édition d'évaluation */}
{showEvaluationForm && (
<EvaluationForm
specialities={specialities}
period={selectedPeriod}
schoolClassId={parseInt(schoolClassId)}
establishmentId={selectedEstablishmentId}
initialValues={editingEvaluation}
onSubmit={editingEvaluation ? handleUpdateEvaluation : handleCreateEvaluation}
onCancel={() => {
setShowEvaluationForm(false);
setEditingEvaluation(null);
}}
/>
)}
{/* Liste des évaluations */}
<div className="bg-white p-4 rounded-lg shadow-md">
<EvaluationList
evaluations={evaluations}
onDelete={handleDeleteEvaluation}
onEdit={handleEditEvaluation}
onGradeStudents={(evaluation) => setSelectedEvaluation(evaluation)}
/>
</div>
{/* Modal de notation */}
{selectedEvaluation && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="w-full max-w-4xl max-h-[90vh] overflow-auto">
<EvaluationGradeTable
evaluation={selectedEvaluation}
students={filteredStudents}
studentEvaluations={studentEvaluations}
onSave={handleSaveGrades}
onClose={() => setSelectedEvaluation(null)}
onDeleteGrade={handleDeleteGrade}
/>
</div>
</div>
)}
</div>
)}
{/* Popup */} {/* Popup */}
<Popup <Popup

View File

@ -31,6 +31,7 @@ import { fetchRegistrationSchoolFileMasters } from '@/app/actions/registerFileGr
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { PlanningProvider, PlanningModes } from '@/context/PlanningContext'; import { PlanningProvider, PlanningModes } from '@/context/PlanningContext';
import { updatePlanning } from '@/app/actions/planningAction';
import CompetenciesList from '@/components/Structure/Competencies/CompetenciesList'; import CompetenciesList from '@/components/Structure/Competencies/CompetenciesList';
export default function Page() { export default function Page() {
@ -259,20 +260,10 @@ export default function Page() {
}); });
}; };
const handleUpdatePlanning = (url, planningId, updatedData) => { const handleUpdatePlanning = (planningId, updatedData) => {
fetch(`${url}/${planningId}`, { updatePlanning(planningId, updatedData, csrfToken)
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(updatedData),
credentials: 'include',
})
.then((response) => response.json())
.then((data) => { .then((data) => {
logger.debug('Planning mis à jour avec succès :', data); logger.debug('Planning mis à jour avec succès :', data);
//setDatas(data);
}) })
.catch((error) => { .catch((error) => {
logger.error('Erreur :', error); logger.error('Erreur :', error);

View File

@ -71,6 +71,8 @@ export default function CreateSubscriptionPage() {
const registerFormMoment = searchParams.get('school_year'); const registerFormMoment = searchParams.get('school_year');
const [students, setStudents] = useState([]); const [students, setStudents] = useState([]);
const ITEMS_PER_PAGE = 10;
const [studentsPage, setStudentsPage] = useState(1);
const [registrationDiscounts, setRegistrationDiscounts] = useState([]); const [registrationDiscounts, setRegistrationDiscounts] = useState([]);
const [tuitionDiscounts, setTuitionDiscounts] = useState([]); const [tuitionDiscounts, setTuitionDiscounts] = useState([]);
const [registrationFees, setRegistrationFees] = useState([]); const [registrationFees, setRegistrationFees] = useState([]);
@ -179,6 +181,8 @@ export default function CreateSubscriptionPage() {
formDataRef.current = formData; formDataRef.current = formData;
}, [formData]); }, [formData]);
useEffect(() => { setStudentsPage(1); }, [students]);
useEffect(() => { useEffect(() => {
if (!formData.guardianEmail) { if (!formData.guardianEmail) {
// Si l'email est vide, réinitialiser existingProfileId et existingProfileInSchool // Si l'email est vide, réinitialiser existingProfileId et existingProfileInSchool
@ -709,6 +713,9 @@ export default function CreateSubscriptionPage() {
return finalAmount.toFixed(2); 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) { if (isLoading === true) {
return <Loader />; // Affichez le composant Loader return <Loader />; // Affichez le composant Loader
} }
@ -869,7 +876,7 @@ export default function CreateSubscriptionPage() {
{!isNewResponsable && ( {!isNewResponsable && (
<div className="mt-4"> <div className="mt-4">
<Table <Table
data={students} data={pagedStudents}
columns={[ columns={[
{ {
name: 'photo', name: 'photo',
@ -927,6 +934,10 @@ export default function CreateSubscriptionPage() {
: '' : ''
} }
selectedRows={selectedStudent ? [selectedStudent.id] : []} // Assurez-vous que selectedRows est un tableau selectedRows={selectedStudent ? [selectedStudent.id] : []} // Assurez-vous que selectedRows est un tableau
itemsPerPage={ITEMS_PER_PAGE}
currentPage={studentsPage}
totalPages={studentsTotalPages}
onPageChange={setStudentsPage}
/> />
{selectedStudent && ( {selectedStudent && (

View File

@ -3,7 +3,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Sidebar from '@/components/Sidebar'; import Sidebar from '@/components/Sidebar';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import { MessageSquare, Settings, Home, Menu } from 'lucide-react'; import { MessageSquare, Settings, Home } from 'lucide-react';
import { import {
FE_PARENTS_HOME_URL, FE_PARENTS_HOME_URL,
FE_PARENTS_MESSAGERIE_URL FE_PARENTS_MESSAGERIE_URL
@ -11,6 +11,7 @@ import {
import ProtectedRoute from '@/components/ProtectedRoute'; import ProtectedRoute from '@/components/ProtectedRoute';
import { disconnect } from '@/app/actions/authAction'; import { disconnect } from '@/app/actions/authAction';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
import MobileTopbar from '@/components/MobileTopbar';
import { RIGHTS } from '@/utils/rights'; import { RIGHTS } from '@/utils/rights';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
@ -73,17 +74,12 @@ export default function Layout({ children }) {
return ( return (
<ProtectedRoute requiredRight={RIGHTS.PARENT}> <ProtectedRoute requiredRight={RIGHTS.PARENT}>
{/* Bouton hamburger pour mobile */} {/* Topbar mobile (hamburger + logo) */}
<button <MobileTopbar onMenuClick={toggleSidebar} />
onClick={toggleSidebar}
className="fixed top-4 left-4 z-40 p-2 rounded-md bg-white shadow-lg border border-gray-200 md:hidden"
>
<Menu size={20} />
</button>
{/* Sidebar */} {/* Sidebar */}
<div <div
className={`absolute top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${ className={`absolute top-14 md:top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
isSidebarOpen ? 'block' : 'hidden md:block' isSidebarOpen ? 'block' : 'hidden md:block'
}`} }`}
> >
@ -104,7 +100,7 @@ export default function Layout({ children }) {
{/* Main container */} {/* Main container */}
<div <div
className={`absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-0 bottom-16 left-0 md:left-64 right-0 ${!isMessagingPage ? 'p-4 md:p-8' : ''}`} className={`absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-14 md:top-0 bottom-16 left-0 md:left-64 right-0 ${!isMessagingPage ? 'p-4 md:p-8' : ''}`}
> >
{children} {children}
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,186 +9,196 @@ import {
BE_SCHOOL_PAYMENT_MODES_URL, BE_SCHOOL_PAYMENT_MODES_URL,
BE_SCHOOL_ESTABLISHMENT_URL, BE_SCHOOL_ESTABLISHMENT_URL,
BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL,
BE_SCHOOL_EVALUATIONS_URL,
BE_SCHOOL_STUDENT_EVALUATIONS_URL,
} from '@/utils/Url'; } from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers'; import { fetchWithAuth } from '@/utils/fetchWithAuth';
export const deleteEstablishmentCompetencies = (ids, csrfToken) => { export const deleteEstablishmentCompetencies = (ids, csrfToken) => {
return fetch(BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, { return fetchWithAuth(BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, {
method: 'DELETE', method: 'DELETE',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({ ids }), body: JSON.stringify({ ids }),
credentials: 'include', });
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const createEstablishmentCompetencies = (newData, csrfToken) => { export const createEstablishmentCompetencies = (newData, csrfToken) => {
return fetch(BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, { return fetchWithAuth(BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL, {
method: 'POST', method: 'POST',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(newData), body: JSON.stringify(newData),
credentials: 'include', });
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchEstablishmentCompetencies = (establishment, cycle = 1) => { export const fetchEstablishmentCompetencies = (establishment, cycle = 1) => {
return fetch( return fetchWithAuth(
`${BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL}?establishment_id=${establishment}&cycle=${cycle}` `${BE_SCHOOL_ESTABLISHMENT_COMPETENCIES_URL}?establishment_id=${establishment}&cycle=${cycle}`
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchSpecialities = (establishment) => {
return fetch(
`${BE_SCHOOL_SPECIALITIES_URL}?establishment_id=${establishment}`
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchTeachers = (establishment) => {
return fetch(`${BE_SCHOOL_TEACHERS_URL}?establishment_id=${establishment}`)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchClasses = (establishment) => {
return fetch(
`${BE_SCHOOL_SCHOOLCLASSES_URL}?establishment_id=${establishment}`
)
.then(requestResponseHandler)
.catch(errorHandler);
};
export const fetchClasse = (id) => {
return fetch(`${BE_SCHOOL_SCHOOLCLASSES_URL}/${id}`).then(
requestResponseHandler
); );
}; };
export const fetchSpecialities = (establishment) => {
return fetchWithAuth(
`${BE_SCHOOL_SPECIALITIES_URL}?establishment_id=${establishment}`
);
};
export const fetchTeachers = (establishment) => {
return fetchWithAuth(`${BE_SCHOOL_TEACHERS_URL}?establishment_id=${establishment}`);
};
export const fetchClasses = (establishment) => {
return fetchWithAuth(
`${BE_SCHOOL_SCHOOLCLASSES_URL}?establishment_id=${establishment}`
);
};
export const fetchClasse = (id) => {
return fetchWithAuth(`${BE_SCHOOL_SCHOOLCLASSES_URL}/${id}`);
};
export const fetchSchedules = () => { export const fetchSchedules = () => {
return fetch(`${BE_SCHOOL_PLANNINGS_URL}`) return fetchWithAuth(`${BE_SCHOOL_PLANNINGS_URL}`);
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchRegistrationDiscounts = (establishment) => { export const fetchRegistrationDiscounts = (establishment) => {
return fetch( return fetchWithAuth(
`${BE_SCHOOL_DISCOUNTS_URL}?filter=registration&establishment_id=${establishment}` `${BE_SCHOOL_DISCOUNTS_URL}?filter=registration&establishment_id=${establishment}`
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchTuitionDiscounts = (establishment) => { export const fetchTuitionDiscounts = (establishment) => {
return fetch( return fetchWithAuth(
`${BE_SCHOOL_DISCOUNTS_URL}?filter=tuition&establishment_id=${establishment}` `${BE_SCHOOL_DISCOUNTS_URL}?filter=tuition&establishment_id=${establishment}`
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchRegistrationFees = (establishment) => { export const fetchRegistrationFees = (establishment) => {
return fetch( return fetchWithAuth(
`${BE_SCHOOL_FEES_URL}?filter=registration&establishment_id=${establishment}` `${BE_SCHOOL_FEES_URL}?filter=registration&establishment_id=${establishment}`
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchTuitionFees = (establishment) => { export const fetchTuitionFees = (establishment) => {
return fetch( return fetchWithAuth(
`${BE_SCHOOL_FEES_URL}?filter=tuition&establishment_id=${establishment}` `${BE_SCHOOL_FEES_URL}?filter=tuition&establishment_id=${establishment}`
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchRegistrationPaymentPlans = (establishment) => { export const fetchRegistrationPaymentPlans = (establishment) => {
return fetch( return fetchWithAuth(
`${BE_SCHOOL_PAYMENT_PLANS_URL}?filter=registration&establishment_id=${establishment}` `${BE_SCHOOL_PAYMENT_PLANS_URL}?filter=registration&establishment_id=${establishment}`
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchTuitionPaymentPlans = (establishment) => { export const fetchTuitionPaymentPlans = (establishment) => {
return fetch( return fetchWithAuth(
`${BE_SCHOOL_PAYMENT_PLANS_URL}?filter=tuition&establishment_id=${establishment}` `${BE_SCHOOL_PAYMENT_PLANS_URL}?filter=tuition&establishment_id=${establishment}`
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchRegistrationPaymentModes = (establishment) => { export const fetchRegistrationPaymentModes = (establishment) => {
return fetch( return fetchWithAuth(
`${BE_SCHOOL_PAYMENT_MODES_URL}?filter=registration&establishment_id=${establishment}` `${BE_SCHOOL_PAYMENT_MODES_URL}?filter=registration&establishment_id=${establishment}`
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchTuitionPaymentModes = (establishment) => { export const fetchTuitionPaymentModes = (establishment) => {
return fetch( return fetchWithAuth(
`${BE_SCHOOL_PAYMENT_MODES_URL}?filter=tuition&establishment_id=${establishment}` `${BE_SCHOOL_PAYMENT_MODES_URL}?filter=tuition&establishment_id=${establishment}`
) );
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const fetchEstablishment = (establishment) => { export const fetchEstablishment = (establishment) => {
return fetch(`${BE_SCHOOL_ESTABLISHMENT_URL}/${establishment}`) return fetchWithAuth(`${BE_SCHOOL_ESTABLISHMENT_URL}/${establishment}`);
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const createDatas = (url, newData, csrfToken) => { export const createDatas = (url, newData, csrfToken) => {
return fetch(url, { return fetchWithAuth(url, {
method: 'POST', method: 'POST',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(newData), body: JSON.stringify(newData),
credentials: 'include', });
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const updateDatas = (url, id, updatedData, csrfToken) => { export const updateDatas = (url, id, updatedData, csrfToken) => {
return fetch(`${url}/${id}`, { return fetchWithAuth(`${url}/${id}`, {
method: 'PUT', method: 'PUT',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify(updatedData), body: JSON.stringify(updatedData),
credentials: 'include', });
})
.then(requestResponseHandler)
.catch(errorHandler);
}; };
export const removeDatas = (url, id, csrfToken) => { export const removeDatas = (url, id, csrfToken) => {
return fetch(`${url}/${id}`, { return fetchWithAuth(`${url}/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: { 'X-CSRFToken': csrfToken },
'Content-Type': 'application/json', });
'X-CSRFToken': csrfToken, };
},
credentials: 'include', // ===================== EVALUATIONS =====================
})
.then(requestResponseHandler) export const fetchEvaluations = (establishmentId, schoolClassId = null, period = null) => {
.catch(errorHandler); let url = `${BE_SCHOOL_EVALUATIONS_URL}?establishment_id=${establishmentId}`;
if (schoolClassId) url += `&school_class=${schoolClassId}`;
if (period) url += `&period=${period}`;
return fetchWithAuth(url);
};
export const createEvaluation = (data, csrfToken) => {
return fetchWithAuth(BE_SCHOOL_EVALUATIONS_URL, {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken },
body: JSON.stringify(data),
});
};
export const updateEvaluation = (id, data, csrfToken) => {
return fetchWithAuth(`${BE_SCHOOL_EVALUATIONS_URL}/${id}`, {
method: 'PUT',
headers: { 'X-CSRFToken': csrfToken },
body: JSON.stringify(data),
});
};
export const deleteEvaluation = (id, csrfToken) => {
return fetchWithAuth(`${BE_SCHOOL_EVALUATIONS_URL}/${id}`, {
method: 'DELETE',
headers: { 'X-CSRFToken': csrfToken },
});
};
// ===================== STUDENT EVALUATIONS =====================
export const fetchStudentEvaluations = (studentId = null, evaluationId = null, period = null, schoolClassId = null) => {
let url = `${BE_SCHOOL_STUDENT_EVALUATIONS_URL}?`;
const params = [];
if (studentId) params.push(`student_id=${studentId}`);
if (evaluationId) params.push(`evaluation_id=${evaluationId}`);
if (period) params.push(`period=${period}`);
if (schoolClassId) params.push(`school_class_id=${schoolClassId}`);
url += params.join('&');
return fetchWithAuth(url);
};
export const saveStudentEvaluations = (data, csrfToken) => {
return fetchWithAuth(`${BE_SCHOOL_STUDENT_EVALUATIONS_URL}/bulk`, {
method: 'PUT',
headers: { 'X-CSRFToken': csrfToken },
body: JSON.stringify(data),
});
};
export const updateStudentEvaluation = (id, data, csrfToken) => {
return fetchWithAuth(`${BE_SCHOOL_STUDENT_EVALUATIONS_URL}/${id}`, {
method: 'PUT',
headers: { 'X-CSRFToken': csrfToken },
body: JSON.stringify(data),
});
};
export const deleteStudentEvaluation = (id, csrfToken) => {
return fetchWithAuth(`${BE_SCHOOL_STUDENT_EVALUATIONS_URL}/${id}`, {
method: 'DELETE',
headers: { 'X-CSRFToken': csrfToken },
});
}; };

View File

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

View File

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

View File

@ -1,12 +1,19 @@
import React from 'react'; import React from 'react';
import { getMessages } from 'next-intl/server'; import { getMessages } from 'next-intl/server';
import Providers from '@/components/Providers'; import Providers from '@/components/Providers';
import ServiceWorkerRegister from '@/components/ServiceWorkerRegister';
import '@/css/tailwind.css'; import '@/css/tailwind.css';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
export const metadata = { export const metadata = {
title: 'N3WT-SCHOOL', title: 'N3WT-SCHOOL',
description: "Gestion de l'école", description: "Gestion de l'école",
manifest: '/manifest.webmanifest',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'N3WT School',
},
icons: { icons: {
icon: [ icon: [
{ {
@ -14,10 +21,11 @@ export const metadata = {
type: 'image/svg+xml', type: 'image/svg+xml',
}, },
{ {
url: '/favicon.ico', // Fallback pour les anciens navigateurs url: '/favicon.ico',
sizes: 'any', sizes: 'any',
}, },
], ],
apple: '/icons/icon.svg',
}, },
}; };
@ -32,6 +40,7 @@ export default async function RootLayout({ children, params }) {
<Providers messages={messages} locale={locale} session={params.session}> <Providers messages={messages} locale={locale} session={params.session}>
{children} {children}
</Providers> </Providers>
<ServiceWorkerRegister />
</body> </body>
</html> </html>
); );

View File

@ -0,0 +1,26 @@
export default function manifest() {
return {
name: 'N3WT School',
short_name: 'N3WT School',
description: "Gestion de l'école",
start_url: '/',
display: 'standalone',
background_color: '#f0fdf4',
theme_color: '#10b981',
orientation: 'portrait',
icons: [
{
src: '/icons/icon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any',
},
{
src: '/icons/icon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'maskable',
},
],
};
}

View File

@ -4,6 +4,7 @@ import WeekView from '@/components/Calendar/WeekView';
import MonthView from '@/components/Calendar/MonthView'; import MonthView from '@/components/Calendar/MonthView';
import YearView from '@/components/Calendar/YearView'; import YearView from '@/components/Calendar/YearView';
import PlanningView from '@/components/Calendar/PlanningView'; import PlanningView from '@/components/Calendar/PlanningView';
import DayView from '@/components/Calendar/DayView';
import ToggleView from '@/components/ToggleView'; import ToggleView from '@/components/ToggleView';
import { ChevronLeft, ChevronRight, Plus, ChevronDown } from 'lucide-react'; import { ChevronLeft, ChevronRight, Plus, ChevronDown } from 'lucide-react';
import { import {
@ -11,9 +12,11 @@ import {
addWeeks, addWeeks,
addMonths, addMonths,
addYears, addYears,
addDays,
subWeeks, subWeeks,
subMonths, subMonths,
subYears, subYears,
subDays,
getWeek, getWeek,
setMonth, setMonth,
setYear, setYear,
@ -22,7 +25,7 @@ import { fr } from 'date-fns/locale';
import { AnimatePresence, motion } from 'framer-motion'; // Ajouter cet import import { AnimatePresence, motion } from 'framer-motion'; // Ajouter cet import
import logger from '@/utils/logger'; import logger from '@/utils/logger';
const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' }) => { const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName = '', onOpenDrawer = () => {} }) => {
const { const {
currentDate, currentDate,
setCurrentDate, setCurrentDate,
@ -35,6 +38,14 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
} = usePlanning(); } = usePlanning();
const [visibleEvents, setVisibleEvents] = useState([]); const [visibleEvents, setVisibleEvents] = useState([]);
const [showDatePicker, setShowDatePicker] = useState(false); 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 // Ajouter ces fonctions pour la gestion des mois et années
const months = Array.from({ length: 12 }, (_, i) => ({ const months = Array.from({ length: 12 }, (_, i) => ({
@ -68,7 +79,12 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
const navigateDate = (direction) => { const navigateDate = (direction) => {
const getNewDate = () => { const getNewDate = () => {
switch (viewType) { const effectiveView = isMobile ? 'day' : viewType;
switch (effectiveView) {
case 'day':
return direction === 'next'
? addDays(currentDate, 1)
: subDays(currentDate, 1);
case 'week': case 'week':
return direction === 'next' return direction === 'next'
? addWeeks(currentDate, 1) ? addWeeks(currentDate, 1)
@ -91,116 +107,114 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
return ( return (
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<div className="flex items-center justify-between p-4 bg-white sticky top-0 z-30 border-b shadow-sm h-[64px]"> {/* Header uniquement sur desktop */}
{/* Navigation à gauche */} <div className="hidden md:flex items-center justify-between p-4 bg-white sticky top-0 z-30 border-b shadow-sm h-[64px]">
{planningMode === PlanningModes.PLANNING && ( <>
<div className="flex items-center gap-4"> {planningMode === PlanningModes.PLANNING && (
<button <div className="flex items-center gap-4">
onClick={() => setCurrentDate(new Date())} <button
className="px-3 py-1.5 text-sm font-medium text-gray-700 hover:text-gray-900 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors" onClick={() => setCurrentDate(new Date())}
> className="px-3 py-1.5 text-sm font-medium text-gray-700 hover:text-gray-900 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
Aujourd&apos;hui >
</button> Aujourd&apos;hui
<button </button>
onClick={() => navigateDate('prev')} <button onClick={() => navigateDate('prev')} className="p-2 hover:bg-gray-100 rounded-full">
className="p-2 hover:bg-gray-100 rounded-full" <ChevronLeft className="w-5 h-5" />
> </button>
<ChevronLeft className="w-5 h-5" /> <div className="relative">
</button> <button
<div className="relative"> onClick={() => setShowDatePicker(!showDatePicker)}
<button className="flex items-center gap-1 px-2 py-1 hover:bg-gray-100 rounded-md"
onClick={() => setShowDatePicker(!showDatePicker)} >
className="flex items-center gap-1 px-2 py-1 hover:bg-gray-100 rounded-md" <h2 className="text-xl font-semibold">
> {format(currentDate, viewType === 'year' ? 'yyyy' : 'MMMM yyyy', { locale: fr })}
<h2 className="text-xl font-semibold"> </h2>
{format( <ChevronDown className="w-4 h-4" />
currentDate, </button>
viewType === 'year' ? 'yyyy' : 'MMMM yyyy', {showDatePicker && (
{ locale: fr } <div className="absolute top-full left-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 w-64">
)} {viewType !== 'year' && (
</h2> <div className="p-2 border-b">
<ChevronDown className="w-4 h-4" /> <div className="grid grid-cols-3 gap-1">
</button> {months.map((month) => (
{showDatePicker && ( <button key={month.value} onClick={() => handleMonthSelect(month.value)} className="p-2 text-sm hover:bg-gray-100 rounded-md">
<div className="absolute top-full left-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 w-64"> {month.label}
{viewType !== 'year' && ( </button>
<div className="p-2 border-b"> ))}
<div className="grid grid-cols-3 gap-1"> </div>
{months.map((month) => ( </div>
<button )}
key={month.value} <div className="p-2">
onClick={() => handleMonthSelect(month.value)} <div className="grid grid-cols-3 gap-1">
className="p-2 text-sm hover:bg-gray-100 rounded-md" {years.map((year) => (
> <button key={year.value} onClick={() => handleYearSelect(year.value)} className="p-2 text-sm hover:bg-gray-100 rounded-md">
{month.label} {year.label}
</button> </button>
))} ))}
</div>
</div> </div>
</div> </div>
)} )}
<div className="p-2"> </div>
<div className="grid grid-cols-3 gap-1"> <button onClick={() => navigateDate('next')} className="p-2 hover:bg-gray-100 rounded-full">
{years.map((year) => ( <ChevronRight className="w-5 h-5" />
<button </button>
key={year.value} </div>
onClick={() => handleYearSelect(year.value)} )}
className="p-2 text-sm hover:bg-gray-100 rounded-md"
> <div className="flex-1 flex justify-center">
{year.label} {((planningMode === PlanningModes.PLANNING || planningMode === PlanningModes.CLASS_SCHEDULE) && viewType === 'week' && !parentView) && (
</button> <div className="flex items-center gap-1 text-sm font-medium text-gray-600">
))} <span>Semaine</span>
</div> <span className="px-2 py-1 bg-gray-100 rounded-md">
</div> {getWeek(currentDate, { weekStartsOn: 1 })}
</span>
</div> </div>
)} )}
{parentView && (
<span className="px-2 py-1 bg-gray-100 rounded-md text-base font-semibold">
{planningClassName}
</span>
)}
</div> </div>
<button
onClick={() => navigateDate('next')}
className="p-2 hover:bg-gray-100 rounded-full"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
)}
{/* Centre : numéro de semaine ou classe/niveau */} <div className="flex items-center gap-4">
<div className="flex-1 flex justify-center"> {planningMode === PlanningModes.PLANNING && (
{((planningMode === PlanningModes.PLANNING || planningMode === PlanningModes.CLASS_SCHEDULE) && viewType === 'week' && !parentView) && ( <ToggleView viewType={viewType} setViewType={setViewType} />
<div className="flex items-center gap-1 text-sm font-medium text-gray-600"> )}
<span>Semaine</span> {(planningMode === PlanningModes.PLANNING || (planningMode === PlanningModes.CLASS_SCHEDULE && !parentView)) && (
<span className="px-2 py-1 bg-gray-100 rounded-md"> <button
{getWeek(currentDate, { weekStartsOn: 1 })} onClick={onDateClick}
</span> className="w-10 h-10 flex items-center justify-center bg-emerald-600 text-white rounded-full hover:bg-emerald-700 shadow-md transition-colors"
>
<Plus className="w-5 h-5" />
</button>
)}
</div> </div>
)} </>
{parentView && (
<span className="px-2 py-1 bg-gray-100 rounded-md text-base font-semibold">
{/* À adapter selon les props disponibles */}
{planningClassName}
</span>
)}
</div>
{/* Contrôles à droite */}
<div className="flex items-center gap-4">
{planningMode === PlanningModes.PLANNING && (
<ToggleView viewType={viewType} setViewType={setViewType} />
)}
{(planningMode === PlanningModes.PLANNING || (planningMode === PlanningModes.CLASS_SCHEDULE && !parentView)) && (
<button
onClick={onDateClick}
className="w-10 h-10 flex items-center justify-center bg-emerald-600 text-white rounded-full hover:bg-emerald-700 shadow-md transition-colors"
>
<Plus className="w-5 h-5" />
</button>
)}
</div>
</div> </div>
{/* Contenu scrollable */} {/* Contenu scrollable */}
<div className="flex-1 max-h-[calc(100vh-192px)] overflow-hidden"> <div className="flex-1 max-h-[calc(100vh-192px)] overflow-hidden">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{viewType === 'week' && ( {isMobile && (
<motion.div
key="day"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
className="h-full flex flex-col"
>
<DayView
onDateClick={onDateClick}
onEventClick={onEventClick}
events={visibleEvents}
onOpenDrawer={onOpenDrawer}
/>
</motion.div>
)}
{!isMobile && viewType === 'week' && (
<motion.div <motion.div
key="week" key="week"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@ -216,7 +230,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
/> />
</motion.div> </motion.div>
)} )}
{viewType === 'month' && ( {!isMobile && viewType === 'month' && (
<motion.div <motion.div
key="month" key="month"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@ -231,7 +245,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
/> />
</motion.div> </motion.div>
)} )}
{viewType === 'year' && ( {!isMobile && viewType === 'year' && (
<motion.div <motion.div
key="year" key="year"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@ -242,7 +256,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
<YearView onDateClick={onDateClick} events={visibleEvents} /> <YearView onDateClick={onDateClick} events={visibleEvents} />
</motion.div> </motion.div>
)} )}
{viewType === 'planning' && ( {!isMobile && viewType === 'planning' && (
<motion.div <motion.div
key="planning" key="planning"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}

View File

@ -0,0 +1,230 @@
import React, { useEffect, useState, useRef } from 'react';
import { usePlanning } from '@/context/PlanningContext';
import {
format,
startOfWeek,
addDays,
subDays,
isSameDay,
isToday,
} from 'date-fns';
import { fr } from 'date-fns/locale';
import { getWeekEvents } from '@/utils/events';
import { CalendarDays, ChevronLeft, ChevronRight, Plus } from 'lucide-react';
const DayView = ({ onDateClick, onEventClick, events, onOpenDrawer }) => {
const { currentDate, setCurrentDate, parentView } = usePlanning();
const [currentTime, setCurrentTime] = useState(new Date());
const scrollRef = useRef(null);
const timeSlots = Array.from({ length: 24 }, (_, i) => i);
const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 });
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
const isCurrentDay = isSameDay(currentDate, new Date());
const dayEvents = getWeekEvents(currentDate, events) || [];
useEffect(() => {
const interval = setInterval(() => setCurrentTime(new Date()), 60000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (scrollRef.current && isCurrentDay) {
const currentHour = new Date().getHours();
setTimeout(() => {
scrollRef.current.scrollTop = currentHour * 80 - 200;
}, 0);
}
}, [currentDate, isCurrentDay]);
const getCurrentTimePosition = () => {
const hours = currentTime.getHours();
const minutes = currentTime.getMinutes();
return `${(hours + minutes / 60) * 5}rem`;
};
const calculateEventStyle = (event, allDayEvents) => {
const start = new Date(event.start);
const end = new Date(event.end);
const startMinutes = (start.getMinutes() / 60) * 5;
const duration = ((end - start) / (1000 * 60 * 60)) * 5;
const overlapping = allDayEvents.filter((other) => {
if (other.id === event.id) return false;
const oStart = new Date(other.start);
const oEnd = new Date(other.end);
return !(oEnd <= start || oStart >= end);
});
const eventIndex = overlapping.findIndex((e) => e.id > event.id) + 1;
const total = overlapping.length + 1;
return {
height: `${Math.max(duration, 1.5)}rem`,
position: 'absolute',
width: `calc((100% / ${total}) - 4px)`,
left: `calc((100% / ${total}) * ${eventIndex})`,
backgroundColor: `${event.color}15`,
borderLeft: `3px solid ${event.color}`,
borderRadius: '0.25rem',
zIndex: 1,
transform: `translateY(${startMinutes}rem)`,
};
};
return (
<div className="flex flex-col h-full">
{/* Barre de navigation (remplace le header Calendar sur mobile) */}
<div className="flex items-center justify-between px-3 py-2 bg-white border-b shrink-0">
<button
onClick={onOpenDrawer}
className="p-2 hover:bg-gray-100 rounded-full"
aria-label="Ouvrir les plannings"
>
<CalendarDays className="w-5 h-5 text-gray-600" />
</button>
<div className="flex items-center gap-1">
<button
onClick={() => setCurrentDate(subDays(currentDate, 1))}
className="p-2 hover:bg-gray-100 rounded-full"
>
<ChevronLeft className="w-5 h-5" />
</button>
<label className="relative cursor-pointer">
<span className="px-2 py-1 text-sm font-semibold text-gray-800 hover:bg-gray-100 rounded-md capitalize">
{format(currentDate, 'EEE d MMM', { locale: fr })}
</span>
<input
type="date"
className="absolute inset-0 opacity-0 w-full h-full cursor-pointer"
value={format(currentDate, 'yyyy-MM-dd')}
onChange={(e) => {
if (e.target.value) setCurrentDate(new Date(e.target.value + 'T12:00:00'));
}}
/>
</label>
<button
onClick={() => setCurrentDate(addDays(currentDate, 1))}
className="p-2 hover:bg-gray-100 rounded-full"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
<button
onClick={() => onDateClick?.(currentDate)}
className="w-9 h-9 flex items-center justify-center bg-emerald-600 text-white rounded-full hover:bg-emerald-700 shadow-md transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Bandeau jours de la semaine */}
<div className="flex gap-1 px-2 py-2 bg-white border-b overflow-x-auto shrink-0">
{weekDays.map((day) => (
<button
key={day.toISOString()}
onClick={() => setCurrentDate(day)}
className={`flex flex-col items-center min-w-[2.75rem] px-1 py-1.5 rounded-xl transition-colors ${
isSameDay(day, currentDate)
? 'bg-emerald-600 text-white'
: isToday(day)
? 'border border-emerald-400 text-emerald-600'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<span className="text-xs font-medium uppercase">
{format(day, 'EEE', { locale: fr })}
</span>
<span className="text-sm font-bold">{format(day, 'd')}</span>
</button>
))}
</div>
{/* Grille horaire */}
<div ref={scrollRef} className="flex-1 overflow-y-auto relative">
{isCurrentDay && (
<div
className="absolute left-0 right-0 z-10 border-emerald-500 border pointer-events-none"
style={{ top: getCurrentTimePosition() }}
>
<div className="absolute -left-2 -top-2 w-2 h-2 rounded-full bg-emerald-500" />
</div>
)}
<div
className="grid w-full bg-gray-100 gap-[1px]"
style={{ gridTemplateColumns: '2.5rem 1fr' }}
>
{timeSlots.map((hour) => (
<React.Fragment key={hour}>
<div className="h-20 p-1 text-right text-sm text-gray-500 bg-gray-100 font-medium">
{`${hour.toString().padStart(2, '0')}:00`}
</div>
<div
className={`h-20 relative border-b border-gray-100 ${
isCurrentDay ? 'bg-emerald-50/30' : 'bg-white'
}`}
onClick={
parentView
? undefined
: () => {
const date = new Date(currentDate);
date.setHours(hour);
date.setMinutes(0);
onDateClick(date);
}
}
>
{dayEvents
.filter((e) => new Date(e.start).getHours() === hour)
.map((event) => (
<div
key={event.id}
className="rounded-sm overflow-hidden cursor-pointer hover:shadow-lg"
style={calculateEventStyle(event, dayEvents)}
onClick={
parentView
? undefined
: (e) => {
e.stopPropagation();
onEventClick(event);
}
}
>
<div className="p-1">
<div
className="font-semibold text-xs truncate"
style={{ color: event.color }}
>
{event.title}
</div>
<div
className="text-xs"
style={{ color: event.color, opacity: 0.75 }}
>
{format(new Date(event.start), 'HH:mm')} {' '}
{format(new Date(event.end), 'HH:mm')}
</div>
{event.location && (
<div
className="text-xs truncate"
style={{ color: event.color, opacity: 0.75 }}
>
{event.location}
</div>
)}
</div>
</div>
))}
</div>
</React.Fragment>
))}
</div>
</div>
</div>
);
};
export default DayView;

View File

@ -253,7 +253,7 @@ export default function EventModal({
)} )}
{/* Dates */} {/* Dates */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Début Début

View File

@ -75,22 +75,35 @@ const MonthView = ({ onDateClick, onEventClick }) => {
); );
}; };
const dayLabels = [
{ short: 'L', long: 'Lun' },
{ short: 'M', long: 'Mar' },
{ short: 'M', long: 'Mer' },
{ short: 'J', long: 'Jeu' },
{ short: 'V', long: 'Ven' },
{ short: 'S', long: 'Sam' },
{ short: 'D', long: 'Dim' },
];
return ( return (
<div className="h-full flex flex-col border border-gray-200 rounded-lg bg-white"> <div className="h-full flex flex-col border border-gray-200 rounded-lg bg-white overflow-x-auto">
{/* En-tête des jours de la semaine */} <div className="min-w-[280px]">
<div className="grid grid-cols-7 border-b"> {/* En-tête des jours de la semaine */}
{['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map((day) => ( <div className="grid grid-cols-7 border-b">
<div {dayLabels.map((day, i) => (
key={day} <div
className="p-2 text-center text-sm font-medium text-gray-500" key={i}
> className="p-1 sm:p-2 text-center text-xs sm:text-sm font-medium text-gray-500"
{day} >
</div> <span className="sm:hidden">{day.short}</span>
))} <span className="hidden sm:inline">{day.long}</span>
</div> </div>
{/* Grille des jours */} ))}
<div className="flex-1 grid grid-cols-7 grid-rows-[repeat(6,1fr)]"> </div>
{days.map((day) => renderDay(day))} {/* Grille des jours */}
<div className="grid grid-cols-7 grid-rows-[repeat(6,1fr)]">
{days.map((day) => renderDay(day))}
</div>
</div> </div>
</div> </div>
); );

View File

@ -32,7 +32,7 @@ const PlanningView = ({ events, onEventClick }) => {
return ( return (
<div className="bg-white h-full overflow-auto"> <div className="bg-white h-full overflow-auto">
<table className="w-full border-collapse"> <table className="min-w-full border-collapse">
<thead className="bg-gray-50 sticky top-0 z-10"> <thead className="bg-gray-50 sticky top-0 z-10">
<tr> <tr>
<th className="py-3 px-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-b"> <th className="py-3 px-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-b">

View File

@ -3,7 +3,7 @@ import { usePlanning, PlanningModes } from '@/context/PlanningContext';
import { Plus, Edit2, Eye, EyeOff, Check, X } from 'lucide-react'; import { Plus, Edit2, Eye, EyeOff, Check, X } from 'lucide-react';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
export default function ScheduleNavigation({ classes, modeSet = 'event' }) { export default function ScheduleNavigation({ classes, modeSet = 'event', isOpen = false, onClose = () => {} }) {
const { const {
schedules, schedules,
selectedSchedule, selectedSchedule,
@ -62,22 +62,10 @@ export default function ScheduleNavigation({ classes, modeSet = 'event' }) {
} }
}; };
return ( const title = planningMode === PlanningModes.CLASS_SCHEDULE ? 'Emplois du temps' : 'Plannings';
<nav className="w-64 border-r p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold">
{planningMode === PlanningModes.CLASS_SCHEDULE
? 'Emplois du temps'
: 'Plannings'}
</h2>
<button
onClick={() => setIsAddingNew(true)}
className="p-1 hover:bg-gray-100 rounded"
>
<Plus className="w-4 h-4" />
</button>
</div>
const listContent = (
<>
{isAddingNew && ( {isAddingNew && (
<div className="mb-4 p-2 border rounded"> <div className="mb-4 p-2 border rounded">
<input <input
@ -251,6 +239,50 @@ export default function ScheduleNavigation({ classes, modeSet = 'event' }) {
</li> </li>
))} ))}
</ul> </ul>
</nav> </>
);
return (
<>
{/* Desktop : sidebar fixe */}
<nav className="hidden md:flex flex-col w-64 border-r p-4 h-full overflow-y-auto shrink-0">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold">{title}</h2>
<button onClick={() => setIsAddingNew(true)} className="p-1 hover:bg-gray-100 rounded">
<Plus className="w-4 h-4" />
</button>
</div>
{listContent}
</nav>
{/* Mobile : drawer en overlay */}
<div
className={`md:hidden fixed inset-0 z-50 transition-opacity duration-200 ${
isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
}`}
>
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div
className={`absolute left-0 top-0 bottom-0 w-72 bg-white shadow-xl flex flex-col transition-transform duration-200 ${
isOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex items-center justify-between p-4 border-b shrink-0">
<h2 className="font-semibold">{title}</h2>
<div className="flex items-center gap-1">
<button onClick={() => setIsAddingNew(true)} className="p-1 hover:bg-gray-100 rounded">
<Plus className="w-4 h-4" />
</button>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded">
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
{listContent}
</div>
</div>
</div>
</>
); );
} }

View File

@ -36,7 +36,7 @@ const YearView = ({ onDateClick }) => {
}; };
return ( return (
<div className="grid grid-cols-4 gap-4 p-4"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 p-4">
{months.map((month) => ( {months.map((month) => (
<MonthCard <MonthCard
key={month.getTime()} key={month.getTime()}

View File

@ -15,27 +15,28 @@ export default function LineChart({ data }) {
.filter((idx) => idx !== -1); .filter((idx) => idx !== -1);
return ( return (
<div <div className="w-full flex space-x-4">
className="w-full flex items-end space-x-4"
style={{ height: chartHeight }}
>
{data.map((point, idx) => { {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); const isMax = maxIndices.includes(idx);
return ( return (
<div key={idx} className="flex flex-col items-center flex-1"> <div key={idx} className="flex flex-col items-center flex-1">
{/* Valeur au-dessus de la barre */} {/* Valeur au-dessus de la barre — hors de la zone hauteur fixe */}
<span className="text-xs mb-1 text-gray-700 font-semibold"> <span className="text-xs mb-1 text-gray-700 font-semibold">
{point.value} {point.value}
</span> </span>
{/* Zone barres à hauteur fixe, alignées en bas */}
<div <div
className={`${isMax ? 'bg-emerald-400' : 'bg-blue-400'} rounded-t w-4`} className="w-full flex items-end justify-center"
style={{ style={{ height: chartHeight }}
height: `${barHeight}px`, >
transition: 'height 0.3s', <div
}} className={`${isMax ? 'bg-emerald-400' : 'bg-blue-400'} rounded-t w-4`}
title={`${point.month}: ${point.value}`} style={{ height: `${barHeight}px`, transition: 'height 0.3s' }}
/> title={`${point.month}: ${point.value}`}
/>
</div>
{/* Label mois en dessous */}
<span className="text-xs mt-1 text-gray-600">{point.month}</span> <span className="text-xs mt-1 text-gray-600">{point.month}</span>
</div> </div>
); );

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; 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 { useSession } from 'next-auth/react';
import { useCsrfToken } from '@/context/CsrfContext'; import { useCsrfToken } from '@/context/CsrfContext';
import { useNotification } from '@/context/NotificationContext'; import { useNotification } from '@/context/NotificationContext';
@ -99,6 +99,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
// États pour la confirmation de suppression // États pour la confirmation de suppression
const [confirmDeleteVisible, setConfirmDeleteVisible] = useState(false); const [confirmDeleteVisible, setConfirmDeleteVisible] = useState(false);
const [conversationToDelete, setConversationToDelete] = useState(null); const [conversationToDelete, setConversationToDelete] = useState(null);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(true);
// Refs // Refs
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
@ -541,6 +542,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
logger.debug('🔄 Sélection de la conversation:', conversation); logger.debug('🔄 Sélection de la conversation:', conversation);
setSelectedConversation(conversation); setSelectedConversation(conversation);
setTypingUsers([]); setTypingUsers([]);
setIsMobileSidebarOpen(false);
// Utiliser id ou conversation_id selon ce qui est disponible // Utiliser id ou conversation_id selon ce qui est disponible
const conversationId = conversation.id || conversation.conversation_id; const conversationId = conversation.id || conversation.conversation_id;
@ -828,7 +830,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
return ( return (
<div className="flex h-full bg-white"> <div className="flex h-full bg-white">
{/* Sidebar des conversations */} {/* Sidebar des conversations */}
<div className="w-80 bg-gray-50 border-r border-gray-200 flex flex-col"> <div className={`${isMobileSidebarOpen ? 'flex' : 'hidden'} md:flex w-full md:w-80 bg-gray-50 border-r border-gray-200 flex-col`}>
{/* En-tête */} {/* En-tête */}
<div className="p-4 border-b border-gray-200"> <div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
@ -986,12 +988,20 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
</div> </div>
{/* Zone de chat principale */} {/* Zone de chat principale */}
<div className="flex-1 flex flex-col"> <div className={`${!isMobileSidebarOpen ? 'flex' : 'hidden'} md:flex flex-1 flex-col`}>
{selectedConversation ? ( {selectedConversation ? (
<> <>
{/* En-tête de la conversation */} {/* En-tête de la conversation */}
<div className="p-4 border-b border-gray-200 bg-white"> <div className="p-4 border-b border-gray-200 bg-white">
<div className="flex items-center"> <div className="flex items-center">
{/* Bouton retour liste sur mobile */}
<button
onClick={() => setIsMobileSidebarOpen(true)}
className="mr-3 p-1 rounded hover:bg-gray-100 md:hidden"
aria-label="Retour aux conversations"
>
<ArrowLeft className="w-5 h-5 text-gray-600" />
</button>
<div className="flex items-center"> <div className="flex items-center">
<div className="relative"> <div className="relative">
<img <img

View File

@ -0,0 +1,158 @@
'use client';
import React, { useState, useEffect } from 'react';
import InputText from '@/components/Form/InputText';
import SelectChoice from '@/components/Form/SelectChoice';
import Button from '@/components/Form/Button';
import { Plus, Save, X } from 'lucide-react';
export default function EvaluationForm({
specialities,
period,
schoolClassId,
establishmentId,
initialValues,
onSubmit,
onCancel,
}) {
const isEditing = !!initialValues;
const [form, setForm] = useState({
name: '',
speciality: '',
date: '',
max_score: '20',
coefficient: '1',
description: '',
});
useEffect(() => {
if (initialValues) {
setForm({
name: initialValues.name || '',
speciality: initialValues.speciality?.toString() || '',
date: initialValues.date || '',
max_score: initialValues.max_score?.toString() || '20',
coefficient: initialValues.coefficient?.toString() || '1',
description: initialValues.description || '',
});
}
}, [initialValues]);
const [errors, setErrors] = useState({});
const validate = () => {
const newErrors = {};
if (!form.name.trim()) newErrors.name = 'Le nom est requis';
if (!form.speciality) newErrors.speciality = 'La matière est requise';
return newErrors;
};
const handleSubmit = (e) => {
e.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
onSubmit({
name: form.name,
speciality: Number(form.speciality),
school_class: schoolClassId,
establishment: establishmentId,
period: period,
date: form.date || null,
max_score: parseFloat(form.max_score) || 20,
coefficient: parseFloat(form.coefficient) || 1,
description: form.description,
});
};
return (
<form
onSubmit={handleSubmit}
className="space-y-4 p-4 bg-white rounded-lg border border-gray-200 shadow-sm"
>
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg text-gray-800">
{isEditing ? 'Modifier l\'évaluation' : 'Nouvelle évaluation'}
</h3>
<button
type="button"
onClick={onCancel}
className="p-1 hover:bg-gray-100 rounded"
>
<X size={20} className="text-gray-500" />
</button>
</div>
<InputText
name="name"
label="Nom de l'évaluation"
placeholder="Ex: Contrôle de mathématiques"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
errorMsg={errors.name}
required
/>
<SelectChoice
name="speciality"
label="Matière"
placeHolder="Sélectionner une matière"
choices={specialities.map((s) => ({ value: s.id, label: s.name }))}
selected={form.speciality}
callback={(e) => setForm({ ...form, speciality: e.target.value })}
errorMsg={errors.speciality}
required
/>
<InputText
name="date"
type="date"
label="Date de l'évaluation"
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
/>
<div className="flex gap-4">
<div className="flex-1">
<InputText
name="max_score"
type="number"
label="Note maximale"
value={form.max_score}
onChange={(e) => setForm({ ...form, max_score: e.target.value })}
/>
</div>
<div className="flex-1">
<InputText
name="coefficient"
type="number"
label="Coefficient"
value={form.coefficient}
onChange={(e) => setForm({ ...form, coefficient: e.target.value })}
/>
</div>
</div>
<InputText
name="description"
label="Description (optionnel)"
placeholder="Détails de l'évaluation..."
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
<div className="flex gap-2 pt-2">
<Button
primary
type="submit"
text={isEditing ? 'Enregistrer' : 'Créer l\'évaluation'}
icon={isEditing ? <Save size={16} /> : <Plus size={16} />}
/>
<Button type="button" text="Annuler" onClick={onCancel} />
</div>
</form>
);
}

View File

@ -0,0 +1,299 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Save, X, UserX, Trash2 } from 'lucide-react';
import Button from '@/components/Form/Button';
import CheckBox from '@/components/Form/CheckBox';
import { useNotification } from '@/context/NotificationContext';
export default function EvaluationGradeTable({
evaluation,
students,
studentEvaluations,
onSave,
onClose,
onDeleteGrade,
}) {
const [grades, setGrades] = useState({});
const [isSaving, setIsSaving] = useState(false);
const { showNotification } = useNotification();
// Initialiser les notes à partir des données existantes
useEffect(() => {
const initialGrades = {};
students.forEach((student) => {
const existingEval = studentEvaluations.find(
(se) => se.student === student.id && se.evaluation === evaluation.id
);
initialGrades[student.id] = {
score: existingEval?.score ?? '',
comment: existingEval?.comment ?? '',
is_absent: existingEval?.is_absent ?? false,
};
});
setGrades(initialGrades);
}, [students, studentEvaluations, evaluation]);
const handleScoreChange = (studentId, value) => {
const numValue = value === '' ? '' : parseFloat(value);
if (value !== '' && (numValue < 0 || numValue > evaluation.max_score)) {
return;
}
setGrades((prev) => ({
...prev,
[studentId]: {
...prev[studentId],
score: value,
is_absent: false,
},
}));
};
const handleAbsentToggle = (studentId) => {
setGrades((prev) => ({
...prev,
[studentId]: {
...prev[studentId],
is_absent: !prev[studentId]?.is_absent,
score: !prev[studentId]?.is_absent ? '' : prev[studentId]?.score,
},
}));
};
const handleCommentChange = (studentId, value) => {
setGrades((prev) => ({
...prev,
[studentId]: {
...prev[studentId],
comment: value,
},
}));
};
const handleSave = async () => {
setIsSaving(true);
try {
const dataToSave = Object.entries(grades).map(([studentId, data]) => ({
student_id: parseInt(studentId),
evaluation_id: evaluation.id,
score: data.score === '' ? null : parseFloat(data.score),
comment: data.comment,
is_absent: data.is_absent,
}));
await onSave(dataToSave);
showNotification('Notes enregistrées avec succès', 'success', 'Succès');
} catch (error) {
showNotification('Erreur lors de la sauvegarde', 'error', 'Erreur');
} finally {
setIsSaving(false);
}
};
// Calculer les statistiques
const stats = React.useMemo(() => {
const validScores = Object.values(grades)
.filter((g) => g.score !== '' && !g.is_absent)
.map((g) => parseFloat(g.score));
if (validScores.length === 0) return null;
const sum = validScores.reduce((a, b) => a + b, 0);
const avg = sum / validScores.length;
const min = Math.min(...validScores);
const max = Math.max(...validScores);
const absentCount = Object.values(grades).filter((g) => g.is_absent).length;
return { avg, min, max, count: validScores.length, absentCount };
}, [grades]);
return (
<div className="bg-white rounded-lg border border-gray-200 shadow-lg">
{/* Header */}
<div className="p-4 border-b border-gray-200 bg-gray-50 rounded-t-lg">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-lg text-gray-800">
{evaluation.name}
</h3>
<div className="text-sm text-gray-500 flex gap-3">
<span>{evaluation.speciality_name}</span>
<span></span>
<span>Note max: {evaluation.max_score}</span>
{evaluation.date && (
<>
<span></span>
<span>
{new Date(evaluation.date).toLocaleDateString('fr-FR')}
</span>
</>
)}
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-200 rounded-full"
>
<X size={20} className="text-gray-500" />
</button>
</div>
</div>
{/* Table */}
<div className="overflow-x-auto max-h-[60vh]">
<table className="min-w-full">
<thead className="bg-gray-50 sticky top-0">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
Élève
</th>
<th className="px-4 py-3 text-center text-sm font-medium text-gray-600 w-32">
Note / {evaluation.max_score}
</th>
<th className="px-4 py-3 text-center text-sm font-medium text-gray-600 w-24">
Absent
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
Commentaire
</th>
{onDeleteGrade && (
<th className="px-4 py-3 text-center text-sm font-medium text-gray-600 w-20">
Actions
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{students.map((student) => {
const studentGrade = grades[student.id] || {};
const isAbsent = studentGrade.is_absent;
const existingEval = studentEvaluations.find(
(se) => se.student === student.id && se.evaluation === evaluation.id
);
return (
<tr
key={student.id}
className={`hover:bg-gray-50 ${isAbsent ? 'bg-red-50' : ''}`}
>
<td className="px-4 py-3">
<div className="font-medium text-gray-800">
{student.last_name} {student.first_name}
</div>
</td>
<td className="px-4 py-3 text-center">
<input
type="number"
step="0.5"
min="0"
max={evaluation.max_score}
value={studentGrade.score ?? ''}
onChange={(e) =>
handleScoreChange(student.id, e.target.value)
}
disabled={isAbsent}
className={`w-20 px-2 py-1 text-center border rounded focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 ${
isAbsent
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'border-gray-300'
}`}
/>
</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleAbsentToggle(student.id)}
className={`p-2 rounded ${
isAbsent
? 'bg-red-100 text-red-600'
: 'bg-gray-100 text-gray-400 hover:bg-gray-200'
}`}
title={isAbsent ? 'Marquer présent' : 'Marquer absent'}
>
<UserX size={18} />
</button>
</td>
<td className="px-4 py-3">
<input
type="text"
value={studentGrade.comment ?? ''}
onChange={(e) =>
handleCommentChange(student.id, e.target.value)
}
placeholder="Commentaire..."
className="w-full px-2 py-1 border border-gray-300 rounded focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
</td>
{onDeleteGrade && (
<td className="px-4 py-3 text-center">
{existingEval && (
<button
onClick={() => {
if (confirm('Supprimer cette note ?')) {
onDeleteGrade(existingEval.id);
setGrades((prev) => ({
...prev,
[student.id]: { score: '', comment: '', is_absent: false },
}));
}
}}
className="p-2 text-red-600 hover:bg-red-50 rounded"
title="Supprimer la note"
>
<Trash2 size={18} />
</button>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
{/* Footer avec statistiques et boutons */}
<div className="p-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
<div className="flex items-center justify-between">
{/* Statistiques */}
{stats && (
<div className="flex gap-4 text-sm text-gray-600">
<span>
Moyenne:{' '}
<span className="font-semibold text-emerald-600">
{stats.avg.toFixed(2)}
</span>
</span>
<span>
Min:{' '}
<span className="font-semibold text-red-600">{stats.min}</span>
</span>
<span>
Max:{' '}
<span className="font-semibold text-green-600">{stats.max}</span>
</span>
<span>
Notés: {stats.count}/{students.length}
</span>
{stats.absentCount > 0 && (
<span className="text-red-600">
Absents: {stats.absentCount}
</span>
)}
</div>
)}
{/* Boutons */}
<div className="flex gap-2">
<Button text="Fermer" onClick={onClose} />
<Button
primary
text={isSaving ? 'Enregistrement...' : 'Enregistrer'}
icon={<Save size={16} />}
onClick={handleSave}
disabled={isSaving}
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,153 @@
'use client';
import React, { useState } from 'react';
import { Trash2, Edit2, ClipboardList, ChevronDown, ChevronUp } from 'lucide-react';
import Button from '@/components/Form/Button';
import Popup from '@/components/Popup';
import { useNotification } from '@/context/NotificationContext';
export default function EvaluationList({
evaluations,
onDelete,
onEdit,
onGradeStudents,
}) {
const [expandedId, setExpandedId] = useState(null);
const [deletePopupVisible, setDeletePopupVisible] = useState(false);
const [evaluationToDelete, setEvaluationToDelete] = useState(null);
const { showNotification } = useNotification();
const handleDeleteClick = (evaluation) => {
setEvaluationToDelete(evaluation);
setDeletePopupVisible(true);
};
const handleConfirmDelete = () => {
if (evaluationToDelete && onDelete) {
onDelete(evaluationToDelete.id)
.then(() => {
showNotification('Évaluation supprimée avec succès', 'success', 'Succès');
setDeletePopupVisible(false);
setEvaluationToDelete(null);
})
.catch((error) => {
showNotification('Erreur lors de la suppression', 'error', 'Erreur');
});
}
};
// Grouper les évaluations par matière
const groupedBySpeciality = evaluations.reduce((acc, ev) => {
const key = ev.speciality_name || 'Sans matière';
if (!acc[key]) {
acc[key] = {
name: key,
color: ev.speciality_color || '#6B7280',
evaluations: [],
};
}
acc[key].evaluations.push(ev);
return acc;
}, {});
if (evaluations.length === 0) {
return (
<div className="text-center text-gray-500 py-8">
Aucune évaluation créée pour cette période
</div>
);
}
return (
<div className="space-y-4">
{Object.values(groupedBySpeciality).map((group) => (
<div
key={group.name}
className="border border-gray-200 rounded-lg overflow-hidden"
>
<div
className="flex items-center justify-between p-3 bg-gray-50 cursor-pointer"
onClick={() =>
setExpandedId(expandedId === group.name ? null : group.name)
}
>
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: group.color }}
/>
<span className="font-medium text-gray-800">{group.name}</span>
<span className="text-sm text-gray-500">
({group.evaluations.length} évaluation
{group.evaluations.length > 1 ? 's' : ''})
</span>
</div>
{expandedId === group.name ? (
<ChevronUp size={20} className="text-gray-500" />
) : (
<ChevronDown size={20} className="text-gray-500" />
)}
</div>
{expandedId === group.name && (
<div className="divide-y divide-gray-100">
{group.evaluations.map((evaluation) => (
<div
key={evaluation.id}
className="p-3 flex items-center justify-between hover:bg-gray-50"
>
<div className="flex-1">
<div className="font-medium text-gray-700">
{evaluation.name}
</div>
<div className="text-sm text-gray-500 flex gap-3">
{evaluation.date && (
<span>
{new Date(evaluation.date).toLocaleDateString('fr-FR')}
</span>
)}
<span>Note max: {evaluation.max_score}</span>
<span>Coef: {evaluation.coefficient}</span>
</div>
</div>
<div className="flex gap-2">
<Button
primary
onClick={() => onGradeStudents(evaluation)}
icon={<ClipboardList size={16} />}
text="Noter"
title="Noter les élèves"
/>
<button
onClick={() => onEdit && onEdit(evaluation)}
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded"
title="Modifier"
>
<Edit2 size={16} />
</button>
<button
onClick={() => handleDeleteClick(evaluation)}
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded"
title="Supprimer"
>
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
)}
</div>
))}
<Popup
isOpen={deletePopupVisible}
message={`Êtes-vous sûr de vouloir supprimer l'évaluation "${evaluationToDelete?.name}" ?`}
onConfirm={handleConfirmDelete}
onCancel={() => {
setDeletePopupVisible(false);
setEvaluationToDelete(null);
}}
/>
</div>
);
}

View File

@ -0,0 +1,298 @@
'use client';
import React, { useState } from 'react';
import { BookOpen, TrendingUp, TrendingDown, Minus, Pencil, Trash2, Save, X } from 'lucide-react';
export default function EvaluationStudentView({
evaluations,
studentEvaluations,
onUpdateGrade,
onDeleteGrade,
editable = false
}) {
const [editingId, setEditingId] = useState(null);
const [editScore, setEditScore] = useState('');
const [editComment, setEditComment] = useState('');
const [editAbsent, setEditAbsent] = useState(false);
if (!evaluations || evaluations.length === 0) {
return (
<div className="text-center text-gray-500 py-8">
Aucune évaluation pour cette période
</div>
);
}
const startEdit = (ev, studentEval) => {
setEditingId(ev.id);
setEditScore(studentEval?.score ?? '');
setEditComment(studentEval?.comment ?? '');
setEditAbsent(studentEval?.is_absent ?? false);
};
const cancelEdit = () => {
setEditingId(null);
setEditScore('');
setEditComment('');
setEditAbsent(false);
};
const handleSaveEdit = async (ev, studentEval) => {
if (onUpdateGrade && studentEval) {
await onUpdateGrade(studentEval.id, {
score: editAbsent ? null : (editScore === '' ? null : parseFloat(editScore)),
comment: editComment,
is_absent: editAbsent,
});
}
cancelEdit();
};
const handleDelete = async (studentEval) => {
if (onDeleteGrade && studentEval && confirm('Supprimer cette note ?')) {
await onDeleteGrade(studentEval.id);
}
};
// Grouper les évaluations par matière
const groupedBySpeciality = evaluations.reduce((acc, ev) => {
const key = ev.speciality_name || 'Sans matière';
if (!acc[key]) {
acc[key] = {
name: key,
color: ev.speciality_color || '#6B7280',
evaluations: [],
totalScore: 0,
totalMaxScore: 0,
totalCoef: 0,
weightedSum: 0,
};
}
const studentEval = studentEvaluations.find(
(se) => se.evaluation === ev.id
);
const evalData = {
...ev,
studentScore: studentEval?.score,
studentComment: studentEval?.comment,
isAbsent: studentEval?.is_absent,
};
acc[key].evaluations.push(evalData);
// Calcul de la moyenne pondérée
if (studentEval?.score != null && !studentEval?.is_absent) {
const normalizedScore = (studentEval.score / ev.max_score) * 20;
acc[key].weightedSum += normalizedScore * ev.coefficient;
acc[key].totalCoef += parseFloat(ev.coefficient);
acc[key].totalScore += studentEval.score;
acc[key].totalMaxScore += parseFloat(ev.max_score);
}
return acc;
}, {});
// Calcul de la moyenne générale
let totalWeightedSum = 0;
let totalCoef = 0;
Object.values(groupedBySpeciality).forEach((group) => {
if (group.totalCoef > 0) {
const groupAvg = group.weightedSum / group.totalCoef;
totalWeightedSum += groupAvg * group.totalCoef;
totalCoef += group.totalCoef;
}
});
const generalAverage = totalCoef > 0 ? totalWeightedSum / totalCoef : null;
const getScoreColor = (score, maxScore) => {
if (score == null) return 'text-gray-400';
const percentage = (score / maxScore) * 100;
if (percentage >= 70) return 'text-green-600';
if (percentage >= 50) return 'text-yellow-600';
return 'text-red-600';
};
const getAverageIcon = (avg) => {
if (avg >= 14) return <TrendingUp size={16} className="text-green-500" />;
if (avg >= 10) return <Minus size={16} className="text-yellow-500" />;
return <TrendingDown size={16} className="text-red-500" />;
};
return (
<div className="space-y-4">
{/* Moyenne générale */}
{generalAverage !== null && (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<BookOpen className="text-emerald-600" size={24} />
<span className="font-medium text-emerald-800">Moyenne générale</span>
</div>
<div className="flex items-center gap-2">
{getAverageIcon(generalAverage)}
<span className="text-2xl font-bold text-emerald-700">
{generalAverage.toFixed(2)}/20
</span>
</div>
</div>
)}
{/* Évaluations par matière */}
{Object.values(groupedBySpeciality).map((group) => {
const groupAverage =
group.totalCoef > 0 ? group.weightedSum / group.totalCoef : null;
return (
<div
key={group.name}
className="border border-gray-200 rounded-lg overflow-hidden"
>
{/* Header de la matière */}
<div
className="p-3 flex items-center justify-between"
style={{ backgroundColor: `${group.color}15` }}
>
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: group.color }}
/>
<span className="font-semibold text-gray-800">{group.name}</span>
</div>
{groupAverage !== null && (
<div className="flex items-center gap-2">
{getAverageIcon(groupAverage)}
<span className="font-bold" style={{ color: group.color }}>
{groupAverage.toFixed(2)}/20
</span>
</div>
)}
</div>
{/* Liste des évaluations */}
<div className="divide-y divide-gray-100">
{group.evaluations.map((ev) => {
const studentEval = studentEvaluations.find(se => se.evaluation === ev.id);
const isEditing = editingId === ev.id;
return (
<div
key={ev.id}
className="p-3 flex items-center justify-between hover:bg-gray-50"
>
<div className="flex-1">
<div className="font-medium text-gray-700">{ev.name}</div>
<div className="text-sm text-gray-500 flex gap-2">
{ev.date && (
<span>
{new Date(ev.date).toLocaleDateString('fr-FR')}
</span>
)}
<span>Coef: {ev.coefficient}</span>
</div>
{!isEditing && ev.studentComment && (
<div className="text-sm text-gray-500 italic mt-1">
&quot;{ev.studentComment}&quot;
</div>
)}
{isEditing && (
<input
type="text"
value={editComment}
onChange={(e) => setEditComment(e.target.value)}
placeholder="Commentaire"
className="mt-2 w-full text-sm px-2 py-1 border rounded"
/>
)}
</div>
<div className="flex items-center gap-2">
{isEditing ? (
<>
<label className="flex items-center gap-1 text-sm text-gray-600">
<input
type="checkbox"
checked={editAbsent}
onChange={(e) => {
setEditAbsent(e.target.checked);
if (e.target.checked) setEditScore('');
}}
/>
Absent
</label>
{!editAbsent && (
<input
type="number"
value={editScore}
onChange={(e) => setEditScore(e.target.value)}
min="0"
max={ev.max_score}
step="0.5"
className="w-16 text-center px-2 py-1 border rounded"
/>
)}
<span className="text-gray-500">/{ev.max_score}</span>
<button
onClick={() => handleSaveEdit(ev, studentEval)}
className="p-1 text-emerald-600 hover:bg-emerald-50 rounded"
title="Enregistrer"
>
<Save size={16} />
</button>
<button
onClick={cancelEdit}
className="p-1 text-gray-500 hover:bg-gray-100 rounded"
title="Annuler"
>
<X size={16} />
</button>
</>
) : (
<>
{ev.isAbsent ? (
<span className="px-2 py-1 bg-red-100 text-red-700 rounded text-sm font-medium">
Absent
</span>
) : ev.studentScore != null ? (
<span
className={`text-lg font-bold ${getScoreColor(
ev.studentScore,
ev.max_score
)}`}
>
{ev.studentScore}/{ev.max_score}
</span>
) : (
<span className="text-gray-400 text-sm">Non noté</span>
)}
{editable && studentEval && (
<>
<button
onClick={() => startEdit(ev, studentEval)}
className="p-1 text-blue-600 hover:bg-blue-50 rounded"
title="Modifier"
>
<Pencil size={16} />
</button>
{onDeleteGrade && (
<button
onClick={() => handleDelete(studentEval)}
className="p-1 text-red-600 hover:bg-red-50 rounded"
title="Supprimer"
>
<Trash2 size={16} />
</button>
)}
</>
)}
</>
)}
</div>
</div>
);})}
</div>
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,4 @@
export { default as EvaluationForm } from './EvaluationForm';
export { default as EvaluationList } from './EvaluationList';
export { default as EvaluationGradeTable } from './EvaluationGradeTable';
export { default as EvaluationStudentView } from './EvaluationStudentView';

View File

@ -57,14 +57,14 @@ export default function FlashNotification({
animate={{ opacity: 1, x: 0 }} // Animation visible animate={{ opacity: 1, x: 0 }} // Animation visible
exit={{ opacity: 0, x: 50 }} // Animation de sortie exit={{ opacity: 0, x: 50 }} // Animation de sortie
transition={{ duration: 0.3 }} // Durée des animations transition={{ duration: 0.3 }} // Durée des animations
className="fixed top-5 right-5 flex items-stretch rounded-lg shadow-lg bg-white z-50 border border-gray-200" className="fixed top-5 right-2 left-2 sm:left-auto sm:right-5 sm:max-w-sm flex items-stretch rounded-lg shadow-lg bg-white z-50 border border-gray-200"
> >
{/* Rectangle gauche avec l'icône */} {/* Rectangle gauche avec l'icône */}
<div className={`flex items-center justify-center w-14 ${bg}`}> <div className={`flex items-center justify-center w-14 ${bg}`}>
{icon} {icon}
</div> </div>
{/* Zone de texte */} {/* Zone de texte */}
<div className="flex-1 w-96 p-4"> <div className="flex-1 min-w-0 p-4">
<p className="font-bold text-black">{title}</p> <p className="font-bold text-black">{title}</p>
<p className="text-gray-700">{message}</p> <p className="text-gray-700">{message}</p>
{type === 'error' && errorCode && ( {type === 'error' && errorCode && (

View File

@ -2,7 +2,7 @@ import Logo from '@/components/Logo';
export default function Footer({ softwareName, softwareVersion }) { export default function Footer({ softwareName, softwareVersion }) {
return ( return (
<footer className="absolute bottom-0 left-64 right-0 h-16 bg-white border-t border-gray-200 flex items-center justify-center box-border"> <footer className="absolute bottom-0 left-0 md:left-64 right-0 h-16 bg-white border-t border-gray-200 flex items-center justify-center box-border">
<div className="text-sm font-light"> <div className="text-sm font-light">
<span> <span>
&copy; {new Date().getFullYear()} N3WT-INNOV Tous droits réservés. &copy; {new Date().getFullYear()} N3WT-INNOV Tous droits réservés.

View File

@ -48,30 +48,6 @@ export default function FormRenderer({
} }
}, [initialValues, reset]); }, [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) => { const onSubmit = async (data) => {
logger.debug('=== DÉBUT onSubmit ==='); logger.debug('=== DÉBUT onSubmit ===');
logger.debug('Réponses :', data); logger.debug('Réponses :', data);

View File

@ -1,5 +1,5 @@
import React, { useState, useMemo, useEffect } from 'react'; 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'; import RadioList from '@/components/Form/RadioList';
const LEVELS = [ const LEVELS = [
@ -86,9 +86,10 @@ export default function GradeView({ data, grades, onGradeChange }) {
{domaine.domaine_nom} {domaine.domaine_nom}
</span> </span>
</div> </div>
<span className="text-emerald-700 text-xl"> {openDomains[domaine.domaine_id]
{openDomains[domaine.domaine_id] ? '▼' : '►'} ? <ChevronDown className="w-5 h-5 text-emerald-700" />
</span> : <ChevronRight className="w-5 h-5 text-emerald-700" />
}
</div> </div>
{openDomains[domaine.domaine_id] && ( {openDomains[domaine.domaine_id] && (
<div className="mt-4"> <div className="mt-4">
@ -99,7 +100,10 @@ export default function GradeView({ data, grades, onGradeChange }) {
className="flex items-center gap-2 text-lg font-semibold text-emerald-700 mb-4 hover:underline" className="flex items-center gap-2 text-lg font-semibold text-emerald-700 mb-4 hover:underline"
onClick={() => toggleCategory(categorie.categorie_id)} onClick={() => toggleCategory(categorie.categorie_id)}
> >
{openCategories[categorie.categorie_id] ? '▼' : '►'}{' '} {openCategories[categorie.categorie_id]
? <ChevronDown className="w-4 h-4" />
: <ChevronRight className="w-4 h-4" />
}
{categorie.categorie_nom} {categorie.categorie_nom}
</button> </button>
{openCategories[categorie.categorie_id] && ( {openCategories[categorie.categorie_id] && (

View File

@ -0,0 +1,18 @@
'use client';
import { Menu } from 'lucide-react';
import ProfileSelector from '@/components/ProfileSelector';
export default function MobileTopbar({ onMenuClick }) {
return (
<header className="fixed top-0 left-0 right-0 z-40 h-14 bg-white border-b border-gray-200 flex items-center justify-between px-4 md:hidden">
<button
onClick={onMenuClick}
className="p-2 rounded-md hover:bg-gray-100 border border-gray-200"
aria-label="Ouvrir le menu"
>
<Menu size={20} />
</button>
<ProfileSelector compact />
</header>
);
}

View File

@ -6,11 +6,11 @@ const Pagination = ({ currentPage, totalPages, onPageChange }) => {
const pages = Array.from({ length: totalPages }, (_, i) => i + 1); const pages = Array.from({ length: totalPages }, (_, i) => i + 1);
return ( return (
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between"> <div className="px-4 sm:px-6 py-4 border-t border-gray-200 flex flex-wrap items-center justify-between gap-2">
<div className="text-sm text-gray-600"> <div className="text-sm text-gray-600">
{t('page')} {currentPage} {t('of')} {pages.length} {t('page')} {currentPage} {t('of')} {pages.length}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-1 sm:gap-2">
{currentPage > 1 && ( {currentPage > 1 && (
<PaginationButton <PaginationButton
text={t('previous')} text={t('previous')}

View File

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

View File

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

View File

@ -13,7 +13,7 @@ import {
BASE_URL, BASE_URL,
} from '@/utils/Url'; } from '@/utils/Url';
const ProfileSelector = ({ onRoleChange, className = '' }) => { const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
const { const {
establishments, establishments,
selectedRoleId, selectedRoleId,
@ -103,50 +103,72 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
// Suppression du tronquage JS, on utilise uniquement CSS // Suppression du tronquage JS, on utilise uniquement CSS
const isSingleRole = establishments && establishments.length === 1; const isSingleRole = establishments && establishments.length === 1;
const buttonContent = compact ? (
/* Mode compact : avatar seul pour la topbar mobile */
<div className="flex items-center gap-1 cursor-pointer px-2 py-1 rounded-md hover:bg-gray-100">
<div className="relative">
<Image
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)}
alt="Profile"
className="w-8 h-8 rounded-full object-cover shadow-md"
width={32}
height={32}
/>
<div
className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${getStatusColor()}`}
title={getStatusTitle()}
/>
</div>
<ChevronDown
className={`w-4 h-4 text-gray-500 transition-transform duration-200 ${dropdownOpen ? 'rotate-180' : 'rotate-0'}`}
/>
</div>
) : (
/* Mode normal : avatar + infos texte */
<div className="flex items-center gap-2 cursor-pointer px-4 bg-white h-24">
<div className="relative">
<Image
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)}
alt="Profile"
className="w-16 h-16 rounded-full object-cover shadow-md"
width={64}
height={64}
/>
<div
className={`absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-white ${getStatusColor()}`}
title={getStatusTitle()}
/>
</div>
<div className="flex-1 min-w-0">
<div
className="font-semibold text-base text-gray-900 text-left truncate max-w-full"
title={user?.email}
>
{user?.email}
</div>
<div
className="font-semibold text-base text-emerald-700 text-left truncate max-w-full"
title={selectedEstablishment?.name || ''}
>
{selectedEstablishment?.name || ''}
</div>
<div
className="italic text-sm text-emerald-600 text-left truncate max-w-full"
title={getRightStr(selectedEstablishment?.role_type) || ''}
>
{getRightStr(selectedEstablishment?.role_type) || ''}
</div>
</div>
<ChevronDown
className={`w-5 h-5 transition-transform duration-200 ${dropdownOpen ? 'rotate-180' : 'rotate-0'}`}
/>
</div>
);
return ( return (
<div className={`relative ${className}`}> <div className={`relative ${className}`}>
<DropdownMenu <DropdownMenu
buttonContent={ buttonContent={buttonContent}
<div className="h-16 flex items-center gap-2 cursor-pointer px-4 bg-white h-24">
<div className="relative">
<Image
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)}
alt="Profile"
className="w-16 h-16 rounded-full object-cover shadow-md"
width={64}
height={64}
/>
{/* Bulle de statut de connexion au chat */}
<div
className={`absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-white ${getStatusColor()}`}
title={getStatusTitle()}
/>
</div>
<div className="flex-1 min-w-0">
<div
className="font-semibold text-base text-gray-900 text-left truncate max-w-full"
title={user?.email}
>
{user?.email}
</div>
<div
className="font-semibold text-base text-emerald-700 text-left truncate max-w-full"
title={selectedEstablishment?.name || ''}
>
{selectedEstablishment?.name || ''}
</div>
<div
className="italic text-sm text-emerald-600 text-left truncate max-w-full"
title={getRightStr(selectedEstablishment?.role_type) || ''}
>
{getRightStr(selectedEstablishment?.role_type) || ''}
</div>
</div>
<ChevronDown
className={`w-5 h-5 transition-transform duration-200 ${dropdownOpen ? 'rotate-180' : 'rotate-0'}`}
/>
</div>
}
items={ items={
isSingleRole isSingleRole
? [ ? [
@ -190,7 +212,10 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
] ]
} }
buttonClassName="w-full" 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} dropdownOpen={dropdownOpen}
setDropdownOpen={setDropdownOpen} setDropdownOpen={setDropdownOpen}
/> />

View File

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

View File

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

View File

@ -34,7 +34,7 @@ function Sidebar({ currentPage, items, onCloseMobile }) {
return ( return (
<div className="w-64 bg-stone-50 border-r h-full border-gray-200"> <div className="w-64 bg-stone-50 border-r h-full border-gray-200">
<div className="border-b border-gray-200 "> <div className="border-b border-gray-200 hidden md:block">
<ProfileSelector className="border-none h-24" /> <ProfileSelector className="border-none h-24" />
</div> </div>
<nav className="space-y-1 px-4 py-6"> <nav className="space-y-1 px-4 py-6">

View File

@ -1,8 +1,12 @@
import React, { useState } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { ChevronLeft, ChevronRight } from 'lucide-react';
const SidebarTabs = ({ tabs, onTabChange }) => { const SidebarTabs = ({ tabs, onTabChange }) => {
const [activeTab, setActiveTab] = useState(tabs[0].id); const [activeTab, setActiveTab] = useState(tabs[0].id);
const [showLeftArrow, setShowLeftArrow] = useState(false);
const [showRightArrow, setShowRightArrow] = useState(false);
const scrollRef = useRef(null);
const handleTabChange = (tabId) => { const handleTabChange = (tabId) => {
setActiveTab(tabId); setActiveTab(tabId);
@ -11,23 +15,77 @@ const SidebarTabs = ({ tabs, onTabChange }) => {
} }
}; };
const updateArrows = () => {
const el = scrollRef.current;
if (!el) return;
setShowLeftArrow(el.scrollLeft > 0);
setShowRightArrow(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
useEffect(() => {
updateArrows();
const el = scrollRef.current;
if (!el) return;
el.addEventListener('scroll', updateArrows);
window.addEventListener('resize', updateArrows);
return () => {
el.removeEventListener('scroll', updateArrows);
window.removeEventListener('resize', updateArrows);
};
}, [tabs]);
const scroll = (direction) => {
const el = scrollRef.current;
if (!el) return;
el.scrollBy({ left: direction === 'left' ? -150 : 150, behavior: 'smooth' });
};
return ( return (
<div className="flex flex-col h-full w-full"> <div className="flex flex-col h-full w-full">
{/* Tabs Header */} {/* Tabs Header */}
<div className="flex h-14 bg-gray-50 border-b border-gray-200 shadow-sm"> <div className="relative flex items-center bg-gray-50 border-b border-gray-200 shadow-sm">
{tabs.map((tab) => ( {/* Flèche gauche */}
{showLeftArrow && (
<button <button
key={tab.id} onClick={() => scroll('left')}
className={`flex-1 text-center p-4 font-medium transition-colors duration-200 ${ className="absolute left-0 z-10 h-full w-10 flex items-center justify-center bg-gradient-to-r from-gray-50 via-gray-50 to-transparent text-gray-500 hover:text-emerald-600 active:text-emerald-700"
activeTab === tab.id aria-label="Tabs précédents"
? 'border-b-4 border-emerald-500 text-emerald-600 bg-emerald-50 font-semibold'
: 'text-gray-500 hover:text-emerald-500'
}`}
onClick={() => handleTabChange(tab.id)}
> >
{tab.label} <ChevronLeft size={22} strokeWidth={2.5} />
</button> </button>
))} )}
{/* Liste des onglets scrollable */}
<div
ref={scrollRef}
className="flex overflow-x-auto scrollbar-none scroll-smooth"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={`flex-shrink-0 whitespace-nowrap h-14 px-5 font-medium transition-colors duration-200 ${
activeTab === tab.id
? 'border-b-4 border-emerald-500 text-emerald-600 bg-emerald-50 font-semibold'
: 'text-gray-500 hover:text-emerald-500'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Flèche droite */}
{showRightArrow && (
<button
onClick={() => scroll('right')}
className="absolute right-0 z-10 h-full w-10 flex items-center justify-center bg-gradient-to-l from-gray-50 via-gray-50 to-transparent text-gray-500 hover:text-emerald-600 active:text-emerald-700"
aria-label="Tabs suivants"
>
<ChevronRight size={22} strokeWidth={2.5} />
</button>
)}
</div> </div>
{/* Tabs Content */} {/* Tabs Content */}
@ -38,10 +96,10 @@ const SidebarTabs = ({ tabs, onTabChange }) => {
activeTab === tab.id && ( activeTab === tab.id && (
<motion.div <motion.div
key={tab.id} key={tab.id}
initial={{ opacity: 0, x: 50 }} // Animation d'entrée initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }} // Animation visible animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }} // Animation de sortie exit={{ opacity: 0, x: -50 }}
transition={{ duration: 0.3 }} // Durée des animations transition={{ duration: 0.3 }}
className="flex-1 flex flex-col h-full min-h-0" className="flex-1 flex flex-col h-full min-h-0"
> >
{tab.content} {tab.content}

View File

@ -4,7 +4,7 @@ import React, {
forwardRef, forwardRef,
useImperativeHandle, useImperativeHandle,
} from 'react'; } from 'react';
import { CheckCircle, Circle } from 'lucide-react'; import { CheckCircle, Circle, ChevronDown, ChevronRight } from 'lucide-react';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
const TreeView = forwardRef(function TreeView( const TreeView = forwardRef(function TreeView(
@ -80,20 +80,27 @@ const TreeView = forwardRef(function TreeView(
{data.map((domaine) => ( {data.map((domaine) => (
<div key={domaine.domaine_id} className="mb-4"> <div key={domaine.domaine_id} className="mb-4">
<button <button
className="w-full text-left px-3 py-2 bg-emerald-100 hover:bg-emerald-200 rounded font-semibold text-emerald-800" className="w-full text-left px-3 py-2 bg-emerald-100 hover:bg-emerald-200 rounded font-semibold text-emerald-800 flex items-center gap-2"
onClick={() => toggleDomain(domaine.domaine_id)} onClick={() => toggleDomain(domaine.domaine_id)}
> >
{openDomains[domaine.domaine_id] ? '▼' : '►'} {domaine.domaine_nom} {openDomains[domaine.domaine_id]
? <ChevronDown className="w-4 h-4 flex-shrink-0" />
: <ChevronRight className="w-4 h-4 flex-shrink-0" />
}
{domaine.domaine_nom}
</button> </button>
{openDomains[domaine.domaine_id] && ( {openDomains[domaine.domaine_id] && (
<div className="ml-4"> <div className="ml-4">
{domaine.categories.map((categorie) => ( {domaine.categories.map((categorie) => (
<div key={categorie.categorie_id} className="mb-2"> <div key={categorie.categorie_id} className="mb-2">
<button <button
className="w-full text-left px-2 py-1 bg-emerald-50 hover:bg-emerald-100 rounded text-emerald-700" className="w-full text-left px-2 py-1 bg-emerald-50 hover:bg-emerald-100 rounded text-emerald-700 flex items-center gap-2"
onClick={() => toggleCategory(categorie.categorie_id)} onClick={() => toggleCategory(categorie.categorie_id)}
> >
{openCategories[categorie.categorie_id] ? '▼' : '►'} {openCategories[categorie.categorie_id]
? <ChevronDown className="w-4 h-4 flex-shrink-0" />
: <ChevronRight className="w-4 h-4 flex-shrink-0" />
}
{categorie.categorie_nom} {categorie.categorie_nom}
</button> </button>
{openCategories[categorie.categorie_id] && ( {openCategories[categorie.categorie_id] && (

View File

@ -130,6 +130,12 @@ const ClassesSection = ({
const [removePopupVisible, setRemovePopupVisible] = useState(false); const [removePopupVisible, setRemovePopupVisible] = useState(false);
const [removePopupMessage, setRemovePopupMessage] = useState(''); const [removePopupMessage, setRemovePopupMessage] = useState('');
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
const ITEMS_PER_PAGE = 10;
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => { setCurrentPage(1); }, [classes]);
useEffect(() => { if (newClass) setCurrentPage(1); }, [newClass]);
const totalPages = Math.ceil(classes.length / ITEMS_PER_PAGE);
const pagedClasses = classes.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
const { selectedEstablishmentId, profileRole } = useEstablishment(); const { selectedEstablishmentId, profileRole } = useEstablishment();
const { addSchedule, reloadPlanning, reloadEvents } = usePlanning(); const { addSchedule, reloadPlanning, reloadEvents } = usePlanning();
const { getNiveauxLabels, allNiveaux } = useClasses(); const { getNiveauxLabels, allNiveaux } = useClasses();
@ -555,7 +561,7 @@ const ClassesSection = ({
onClick={handleAddClass} onClick={handleAddClass}
/> />
<Table <Table
data={newClass ? [newClass, ...classes] : classes} data={newClass ? [newClass, ...pagedClasses] : pagedClasses}
columns={columns} columns={columns}
renderCell={renderClassCell} renderCell={renderClassCell}
emptyMessage={ emptyMessage={
@ -565,6 +571,10 @@ const ClassesSection = ({
message="Veuillez procéder à la création d'une nouvelle classe." message="Veuillez procéder à la création d'une nouvelle classe."
/> />
} }
itemsPerPage={ITEMS_PER_PAGE}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/> />
<Popup <Popup
isOpen={popupVisible} isOpen={popupVisible}

View File

@ -1,5 +1,5 @@
import { Trash2, Edit3, Check, X, BookOpen } from 'lucide-react'; import { Trash2, Edit3, Check, X, BookOpen } from 'lucide-react';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
import InputTextWithColorIcon from '@/components/Form/InputTextWithColorIcon'; import InputTextWithColorIcon from '@/components/Form/InputTextWithColorIcon';
@ -28,6 +28,12 @@ const SpecialitiesSection = ({
const [removePopupVisible, setRemovePopupVisible] = useState(false); const [removePopupVisible, setRemovePopupVisible] = useState(false);
const [removePopupMessage, setRemovePopupMessage] = useState(''); const [removePopupMessage, setRemovePopupMessage] = useState('');
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
const ITEMS_PER_PAGE = 10;
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => { setCurrentPage(1); }, [specialities]);
useEffect(() => { if (newSpeciality) setCurrentPage(1); }, [newSpeciality]);
const totalPages = Math.ceil(specialities.length / ITEMS_PER_PAGE);
const pagedSpecialities = specialities.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
const { selectedEstablishmentId, profileRole } = useEstablishment(); const { selectedEstablishmentId, profileRole } = useEstablishment();
@ -253,7 +259,7 @@ const SpecialitiesSection = ({
onClick={handleAddSpeciality} onClick={handleAddSpeciality}
/> />
<Table <Table
data={newSpeciality ? [newSpeciality, ...specialities] : specialities} data={newSpeciality ? [newSpeciality, ...pagedSpecialities] : pagedSpecialities}
columns={columns} columns={columns}
renderCell={renderSpecialityCell} renderCell={renderSpecialityCell}
emptyMessage={ emptyMessage={
@ -263,6 +269,10 @@ const SpecialitiesSection = ({
message="Veuillez procéder à la création d'une nouvelle spécialité." message="Veuillez procéder à la création d'une nouvelle spécialité."
/> />
} }
itemsPerPage={ITEMS_PER_PAGE}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/> />
<Popup <Popup
isOpen={popupVisible} isOpen={popupVisible}

View File

@ -24,53 +24,56 @@ const StructureManagement = ({
return ( return (
<div className="w-full"> <div className="w-full">
<ClassesProvider> <ClassesProvider>
<div className="mt-8 w-2/5"> {/* Spécialités + Enseignants : côte à côte sur desktop, empilés sur mobile */}
<SpecialitiesSection <div className="mt-8 flex flex-col xl:flex-row gap-8">
specialities={specialities} <div className="w-full xl:w-2/5">
setSpecialities={setSpecialities} <SpecialitiesSection
handleCreate={(newData) => specialities={specialities}
handleCreate( setSpecialities={setSpecialities}
`${BE_SCHOOL_SPECIALITIES_URL}`, handleCreate={(newData) =>
newData, handleCreate(
setSpecialities `${BE_SCHOOL_SPECIALITIES_URL}`,
) newData,
} setSpecialities
handleEdit={(id, updatedData) => )
handleEdit( }
`${BE_SCHOOL_SPECIALITIES_URL}`, handleEdit={(id, updatedData) =>
id, handleEdit(
updatedData, `${BE_SCHOOL_SPECIALITIES_URL}`,
setSpecialities id,
) updatedData,
} setSpecialities
handleDelete={(id) => )
handleDelete(`${BE_SCHOOL_SPECIALITIES_URL}`, id, setSpecialities) }
} handleDelete={(id) =>
/> handleDelete(`${BE_SCHOOL_SPECIALITIES_URL}`, id, setSpecialities)
}
/>
</div>
<div className="w-full xl:flex-1">
<TeachersSection
teachers={teachers}
setTeachers={setTeachers}
specialities={specialities}
profiles={profiles}
handleCreate={(newData) =>
handleCreate(`${BE_SCHOOL_TEACHERS_URL}`, newData, setTeachers)
}
handleEdit={(id, updatedData) =>
handleEdit(
`${BE_SCHOOL_TEACHERS_URL}`,
id,
updatedData,
setTeachers
)
}
handleDelete={(id) =>
handleDelete(`${BE_SCHOOL_TEACHERS_URL}`, id, setTeachers)
}
/>
</div>
</div> </div>
<div className="w-4/5 mt-12"> <div className="w-full mt-8 xl:mt-12">
<TeachersSection
teachers={teachers}
setTeachers={setTeachers}
specialities={specialities}
profiles={profiles}
handleCreate={(newData) =>
handleCreate(`${BE_SCHOOL_TEACHERS_URL}`, newData, setTeachers)
}
handleEdit={(id, updatedData) =>
handleEdit(
`${BE_SCHOOL_TEACHERS_URL}`,
id,
updatedData,
setTeachers
)
}
handleDelete={(id) =>
handleDelete(`${BE_SCHOOL_TEACHERS_URL}`, id, setTeachers)
}
/>
</div>
<div className="w-full mt-12">
<ClassesSection <ClassesSection
classes={classes} classes={classes}
setClasses={setClasses} setClasses={setClasses}

Some files were not shown because too many files have changed in this diff Show More