mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-03 16:51:26 +00:00
Compare commits
39 Commits
main
...
4e50a0696f
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e50a0696f | |||
| 4248a589c5 | |||
| 7464b19de5 | |||
| c96b9562a2 | |||
| 7576b5a68c | |||
| e30a41a58b | |||
| c296af2c07 | |||
| fa843097ba | |||
| 2fef6d61a4 | |||
| 0501c1dd73 | |||
| 4f7d7d0024 | |||
| 8fd1b62ec0 | |||
| 3779a47417 | |||
| 05c68ebfaa | |||
| 195579e217 | |||
| ddcaba382e | |||
| a82483f3bd | |||
| 26d4b5633f | |||
| d66db1b019 | |||
| bd7dc2b0c2 | |||
| 176edc5c45 | |||
| 92c6a31740 | |||
| 9dff32b388 | |||
| abb4b525b2 | |||
| b4f70e6bad | |||
| 8549699dec | |||
| a034149eae | |||
| 12f5fc7aa9 | |||
| 2dc0dfa268 | |||
| dd00cba385 | |||
| 7486f6c5ce | |||
| 1e5bc6ccba | |||
| 0fb668b212 | |||
| 5e62ee5100 | |||
| e89d2fc4c3 | |||
| 9481a0132d | |||
| 482e8c1357 | |||
| 0e0141d155 | |||
| 7f002e2e6a |
1
.github/copilot-instructions.md
vendored
1
.github/copilot-instructions.md
vendored
@ -56,3 +56,4 @@ Pour le front-end, les exigences de qualité sont les suivantes :
|
|||||||
|
|
||||||
- **Tickets** : [issues guidelines](./instructions/issues.instruction.md)
|
- **Tickets** : [issues guidelines](./instructions/issues.instruction.md)
|
||||||
- **Commits** : [commit guidelines](./instructions/general-commit.instruction.md)
|
- **Commits** : [commit guidelines](./instructions/general-commit.instruction.md)
|
||||||
|
- **Tests** : [run tests](./instructions/run-tests.instruction.md)
|
||||||
|
|||||||
53
.github/instructions/run-tests.instruction.md
vendored
Normal file
53
.github/instructions/run-tests.instruction.md
vendored
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
applyTo: "**"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Lancer les tests – N3WT-SCHOOL
|
||||||
|
|
||||||
|
## Tests backend (Django)
|
||||||
|
|
||||||
|
Les tests backend tournent dans le conteneur Docker. Toujours utiliser `--settings=N3wtSchool.test_settings`.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Tous les tests
|
||||||
|
docker exec -it n3wt-school-backend-1 python3 manage.py test --settings=N3wtSchool.test_settings --verbosity=2
|
||||||
|
|
||||||
|
# Un module spécifique
|
||||||
|
docker exec -it n3wt-school-backend-1 python3 manage.py test --settings=N3wtSchool.test_settings Auth.tests --verbosity=2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Points importants
|
||||||
|
|
||||||
|
- Le fichier `Back-End/N3wtSchool/test_settings.py` configure l'environnement de test :
|
||||||
|
- Base PostgreSQL dédiée `school_test` (SQLite incompatible avec `ArrayField`)
|
||||||
|
- Cache en mémoire locale (pas de Redis)
|
||||||
|
- Channels en mémoire (`InMemoryChannelLayer`)
|
||||||
|
- Throttling désactivé
|
||||||
|
- Hashage MD5 (plus rapide)
|
||||||
|
- Email en mode `locmem`
|
||||||
|
- Si le conteneur n'est pas démarré : `docker compose up -d` depuis la racine du projet
|
||||||
|
- Les logs `WARNING` dans la sortie des tests sont normaux (endpoints qui retournent 400/401 intentionnellement)
|
||||||
|
|
||||||
|
## Tests frontend (Jest)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Depuis le dossier Front-End
|
||||||
|
cd Front-End
|
||||||
|
npm test -- --watchAll=false
|
||||||
|
|
||||||
|
# Avec couverture
|
||||||
|
npm test -- --watchAll=false --coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
### Points importants
|
||||||
|
|
||||||
|
- Les tests sont dans `Front-End/src/test/`
|
||||||
|
- Les warnings `ReactDOMTestUtils.act is deprecated` sont non bloquants (dépendance `@testing-library/react`)
|
||||||
|
- Config Jest : `Front-End/jest.config.js`
|
||||||
|
|
||||||
|
## Résultats attendus
|
||||||
|
|
||||||
|
| Périmètre | Nb tests | Statut |
|
||||||
|
| -------------- | -------- | ------ |
|
||||||
|
| Backend Django | 121 | ✅ OK |
|
||||||
|
| Frontend Jest | 24 | ✅ OK |
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -2,4 +2,5 @@
|
|||||||
.env
|
.env
|
||||||
node_modules/
|
node_modules/
|
||||||
hardcoded-strings-report.md
|
hardcoded-strings-report.md
|
||||||
backend.env
|
backend.env
|
||||||
|
*.log
|
||||||
@ -2,6 +2,12 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.contrib.auth.backends import ModelBackend
|
from 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
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
# 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')),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -25,7 +25,7 @@ class ProfileRole(models.Model):
|
|||||||
profile = models.ForeignKey('Profile', on_delete=models.CASCADE, related_name='roles')
|
profile = models.ForeignKey('Profile', on_delete=models.CASCADE, related_name='roles')
|
||||||
role_type = models.IntegerField(choices=RoleType.choices, default=RoleType.PROFIL_UNDEFINED)
|
role_type = models.IntegerField(choices=RoleType.choices, default=RoleType.PROFIL_UNDEFINED)
|
||||||
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='profile_roles')
|
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='profile_roles')
|
||||||
is_active = models.BooleanField(default=False)
|
is_active = models.BooleanField(default=False, blank=True)
|
||||||
updated_date = models.DateTimeField(auto_now=True)
|
updated_date = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
553
Back-End/Auth/tests.py
Normal file
553
Back-End/Auth/tests.py
Normal file
@ -0,0 +1,553 @@
|
|||||||
|
"""
|
||||||
|
Tests unitaires pour le module Auth.
|
||||||
|
Vérifie :
|
||||||
|
- L'accès public aux endpoints de login/CSRF/subscribe
|
||||||
|
- La protection JWT des endpoints protégés (profils, rôles, session)
|
||||||
|
- La génération et validation des tokens JWT
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
from Auth.models import Profile, ProfileRole
|
||||||
|
from Establishment.models import Establishment
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def create_establishment():
|
||||||
|
"""Crée un établissement minimal utilisé dans les tests."""
|
||||||
|
return Establishment.objects.create(
|
||||||
|
name="Ecole Test",
|
||||||
|
address="1 rue de l'Ecole",
|
||||||
|
total_capacity=100,
|
||||||
|
establishment_type=[1],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(email="test@example.com", password="testpassword123"):
|
||||||
|
"""Crée un utilisateur (Profile) de test."""
|
||||||
|
user = Profile.objects.create_user(
|
||||||
|
username=email,
|
||||||
|
email=email,
|
||||||
|
password=password,
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def create_active_user_with_role(email="active@example.com", password="testpassword123"):
|
||||||
|
"""Crée un utilisateur avec un rôle actif."""
|
||||||
|
user = create_user(email=email, password=password)
|
||||||
|
establishment = create_establishment()
|
||||||
|
ProfileRole.objects.create(
|
||||||
|
profile=user,
|
||||||
|
role_type=ProfileRole.RoleType.PROFIL_ADMIN,
|
||||||
|
establishment=establishment,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_jwt_token(user):
|
||||||
|
"""Retourne un token d'accès JWT pour l'utilisateur donné."""
|
||||||
|
refresh = RefreshToken.for_user(user)
|
||||||
|
return str(refresh.access_token)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests endpoints publics
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
)
|
||||||
|
class CsrfEndpointTest(TestCase):
|
||||||
|
"""Test de l'endpoint CSRF – doit être accessible sans authentification."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def test_csrf_endpoint_accessible_sans_auth(self):
|
||||||
|
"""GET /Auth/csrf doit retourner 200 sans token."""
|
||||||
|
response = self.client.get(reverse("Auth:csrf"))
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertIn("csrfToken", response.json())
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
)
|
||||||
|
class LoginEndpointTest(TestCase):
|
||||||
|
"""Tests de l'endpoint de connexion."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.url = reverse("Auth:login")
|
||||||
|
self.user = create_active_user_with_role(
|
||||||
|
email="logintest@example.com", password="secureP@ss1"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_login_avec_identifiants_valides(self):
|
||||||
|
"""POST /Auth/login avec identifiants valides retourne 200 et un token."""
|
||||||
|
payload = {"email": "logintest@example.com", "password": "secureP@ss1"}
|
||||||
|
response = self.client.post(
|
||||||
|
self.url, data=json.dumps(payload), content_type="application/json"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
data = response.json()
|
||||||
|
self.assertIn("token", data)
|
||||||
|
self.assertIn("refresh", data)
|
||||||
|
|
||||||
|
def test_login_avec_mauvais_mot_de_passe(self):
|
||||||
|
"""POST /Auth/login avec mauvais mot de passe retourne 400 ou 401."""
|
||||||
|
payload = {"email": "logintest@example.com", "password": "wrongpassword"}
|
||||||
|
response = self.client.post(
|
||||||
|
self.url, data=json.dumps(payload), content_type="application/json"
|
||||||
|
)
|
||||||
|
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
|
||||||
|
|
||||||
|
def test_login_avec_email_inexistant(self):
|
||||||
|
"""POST /Auth/login avec email inconnu retourne 400 ou 401."""
|
||||||
|
payload = {"email": "unknown@example.com", "password": "anypassword"}
|
||||||
|
response = self.client.post(
|
||||||
|
self.url, data=json.dumps(payload), content_type="application/json"
|
||||||
|
)
|
||||||
|
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
|
||||||
|
|
||||||
|
def test_login_accessible_sans_authentification(self):
|
||||||
|
"""L'endpoint de login doit être accessible sans token JWT."""
|
||||||
|
# On vérifie juste que l'on n'obtient pas 401/403 pour raison d'auth manquante
|
||||||
|
payload = {"email": "logintest@example.com", "password": "secureP@ss1"}
|
||||||
|
response = self.client.post(
|
||||||
|
self.url, data=json.dumps(payload), content_type="application/json"
|
||||||
|
)
|
||||||
|
self.assertNotEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
)
|
||||||
|
class RefreshJWTEndpointTest(TestCase):
|
||||||
|
"""Tests de l'endpoint de rafraîchissement du token JWT."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.url = reverse("Auth:refresh_jwt")
|
||||||
|
self.user = create_active_user_with_role(email="refresh@example.com")
|
||||||
|
|
||||||
|
def test_refresh_avec_token_valide(self):
|
||||||
|
"""POST /Auth/refreshJWT avec refresh token valide retourne un nouvel access token."""
|
||||||
|
import jwt, uuid
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
# RefreshJWTView attend le format custom (type='refresh'), pas le format SimpleJWT
|
||||||
|
refresh_payload = {
|
||||||
|
'user_id': self.user.id,
|
||||||
|
'type': 'refresh',
|
||||||
|
'jti': str(uuid.uuid4()),
|
||||||
|
'exp': datetime.utcnow() + timedelta(days=1),
|
||||||
|
'iat': datetime.utcnow(),
|
||||||
|
}
|
||||||
|
custom_refresh = jwt.encode(
|
||||||
|
refresh_payload,
|
||||||
|
django_settings.SIMPLE_JWT['SIGNING_KEY'],
|
||||||
|
algorithm=django_settings.SIMPLE_JWT['ALGORITHM'],
|
||||||
|
)
|
||||||
|
payload = {"refresh": custom_refresh}
|
||||||
|
response = self.client.post(
|
||||||
|
self.url, data=json.dumps(payload), content_type="application/json"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertIn("token", response.json())
|
||||||
|
|
||||||
|
def test_refresh_avec_token_invalide(self):
|
||||||
|
"""POST /Auth/refreshJWT avec token invalide retourne 401."""
|
||||||
|
payload = {"refresh": "invalid.token.here"}
|
||||||
|
response = self.client.post(
|
||||||
|
self.url, data=json.dumps(payload), content_type="application/json"
|
||||||
|
)
|
||||||
|
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
|
||||||
|
|
||||||
|
def test_refresh_accessible_sans_authentification(self):
|
||||||
|
"""L'endpoint de refresh doit être accessible sans token d'accès."""
|
||||||
|
refresh = RefreshToken.for_user(self.user)
|
||||||
|
payload = {"refresh": str(refresh)}
|
||||||
|
response = self.client.post(
|
||||||
|
self.url, data=json.dumps(payload), content_type="application/json"
|
||||||
|
)
|
||||||
|
self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests endpoints protégés – Session
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
)
|
||||||
|
class SessionEndpointTest(TestCase):
|
||||||
|
"""Tests de l'endpoint d'information de session."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.url = reverse("Auth:infoSession")
|
||||||
|
self.user = create_active_user_with_role(email="session@example.com")
|
||||||
|
|
||||||
|
def test_info_session_sans_token_retourne_401(self):
|
||||||
|
"""GET /Auth/infoSession sans token doit retourner 401."""
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_info_session_avec_token_valide_retourne_200(self):
|
||||||
|
"""GET /Auth/infoSession avec token valide doit retourner 200 et les données utilisateur."""
|
||||||
|
token = get_jwt_token(self.user)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
data = response.json()
|
||||||
|
self.assertIn("user", data)
|
||||||
|
self.assertEqual(data["user"]["email"], self.user.email)
|
||||||
|
|
||||||
|
def test_info_session_avec_token_invalide_retourne_401(self):
|
||||||
|
"""GET /Auth/infoSession avec token invalide doit retourner 401."""
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION="Bearer token.invalide.xyz")
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_info_session_avec_token_expire_retourne_401(self):
|
||||||
|
"""GET /Auth/infoSession avec un token expiré doit retourner 401."""
|
||||||
|
import jwt
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
expired_payload = {
|
||||||
|
'user_id': self.user.id,
|
||||||
|
'exp': datetime.utcnow() - timedelta(hours=1),
|
||||||
|
}
|
||||||
|
expired_token = jwt.encode(
|
||||||
|
expired_payload, django_settings.SECRET_KEY, algorithm='HS256'
|
||||||
|
)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {expired_token}")
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests endpoints protégés – Profils
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
REST_FRAMEWORK={
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
),
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
class ProfileEndpointAuthTest(TestCase):
|
||||||
|
"""Tests d'authentification sur les endpoints de profils."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.profiles_url = reverse("Auth:profile")
|
||||||
|
self.user = create_active_user_with_role(email="profile_auth@example.com")
|
||||||
|
|
||||||
|
def test_get_profiles_sans_auth_retourne_401(self):
|
||||||
|
"""GET /Auth/profiles sans token doit retourner 401."""
|
||||||
|
response = self.client.get(self.profiles_url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_get_profiles_avec_auth_retourne_200(self):
|
||||||
|
"""GET /Auth/profiles avec token valide doit retourner 200."""
|
||||||
|
token = get_jwt_token(self.user)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = self.client.get(self.profiles_url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_post_profile_sans_auth_retourne_401(self):
|
||||||
|
"""POST /Auth/profiles sans token doit retourner 401."""
|
||||||
|
payload = {"email": "new@example.com", "password": "pass123"}
|
||||||
|
response = self.client.post(
|
||||||
|
self.profiles_url,
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_get_profile_par_id_sans_auth_retourne_401(self):
|
||||||
|
"""GET /Auth/profiles/{id} sans token doit retourner 401."""
|
||||||
|
url = reverse("Auth:profile", kwargs={"id": self.user.id})
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_put_profile_sans_auth_retourne_401(self):
|
||||||
|
"""PUT /Auth/profiles/{id} sans token doit retourner 401."""
|
||||||
|
url = reverse("Auth:profile", kwargs={"id": self.user.id})
|
||||||
|
payload = {"email": self.user.email}
|
||||||
|
response = self.client.put(
|
||||||
|
url, data=json.dumps(payload), content_type="application/json"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_delete_profile_sans_auth_retourne_401(self):
|
||||||
|
"""DELETE /Auth/profiles/{id} sans token doit retourner 401."""
|
||||||
|
url = reverse("Auth:profile", kwargs={"id": self.user.id})
|
||||||
|
response = self.client.delete(url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests endpoints protégés – ProfileRole
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
REST_FRAMEWORK={
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
),
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
class ProfileRoleEndpointAuthTest(TestCase):
|
||||||
|
"""Tests d'authentification sur les endpoints de rôles."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.profile_roles_url = reverse("Auth:profileRoles")
|
||||||
|
self.user = create_active_user_with_role(email="roles_auth@example.com")
|
||||||
|
|
||||||
|
def test_get_profile_roles_sans_auth_retourne_401(self):
|
||||||
|
"""GET /Auth/profileRoles sans token doit retourner 401."""
|
||||||
|
response = self.client.get(self.profile_roles_url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_get_profile_roles_avec_auth_retourne_200(self):
|
||||||
|
"""GET /Auth/profileRoles avec token valide doit retourner 200."""
|
||||||
|
token = get_jwt_token(self.user)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = self.client.get(self.profile_roles_url)
|
||||||
|
self.assertNotIn(
|
||||||
|
response.status_code,
|
||||||
|
[status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN],
|
||||||
|
msg="Un token valide ne doit pas être rejeté par la couche d'authentification",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_post_profile_role_sans_auth_retourne_401(self):
|
||||||
|
"""POST /Auth/profileRoles sans token doit retourner 401."""
|
||||||
|
payload = {"profile": self.user.id, "role_type": 1}
|
||||||
|
response = self.client.post(
|
||||||
|
self.profile_roles_url,
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests de génération de token JWT
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
)
|
||||||
|
class JWTTokenGenerationTest(TestCase):
|
||||||
|
"""Tests de génération et validation des tokens JWT."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = create_user(email="jwt@example.com", password="jwttest123")
|
||||||
|
|
||||||
|
def test_generation_token_valide(self):
|
||||||
|
"""Un token généré pour un utilisateur est valide et contient user_id."""
|
||||||
|
import jwt
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
token = get_jwt_token(self.user)
|
||||||
|
self.assertIsNotNone(token)
|
||||||
|
self.assertIsInstance(token, str)
|
||||||
|
decoded = jwt.decode(token, django_settings.SECRET_KEY, algorithms=["HS256"])
|
||||||
|
self.assertEqual(decoded["user_id"], self.user.id)
|
||||||
|
|
||||||
|
def test_refresh_token_permet_obtenir_nouvel_access_token(self):
|
||||||
|
"""Le refresh token permet d'obtenir un nouvel access token via SimpleJWT."""
|
||||||
|
refresh = RefreshToken.for_user(self.user)
|
||||||
|
access = refresh.access_token
|
||||||
|
self.assertIsNotNone(str(access))
|
||||||
|
self.assertIsNotNone(str(refresh))
|
||||||
|
|
||||||
|
def test_token_different_par_utilisateur(self):
|
||||||
|
"""Deux utilisateurs différents ont des tokens différents."""
|
||||||
|
user2 = create_user(email="jwt2@example.com", password="jwttest123")
|
||||||
|
token1 = get_jwt_token(self.user)
|
||||||
|
token2 = get_jwt_token(user2)
|
||||||
|
self.assertNotEqual(token1, token2)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests de sécurité — Correction des vulnérabilités identifiées
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
)
|
||||||
|
class SessionViewTokenTypeTest(TestCase):
|
||||||
|
"""
|
||||||
|
SessionView doit rejeter les refresh tokens.
|
||||||
|
Avant la correction, jwt.decode() était appelé sans vérification du claim 'type',
|
||||||
|
ce qui permettait d'utiliser un refresh token là où seul un access token est attendu.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.url = reverse("Auth:infoSession")
|
||||||
|
self.user = create_active_user_with_role(email="session_type@example.com")
|
||||||
|
|
||||||
|
def test_refresh_token_rejete_par_session_view(self):
|
||||||
|
"""
|
||||||
|
Utiliser un refresh token SimpleJWT sur /infoSession doit retourner 401.
|
||||||
|
"""
|
||||||
|
import jwt
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
# Fabriquer manuellement un token de type 'refresh' signé avec la clé correcte
|
||||||
|
refresh_payload = {
|
||||||
|
'user_id': self.user.id,
|
||||||
|
'type': 'refresh', # ← type incorrect pour cet endpoint
|
||||||
|
'jti': 'test-refresh-jti',
|
||||||
|
'exp': datetime.utcnow() + timedelta(days=1),
|
||||||
|
'iat': datetime.utcnow(),
|
||||||
|
}
|
||||||
|
refresh_token = jwt.encode(
|
||||||
|
refresh_payload, django_settings.SECRET_KEY, algorithm='HS256'
|
||||||
|
)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {refresh_token}")
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(
|
||||||
|
response.status_code, status.HTTP_401_UNAUTHORIZED,
|
||||||
|
"Un refresh token ne doit pas être accepté sur /infoSession (OWASP A07 - Auth Failures)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_access_token_accepte_par_session_view(self):
|
||||||
|
"""Un access token de type 'access' est accepté."""
|
||||||
|
import jwt
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
|
||||||
|
access_payload = {
|
||||||
|
'user_id': self.user.id,
|
||||||
|
'type': 'access',
|
||||||
|
'jti': 'test-access-jti',
|
||||||
|
'exp': datetime.utcnow() + timedelta(minutes=15),
|
||||||
|
'iat': datetime.utcnow(),
|
||||||
|
}
|
||||||
|
access_token = jwt.encode(
|
||||||
|
access_payload, django_settings.SECRET_KEY, algorithm='HS256'
|
||||||
|
)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}")
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
)
|
||||||
|
class RefreshJWTErrorLeakTest(TestCase):
|
||||||
|
"""
|
||||||
|
RefreshJWTView ne doit pas retourner les messages d'exception internes.
|
||||||
|
Avant la correction, str(e) était renvoyé directement au client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.url = reverse("Auth:refresh_jwt")
|
||||||
|
|
||||||
|
def test_token_invalide_ne_revele_pas_details_internes(self):
|
||||||
|
"""
|
||||||
|
Un token invalide doit retourner un message générique, pas les détails de l'exception.
|
||||||
|
"""
|
||||||
|
payload = {"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.forged.signature"}
|
||||||
|
response = self.client.post(
|
||||||
|
self.url, data=json.dumps(payload), content_type="application/json"
|
||||||
|
)
|
||||||
|
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
|
||||||
|
body = response.content.decode()
|
||||||
|
# Le message ne doit pas contenir de traceback ou de détails internes de bibliothèque
|
||||||
|
self.assertNotIn("Traceback", body)
|
||||||
|
self.assertNotIn("jwt.exceptions", body)
|
||||||
|
self.assertNotIn("simplejwt", body.lower())
|
||||||
|
|
||||||
|
def test_erreur_reponse_est_generique(self):
|
||||||
|
"""
|
||||||
|
Le message d'erreur doit être 'Token invalide' (générique), pas le str(e).
|
||||||
|
"""
|
||||||
|
payload = {"refresh": "bad.token.data"}
|
||||||
|
response = self.client.post(
|
||||||
|
self.url, data=json.dumps(payload), content_type="application/json"
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
self.assertIn('errorMessage', data)
|
||||||
|
# Le message doit être le message générique, pas la chaîne brute de l'exception
|
||||||
|
self.assertIn(data['errorMessage'], ['Token invalide', 'Format de token invalide',
|
||||||
|
'Refresh token expiré', 'Erreur interne du serveur'])
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
)
|
||||||
|
class SecurityHeadersTest(TestCase):
|
||||||
|
"""
|
||||||
|
Les en-têtes de sécurité HTTP doivent être présents dans toutes les réponses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def test_x_content_type_options_present(self):
|
||||||
|
"""X-Content-Type-Options: nosniff doit être présent."""
|
||||||
|
response = self.client.get(reverse("Auth:csrf"))
|
||||||
|
self.assertEqual(
|
||||||
|
response.get('X-Content-Type-Options'), 'nosniff',
|
||||||
|
"X-Content-Type-Options: nosniff doit être défini (prévient le MIME sniffing)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_referrer_policy_present(self):
|
||||||
|
"""Referrer-Policy doit être présent."""
|
||||||
|
response = self.client.get(reverse("Auth:csrf"))
|
||||||
|
self.assertIsNotNone(
|
||||||
|
response.get('Referrer-Policy'),
|
||||||
|
"Referrer-Policy doit être défini"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_csp_frame_ancestors_present(self):
|
||||||
|
"""Content-Security-Policy doit contenir frame-ancestors."""
|
||||||
|
response = self.client.get(reverse("Auth:csrf"))
|
||||||
|
csp = response.get('Content-Security-Policy', '')
|
||||||
|
self.assertIn('frame-ancestors', csp,
|
||||||
|
"CSP doit définir frame-ancestors (protection clickjacking)")
|
||||||
|
self.assertIn("object-src 'none'", csp,
|
||||||
|
"CSP doit définir object-src 'none' (prévient les plugins malveillants)")
|
||||||
|
|
||||||
@ -17,10 +17,12 @@ from datetime import datetime, timedelta
|
|||||||
import jwt
|
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(
|
||||||
@ -236,17 +270,18 @@ def makeToken(user):
|
|||||||
"establishment__name": role.establishment.name,
|
"establishment__name": role.establishment.name,
|
||||||
"establishment__evaluation_frequency": role.establishment.evaluation_frequency,
|
"establishment__evaluation_frequency": role.establishment.evaluation_frequency,
|
||||||
"establishment__total_capacity": role.establishment.total_capacity,
|
"establishment__total_capacity": role.establishment.total_capacity,
|
||||||
"establishment__api_docuseal": role.establishment.api_docuseal,
|
|
||||||
"establishment__logo": logo_url,
|
"establishment__logo": logo_url,
|
||||||
})
|
})
|
||||||
|
|
||||||
# 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(),
|
||||||
}
|
}
|
||||||
@ -256,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(
|
||||||
@ -291,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")
|
||||||
@ -336,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=[
|
||||||
@ -431,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(
|
||||||
@ -480,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(
|
||||||
@ -526,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)}
|
||||||
@ -597,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}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
# 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
|
||||||
|
|||||||
@ -1,3 +1,145 @@
|
|||||||
from django.test import TestCase
|
"""
|
||||||
|
Tests unitaires pour le module Common.
|
||||||
|
Vérifie que les endpoints Domain et Category requièrent une authentification JWT.
|
||||||
|
"""
|
||||||
|
|
||||||
# Create your tests here.
|
import json
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
from Auth.models import Profile
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(email="common_test@example.com", password="testpassword123"):
|
||||||
|
return Profile.objects.create_user(username=email, email=email, password=password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_jwt_token(user):
|
||||||
|
refresh = RefreshToken.for_user(user)
|
||||||
|
return str(refresh.access_token)
|
||||||
|
|
||||||
|
|
||||||
|
TEST_REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
),
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Domain
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CACHES=TEST_CACHES,
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
|
||||||
|
)
|
||||||
|
class DomainEndpointAuthTest(TestCase):
|
||||||
|
"""Tests d'authentification sur les endpoints Domain."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.list_url = reverse("Common:domain_list_create")
|
||||||
|
self.user = create_user()
|
||||||
|
|
||||||
|
def test_get_domains_sans_auth_retourne_401(self):
|
||||||
|
"""GET /Common/domains sans token doit retourner 401."""
|
||||||
|
response = self.client.get(self.list_url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_post_domain_sans_auth_retourne_401(self):
|
||||||
|
"""POST /Common/domains sans token doit retourner 401."""
|
||||||
|
response = self.client.post(
|
||||||
|
self.list_url,
|
||||||
|
data=json.dumps({"name": "Musique"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_get_domains_avec_auth_retourne_200(self):
|
||||||
|
"""GET /Common/domains avec token valide doit retourner 200."""
|
||||||
|
token = get_jwt_token(self.user)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = self.client.get(self.list_url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_put_domain_sans_auth_retourne_401(self):
|
||||||
|
"""PUT /Common/domains/{id} sans token doit retourner 401."""
|
||||||
|
url = reverse("Common:domain_detail", kwargs={"id": 1})
|
||||||
|
response = self.client.put(
|
||||||
|
url,
|
||||||
|
data=json.dumps({"name": "Danse"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_delete_domain_sans_auth_retourne_401(self):
|
||||||
|
"""DELETE /Common/domains/{id} sans token doit retourner 401."""
|
||||||
|
url = reverse("Common:domain_detail", kwargs={"id": 1})
|
||||||
|
response = self.client.delete(url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Category
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CACHES=TEST_CACHES,
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
|
||||||
|
)
|
||||||
|
class CategoryEndpointAuthTest(TestCase):
|
||||||
|
"""Tests d'authentification sur les endpoints Category."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.list_url = reverse("Common:category_list_create")
|
||||||
|
self.user = create_user(email="category_test@example.com")
|
||||||
|
|
||||||
|
def test_get_categories_sans_auth_retourne_401(self):
|
||||||
|
"""GET /Common/categories sans token doit retourner 401."""
|
||||||
|
response = self.client.get(self.list_url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_post_category_sans_auth_retourne_401(self):
|
||||||
|
"""POST /Common/categories sans token doit retourner 401."""
|
||||||
|
response = self.client.post(
|
||||||
|
self.list_url,
|
||||||
|
data=json.dumps({"name": "Jazz"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_get_categories_avec_auth_retourne_200(self):
|
||||||
|
"""GET /Common/categories avec token valide doit retourner 200."""
|
||||||
|
token = get_jwt_token(self.user)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = self.client.get(self.list_url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_put_category_sans_auth_retourne_401(self):
|
||||||
|
"""PUT /Common/categories/{id} sans token doit retourner 401."""
|
||||||
|
url = reverse("Common:category_detail", kwargs={"id": 1})
|
||||||
|
response = self.client.put(
|
||||||
|
url,
|
||||||
|
data=json.dumps({"name": "Classique"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_delete_category_sans_auth_retourne_401(self):
|
||||||
|
"""DELETE /Common/categories/{id} sans token doit retourner 401."""
|
||||||
|
url = reverse("Common:category_detail", kwargs={"id": 1})
|
||||||
|
response = self.client.delete(url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|||||||
@ -4,18 +4,21 @@ from django.utils.decorators import method_decorator
|
|||||||
from rest_framework.parsers import JSONParser
|
from rest_framework.parsers import JSONParser
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from .models import (
|
from .models import (
|
||||||
Domain,
|
Domain,
|
||||||
Category
|
Category
|
||||||
)
|
)
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
DomainSerializer,
|
DomainSerializer,
|
||||||
CategorySerializer
|
CategorySerializer
|
||||||
)
|
)
|
||||||
|
|
||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class DomainListCreateView(APIView):
|
class DomainListCreateView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
domains = Domain.objects.all()
|
domains = Domain.objects.all()
|
||||||
serializer = DomainSerializer(domains, many=True)
|
serializer = DomainSerializer(domains, many=True)
|
||||||
@ -32,6 +35,8 @@ class DomainListCreateView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class DomainDetailView(APIView):
|
class DomainDetailView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request, id):
|
def get(self, request, id):
|
||||||
try:
|
try:
|
||||||
domain = Domain.objects.get(id=id)
|
domain = Domain.objects.get(id=id)
|
||||||
@ -65,6 +70,8 @@ class DomainDetailView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class CategoryListCreateView(APIView):
|
class CategoryListCreateView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
categories = Category.objects.all()
|
categories = Category.objects.all()
|
||||||
serializer = CategorySerializer(categories, many=True)
|
serializer = CategorySerializer(categories, many=True)
|
||||||
@ -81,6 +88,8 @@ class CategoryListCreateView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class CategoryDetailView(APIView):
|
class CategoryDetailView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request, id):
|
def get(self, request, id):
|
||||||
try:
|
try:
|
||||||
category = Category.objects.get(id=id)
|
category = Category.objects.get(id=id)
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
# This file is intentionally left blank to make this directory a Python package.
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
from django.urls import path, re_path
|
|
||||||
from .views import generate_jwt_token, clone_template, remove_template, download_template
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
re_path(r'generateToken$', generate_jwt_token, name='generate_jwt_token'),
|
|
||||||
re_path(r'cloneTemplate$', clone_template, name='clone_template'),
|
|
||||||
re_path(r'removeTemplate/(?P<id>[0-9]+)$', remove_template, name='remove_template'),
|
|
||||||
re_path(r'downloadTemplate/(?P<slug>[\w-]+)$', download_template, name='download_template')
|
|
||||||
]
|
|
||||||
@ -1,200 +0,0 @@
|
|||||||
from django.conf import settings
|
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
|
||||||
from rest_framework.decorators import api_view
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework import status
|
|
||||||
import jwt
|
|
||||||
import datetime
|
|
||||||
import requests
|
|
||||||
from Establishment.models import Establishment
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@api_view(['POST'])
|
|
||||||
def generate_jwt_token(request):
|
|
||||||
# Récupérer l'établissement concerné (par ID ou autre info transmise)
|
|
||||||
establishment_id = request.data.get('establishment_id')
|
|
||||||
if not establishment_id:
|
|
||||||
return Response({'error': 'establishment_id requis'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
try:
|
|
||||||
establishment = Establishment.objects.get(id=establishment_id)
|
|
||||||
except Establishment.DoesNotExist:
|
|
||||||
return Response({'error': "Établissement introuvable"}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
# Vérifier la clé API reçue dans le header
|
|
||||||
api_key = request.headers.get('X-Auth-Token')
|
|
||||||
if not api_key or not establishment.api_docuseal or api_key != establishment.api_docuseal:
|
|
||||||
return Response({'error': 'Clé API invalide'}, status=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
|
|
||||||
# Récupérer les données de la requête
|
|
||||||
user_email = request.data.get('user_email')
|
|
||||||
documents_urls = request.data.get('documents_urls', [])
|
|
||||||
template_id = request.data.get('id')
|
|
||||||
|
|
||||||
if not user_email:
|
|
||||||
return Response({'error': 'User email is required'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
# Utiliser la clé API de l'établissement comme secret JWT
|
|
||||||
jwt_secret = establishment.api_docuseal
|
|
||||||
jwt_algorithm = settings.DOCUSEAL_JWT['ALGORITHM']
|
|
||||||
expiration_delta = settings.DOCUSEAL_JWT['EXPIRATION_DELTA']
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
'user_email': user_email,
|
|
||||||
'documents_urls': documents_urls,
|
|
||||||
'template_id': template_id,
|
|
||||||
'exp': datetime.datetime.utcnow() + expiration_delta
|
|
||||||
}
|
|
||||||
|
|
||||||
token = jwt.encode(payload, jwt_secret, algorithm=jwt_algorithm)
|
|
||||||
return Response({'token': token}, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@api_view(['POST'])
|
|
||||||
def clone_template(request):
|
|
||||||
# Récupérer l'établissement concerné
|
|
||||||
establishment_id = request.data.get('establishment_id')
|
|
||||||
print(f"establishment_id : {establishment_id}")
|
|
||||||
if not establishment_id:
|
|
||||||
return Response({'error': 'establishment_id requis'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
try:
|
|
||||||
establishment = Establishment.objects.get(id=establishment_id)
|
|
||||||
except Establishment.DoesNotExist:
|
|
||||||
return Response({'error': "Établissement introuvable"}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
# Vérifier la clé API reçue dans le header
|
|
||||||
api_key = request.headers.get('X-Auth-Token')
|
|
||||||
if not api_key or not establishment.api_docuseal or api_key != establishment.api_docuseal:
|
|
||||||
return Response({'error': 'Clé API invalide'}, status=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
|
|
||||||
# Récupérer les données de la requête
|
|
||||||
document_id = request.data.get('templateId')
|
|
||||||
email = request.data.get('email')
|
|
||||||
is_required = request.data.get('is_required')
|
|
||||||
|
|
||||||
# Vérifier les données requises
|
|
||||||
if not document_id:
|
|
||||||
return Response({'error': 'template ID is required'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
# URL de l'API de DocuSeal pour cloner le template
|
|
||||||
clone_url = f'https://docuseal.com/api/templates/{document_id}/clone'
|
|
||||||
|
|
||||||
# Faire la requête pour cloner le template
|
|
||||||
try:
|
|
||||||
response = requests.post(clone_url, headers={
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Auth-Token': establishment.api_docuseal
|
|
||||||
})
|
|
||||||
|
|
||||||
if response.status_code != status.HTTP_200_OK:
|
|
||||||
return Response({'error': 'Failed to clone template'}, status=response.status_code)
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if is_required:
|
|
||||||
# URL de l'API de DocuSeal pour créer une submission
|
|
||||||
submission_url = f'https://docuseal.com/api/submissions'
|
|
||||||
|
|
||||||
try:
|
|
||||||
clone_id = data['id']
|
|
||||||
response = requests.post(submission_url, json={
|
|
||||||
'template_id': clone_id,
|
|
||||||
'send_email': False,
|
|
||||||
'submitters': [{'email': email}]
|
|
||||||
}, headers={
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Auth-Token': establishment.api_docuseal
|
|
||||||
})
|
|
||||||
|
|
||||||
if response.status_code != status.HTTP_200_OK:
|
|
||||||
return Response({'error': 'Failed to create submission'}, status=response.status_code)
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
data[0]['id'] = clone_id
|
|
||||||
return Response(data[0], status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
except requests.RequestException as e:
|
|
||||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
else:
|
|
||||||
print(f'NOT REQUIRED -> on ne crée pas de submission')
|
|
||||||
return Response(data, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
except requests.RequestException as e:
|
|
||||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@api_view(['DELETE'])
|
|
||||||
def remove_template(request, id):
|
|
||||||
# Récupérer l'établissement concerné
|
|
||||||
establishment_id = request.GET.get('establishment_id')
|
|
||||||
if not establishment_id:
|
|
||||||
return Response({'error': 'establishment_id requis'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
try:
|
|
||||||
establishment = Establishment.objects.get(id=establishment_id)
|
|
||||||
except Establishment.DoesNotExist:
|
|
||||||
return Response({'error': "Établissement introuvable"}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
# Vérifier la clé API reçue dans le header
|
|
||||||
api_key = request.headers.get('X-Auth-Token')
|
|
||||||
if not api_key or not establishment.api_docuseal or api_key != establishment.api_docuseal:
|
|
||||||
return Response({'error': 'Clé API invalide'}, status=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
|
|
||||||
# URL de l'API de DocuSeal pour supprimer le template
|
|
||||||
|
|
||||||
clone_url = f'https://docuseal.com/api/templates/{id}'
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = requests.delete(clone_url, headers={
|
|
||||||
'X-Auth-Token': establishment.api_docuseal
|
|
||||||
})
|
|
||||||
|
|
||||||
if response.status_code != status.HTTP_200_OK:
|
|
||||||
return Response({'error': 'Failed to remove template'}, status=response.status_code)
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
return Response(data, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
except requests.RequestException as e:
|
|
||||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@api_view(['GET'])
|
|
||||||
def download_template(request, slug):
|
|
||||||
# Récupérer l'établissement concerné
|
|
||||||
establishment_id = request.GET.get('establishment_id')
|
|
||||||
if not establishment_id:
|
|
||||||
return Response({'error': 'establishment_id requis'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
try:
|
|
||||||
establishment = Establishment.objects.get(id=establishment_id)
|
|
||||||
except Establishment.DoesNotExist:
|
|
||||||
return Response({'error': "Établissement introuvable"}, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
# Vérifier la clé API reçue dans le header
|
|
||||||
api_key = request.headers.get('X-Auth-Token')
|
|
||||||
if not api_key or not establishment.api_docuseal or api_key != establishment.api_docuseal:
|
|
||||||
return Response({'error': 'Clé API invalide'}, status=status.HTTP_401_UNAUTHORIZED)
|
|
||||||
|
|
||||||
# Vérifier les données requises
|
|
||||||
if not slug:
|
|
||||||
return Response({'error': 'slug is required'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
# URL de l'API de DocuSeal pour télécharger le template
|
|
||||||
download_url = f'https://docuseal.com/submitters/{slug}/download'
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = requests.get(download_url, headers={
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Auth-Token': establishment.api_docuseal
|
|
||||||
})
|
|
||||||
|
|
||||||
if response.status_code != status.HTTP_200_OK:
|
|
||||||
return Response({'error': 'Failed to download template'}, status=response.status_code)
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
return Response(data, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
except requests.RequestException as e:
|
|
||||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
@ -1,5 +1,6 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
# Generated by Django 5.1.3 on 2026-03-14 13:23
|
||||||
|
|
||||||
|
import Establishment.models
|
||||||
import django.contrib.postgres.fields
|
import django.contrib.postgres.fields
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
@ -24,6 +25,7 @@ class Migration(migrations.Migration):
|
|||||||
('licence_code', models.CharField(blank=True, max_length=100)),
|
('licence_code', models.CharField(blank=True, max_length=100)),
|
||||||
('is_active', models.BooleanField(default=True)),
|
('is_active', models.BooleanField(default=True)),
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('logo', models.FileField(blank=True, null=True, upload_to=Establishment.models.registration_logo_upload_to)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-30 07:39
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('Establishment', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='establishment',
|
|
||||||
name='api_docuseal',
|
|
||||||
field=models.CharField(blank=True, max_length=255, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-31 09:56
|
|
||||||
|
|
||||||
import Establishment.models
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('Establishment', '0002_establishment_api_docuseal'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='establishment',
|
|
||||||
name='logo',
|
|
||||||
field=models.FileField(blank=True, null=True, upload_to=Establishment.models.registration_logo_upload_to),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -27,7 +27,6 @@ class Establishment(models.Model):
|
|||||||
licence_code = models.CharField(max_length=100, blank=True)
|
licence_code = models.CharField(max_length=100, blank=True)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
api_docuseal = models.CharField(max_length=255, blank=True, null=True)
|
|
||||||
logo = models.FileField(
|
logo = models.FileField(
|
||||||
upload_to=registration_logo_upload_to,
|
upload_to=registration_logo_upload_to,
|
||||||
null=True,
|
null=True,
|
||||||
|
|||||||
92
Back-End/Establishment/tests.py
Normal file
92
Back-End/Establishment/tests.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
Tests unitaires pour le module Establishment.
|
||||||
|
Vérifie que les endpoints requièrent une authentification JWT.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
from Auth.models import Profile
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(email="establishment_test@example.com", password="testpassword123"):
|
||||||
|
return Profile.objects.create_user(username=email, email=email, password=password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_jwt_token(user):
|
||||||
|
refresh = RefreshToken.for_user(user)
|
||||||
|
return str(refresh.access_token)
|
||||||
|
|
||||||
|
|
||||||
|
TEST_REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
),
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CACHES=TEST_CACHES,
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
|
||||||
|
)
|
||||||
|
class EstablishmentEndpointAuthTest(TestCase):
|
||||||
|
"""Tests d'authentification sur les endpoints Establishment."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.list_url = reverse("Establishment:establishment_list_create")
|
||||||
|
self.user = create_user()
|
||||||
|
|
||||||
|
def test_get_establishments_sans_auth_retourne_401(self):
|
||||||
|
"""GET /Establishment/establishments sans token doit retourner 401."""
|
||||||
|
response = self.client.get(self.list_url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_post_establishment_sans_auth_retourne_401(self):
|
||||||
|
"""POST /Establishment/establishments sans token doit retourner 401."""
|
||||||
|
import json
|
||||||
|
response = self.client.post(
|
||||||
|
self.list_url,
|
||||||
|
data=json.dumps({"name": "Ecole Alpha"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_get_establishment_detail_sans_auth_retourne_401(self):
|
||||||
|
"""GET /Establishment/establishments/{id} sans token doit retourner 401."""
|
||||||
|
url = reverse("Establishment:establishment_detail", kwargs={"id": 1})
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_put_establishment_sans_auth_retourne_401(self):
|
||||||
|
"""PUT /Establishment/establishments/{id} sans token doit retourner 401."""
|
||||||
|
import json
|
||||||
|
url = reverse("Establishment:establishment_detail", kwargs={"id": 1})
|
||||||
|
response = self.client.put(
|
||||||
|
url,
|
||||||
|
data=json.dumps({"name": "Ecole Beta"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_delete_establishment_sans_auth_retourne_401(self):
|
||||||
|
"""DELETE /Establishment/establishments/{id} sans token doit retourner 401."""
|
||||||
|
url = reverse("Establishment:establishment_detail", kwargs={"id": 1})
|
||||||
|
response = self.client.delete(url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_get_establishments_avec_auth_retourne_200(self):
|
||||||
|
"""GET /Establishment/establishments avec token valide doit retourner 200."""
|
||||||
|
token = get_jwt_token(self.user)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = self.client.get(self.list_url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
@ -4,6 +4,7 @@ from django.utils.decorators import method_decorator
|
|||||||
from rest_framework.parsers import JSONParser, MultiPartParser, FormParser
|
from rest_framework.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(
|
||||||
|
|||||||
116
Back-End/GestionEmail/tests_security.py
Normal file
116
Back-End/GestionEmail/tests_security.py
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
Tests de sécurité — GestionEmail
|
||||||
|
Vérifie :
|
||||||
|
- search_recipients nécessite une authentification (plus accessible anonymement)
|
||||||
|
- send-email nécessite une authentification
|
||||||
|
- Les données personnelles ne sont pas dans les logs INFO
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
from Auth.models import Profile, ProfileRole
|
||||||
|
from Establishment.models import Establishment
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def create_user_with_role(email, password="TestPass!123"):
|
||||||
|
user = Profile.objects.create_user(
|
||||||
|
username=email, email=email, password=password
|
||||||
|
)
|
||||||
|
est = Establishment.objects.create(
|
||||||
|
name=f"Ecole {email}",
|
||||||
|
address="1 rue Test",
|
||||||
|
total_capacity=50,
|
||||||
|
establishment_type=[1],
|
||||||
|
)
|
||||||
|
ProfileRole.objects.create(
|
||||||
|
profile=user,
|
||||||
|
role_type=ProfileRole.RoleType.PROFIL_ECOLE,
|
||||||
|
establishment=est,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
return user, est
|
||||||
|
|
||||||
|
|
||||||
|
OVERRIDE = dict(
|
||||||
|
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests : search_recipients exige une authentification
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE)
|
||||||
|
class SearchRecipientsAuthTest(TestCase):
|
||||||
|
"""
|
||||||
|
GET /email/search-recipients/ doit retourner 401 si non authentifié.
|
||||||
|
Avant la correction, cet endpoint était accessible anonymement
|
||||||
|
(harvesting d'emails des membres d'un établissement).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.url = reverse('GestionEmail:search_recipients')
|
||||||
|
|
||||||
|
def test_sans_auth_retourne_401(self):
|
||||||
|
"""Accès anonyme doit être rejeté avec 401."""
|
||||||
|
response = self.client.get(self.url, {'q': 'test', 'establishment_id': 1})
|
||||||
|
self.assertEqual(
|
||||||
|
response.status_code, status.HTTP_401_UNAUTHORIZED,
|
||||||
|
"search_recipients doit exiger une authentification (OWASP A01 - Broken Access Control)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_avec_auth_et_query_vide_retourne_200_ou_liste_vide(self):
|
||||||
|
"""Un utilisateur authentifié sans terme de recherche reçoit une liste vide."""
|
||||||
|
user, est = create_user_with_role('search_auth@test.com')
|
||||||
|
token = str(RefreshToken.for_user(user).access_token)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||||
|
response = self.client.get(self.url, {'q': '', 'establishment_id': est.id})
|
||||||
|
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
|
||||||
|
|
||||||
|
def test_avec_auth_et_establishment_manquant_retourne_400(self):
|
||||||
|
"""Un utilisateur authentifié sans establishment_id reçoit 400."""
|
||||||
|
user, _ = create_user_with_role('search_noest@test.com')
|
||||||
|
token = str(RefreshToken.for_user(user).access_token)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||||
|
response = self.client.get(self.url, {'q': 'alice'})
|
||||||
|
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests : send-email exige une authentification
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE)
|
||||||
|
class SendEmailAuthTest(TestCase):
|
||||||
|
"""
|
||||||
|
POST /email/send-email/ doit retourner 401 si non authentifié.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.url = reverse('GestionEmail:send_email')
|
||||||
|
|
||||||
|
def test_sans_auth_retourne_401(self):
|
||||||
|
"""Accès anonyme à l'envoi d'email doit être rejeté."""
|
||||||
|
payload = {
|
||||||
|
'recipients': ['victim@example.com'],
|
||||||
|
'subject': 'Test',
|
||||||
|
'message': 'Hello',
|
||||||
|
'establishment_id': 1,
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
self.url, data=json.dumps(payload), content_type='application/json'
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
@ -2,6 +2,8 @@ from django.http.response import JsonResponse
|
|||||||
from rest_framework.views import APIView
|
from rest_framework.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.
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
# 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 uuid
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
@ -14,6 +16,39 @@ class Migration(migrations.Migration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Conversation',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('name', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
('conversation_type', models.CharField(choices=[('private', 'Privée'), ('group', 'Groupe')], default='private', max_length=10)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('last_activity', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Message',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('content', models.TextField()),
|
||||||
|
('message_type', models.CharField(choices=[('text', 'Texte'), ('file', 'Fichier'), ('image', 'Image'), ('system', 'Système')], default='text', max_length=10)),
|
||||||
|
('file_url', models.URLField(blank=True, null=True)),
|
||||||
|
('file_name', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
('file_size', models.BigIntegerField(blank=True, null=True)),
|
||||||
|
('file_type', models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_edited', models.BooleanField(default=False)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='GestionMessagerie.conversation')),
|
||||||
|
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Messagerie',
|
name='Messagerie',
|
||||||
fields=[
|
fields=[
|
||||||
@ -27,4 +62,40 @@ class Migration(migrations.Migration):
|
|||||||
('emetteur', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='messages_envoyes', to=settings.AUTH_USER_MODEL)),
|
('emetteur', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='messages_envoyes', to=settings.AUTH_USER_MODEL)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserPresence',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('status', models.CharField(choices=[('online', 'En ligne'), ('away', 'Absent'), ('busy', 'Occupé'), ('offline', 'Hors ligne')], default='offline', max_length=10)),
|
||||||
|
('last_seen', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('is_typing_in', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='typing_users', to='GestionMessagerie.conversation')),
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='presence', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ConversationParticipant',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('joined_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('last_read_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='GestionMessagerie.conversation')),
|
||||||
|
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conversation_participants', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('conversation', 'participant')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MessageRead',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('read_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_by', to='GestionMessagerie.message')),
|
||||||
|
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_messages', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('message', 'participant')},
|
||||||
|
},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,87 +0,0 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-30 07:40
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import django.utils.timezone
|
|
||||||
import uuid
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('GestionMessagerie', '0001_initial'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Conversation',
|
|
||||||
fields=[
|
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
||||||
('name', models.CharField(blank=True, max_length=255, null=True)),
|
|
||||||
('conversation_type', models.CharField(choices=[('private', 'Privée'), ('group', 'Groupe')], default='private', max_length=10)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('last_activity', models.DateTimeField(default=django.utils.timezone.now)),
|
|
||||||
('is_active', models.BooleanField(default=True)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Message',
|
|
||||||
fields=[
|
|
||||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
||||||
('content', models.TextField()),
|
|
||||||
('message_type', models.CharField(choices=[('text', 'Texte'), ('file', 'Fichier'), ('image', 'Image'), ('system', 'Système')], default='text', max_length=10)),
|
|
||||||
('file_url', models.URLField(blank=True, null=True)),
|
|
||||||
('file_name', models.CharField(blank=True, max_length=255, null=True)),
|
|
||||||
('file_size', models.BigIntegerField(blank=True, null=True)),
|
|
||||||
('file_type', models.CharField(blank=True, max_length=100, null=True)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('is_edited', models.BooleanField(default=False)),
|
|
||||||
('is_deleted', models.BooleanField(default=False)),
|
|
||||||
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='GestionMessagerie.conversation')),
|
|
||||||
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['created_at'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='UserPresence',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('status', models.CharField(choices=[('online', 'En ligne'), ('away', 'Absent'), ('busy', 'Occupé'), ('offline', 'Hors ligne')], default='offline', max_length=10)),
|
|
||||||
('last_seen', models.DateTimeField(default=django.utils.timezone.now)),
|
|
||||||
('is_typing_in', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='typing_users', to='GestionMessagerie.conversation')),
|
|
||||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='presence', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='ConversationParticipant',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('joined_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('last_read_at', models.DateTimeField(default=django.utils.timezone.now)),
|
|
||||||
('is_active', models.BooleanField(default=True)),
|
|
||||||
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='GestionMessagerie.conversation')),
|
|
||||||
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conversation_participants', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'unique_together': {('conversation', 'participant')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='MessageRead',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('read_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_by', to='GestionMessagerie.message')),
|
|
||||||
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_messages', to=settings.AUTH_USER_MODEL)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'unique_together': {('message', 'participant')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
130
Back-End/GestionMessagerie/tests.py
Normal file
130
Back-End/GestionMessagerie/tests.py
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
Tests unitaires pour le module GestionMessagerie.
|
||||||
|
Vérifie que les endpoints (conversations, messages, upload) requièrent une
|
||||||
|
authentification JWT.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
from Auth.models import Profile
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(email="messagerie_test@example.com", password="testpassword123"):
|
||||||
|
return Profile.objects.create_user(username=email, email=email, password=password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_jwt_token(user):
|
||||||
|
refresh = RefreshToken.for_user(user)
|
||||||
|
return str(refresh.access_token)
|
||||||
|
|
||||||
|
|
||||||
|
TEST_REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
),
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
|
||||||
|
|
||||||
|
OVERRIDE = dict(
|
||||||
|
CACHES=TEST_CACHES,
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
|
||||||
|
CHANNEL_LAYERS={'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE)
|
||||||
|
class ConversationListEndpointAuthTest(TestCase):
|
||||||
|
"""Tests d'authentification sur les endpoints de conversation."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.user = create_user()
|
||||||
|
|
||||||
|
def test_get_conversations_par_user_sans_auth_retourne_401(self):
|
||||||
|
"""GET /GestionMessagerie/conversations/user/{id}/ sans token doit retourner 401."""
|
||||||
|
url = reverse("GestionMessagerie:conversations_by_user", kwargs={"user_id": 1})
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_post_create_conversation_sans_auth_retourne_401(self):
|
||||||
|
"""POST /GestionMessagerie/create-conversation/ sans token doit retourner 401."""
|
||||||
|
url = reverse("GestionMessagerie:create_conversation")
|
||||||
|
response = self.client.post(
|
||||||
|
url,
|
||||||
|
data=json.dumps({"participants": [1, 2]}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_post_send_message_sans_auth_retourne_401(self):
|
||||||
|
"""POST /GestionMessagerie/send-message/ sans token doit retourner 401."""
|
||||||
|
url = reverse("GestionMessagerie:send_message")
|
||||||
|
response = self.client.post(
|
||||||
|
url,
|
||||||
|
data=json.dumps({"content": "Bonjour"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_post_mark_as_read_sans_auth_retourne_401(self):
|
||||||
|
"""POST /GestionMessagerie/conversations/mark-as-read/ sans token doit retourner 401."""
|
||||||
|
url = reverse("GestionMessagerie:mark_as_read")
|
||||||
|
response = self.client.post(
|
||||||
|
url,
|
||||||
|
data=json.dumps({}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_get_search_recipients_sans_auth_retourne_401(self):
|
||||||
|
"""GET /GestionMessagerie/search-recipients/ sans token doit retourner 401."""
|
||||||
|
url = reverse("GestionMessagerie:search_recipients")
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_post_upload_file_sans_auth_retourne_401(self):
|
||||||
|
"""POST /GestionMessagerie/upload-file/ sans token doit retourner 401."""
|
||||||
|
url = reverse("GestionMessagerie:upload_file")
|
||||||
|
response = self.client.post(url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_delete_conversation_sans_auth_retourne_401(self):
|
||||||
|
"""DELETE /GestionMessagerie/conversations/{uuid}/ sans token doit retourner 401."""
|
||||||
|
import uuid as uuid_lib
|
||||||
|
conversation_id = uuid_lib.uuid4()
|
||||||
|
url = reverse(
|
||||||
|
"GestionMessagerie:delete_conversation",
|
||||||
|
kwargs={"conversation_id": conversation_id},
|
||||||
|
)
|
||||||
|
response = self.client.delete(url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_get_conversation_messages_sans_auth_retourne_401(self):
|
||||||
|
"""GET /GestionMessagerie/conversations/{uuid}/messages/ sans token doit retourner 401."""
|
||||||
|
import uuid as uuid_lib
|
||||||
|
conversation_id = uuid_lib.uuid4()
|
||||||
|
url = reverse(
|
||||||
|
"GestionMessagerie:conversation_messages",
|
||||||
|
kwargs={"conversation_id": conversation_id},
|
||||||
|
)
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_get_conversations_avec_auth_retourne_non_403(self):
|
||||||
|
"""GET avec token valide ne doit pas retourner 401/403."""
|
||||||
|
token = get_jwt_token(self.user)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
url = reverse("GestionMessagerie:conversations_by_user", kwargs={"user_id": self.user.id})
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertNotIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN])
|
||||||
274
Back-End/GestionMessagerie/tests_security.py
Normal file
274
Back-End/GestionMessagerie/tests_security.py
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
"""
|
||||||
|
Tests de sécurité — GestionMessagerie
|
||||||
|
Vérifie :
|
||||||
|
- Protection IDOR : un utilisateur ne peut pas lire/écrire au nom d'un autre
|
||||||
|
- Authentification requise sur tous les endpoints
|
||||||
|
- L'expéditeur d'un message est toujours l'utilisateur authentifié
|
||||||
|
- Le mark-as-read utilise request.user (pas user_id du body)
|
||||||
|
- L'upload de fichier utilise request.user (pas sender_id du body)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
from Auth.models import Profile, ProfileRole
|
||||||
|
from Establishment.models import Establishment
|
||||||
|
from GestionMessagerie.models import (
|
||||||
|
Conversation, ConversationParticipant, Message
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def create_establishment(name="Ecole Sécurité"):
|
||||||
|
return Establishment.objects.create(
|
||||||
|
name=name,
|
||||||
|
address="1 rue des Tests",
|
||||||
|
total_capacity=50,
|
||||||
|
establishment_type=[1],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(email, password="TestPass!123"):
|
||||||
|
user = Profile.objects.create_user(
|
||||||
|
username=email,
|
||||||
|
email=email,
|
||||||
|
password=password,
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def create_active_user(email, password="TestPass!123"):
|
||||||
|
user = create_user(email, password)
|
||||||
|
establishment = create_establishment(name=f"Ecole de {email}")
|
||||||
|
ProfileRole.objects.create(
|
||||||
|
profile=user,
|
||||||
|
role_type=ProfileRole.RoleType.PROFIL_ECOLE,
|
||||||
|
establishment=establishment,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_token(user):
|
||||||
|
return str(RefreshToken.for_user(user).access_token)
|
||||||
|
|
||||||
|
|
||||||
|
def create_conversation_with_participant(user1, user2):
|
||||||
|
"""Crée une conversation privée entre deux utilisateurs."""
|
||||||
|
conv = Conversation.objects.create(conversation_type='private')
|
||||||
|
ConversationParticipant.objects.create(
|
||||||
|
conversation=conv, participant=user1, is_active=True
|
||||||
|
)
|
||||||
|
ConversationParticipant.objects.create(
|
||||||
|
conversation=conv, participant=user2, is_active=True
|
||||||
|
)
|
||||||
|
return conv
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration commune
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
OVERRIDE = dict(
|
||||||
|
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
CHANNEL_LAYERS={'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests : authentification requise
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE)
|
||||||
|
class MessagerieAuthRequiredTest(TestCase):
|
||||||
|
"""Tous les endpoints de messagerie doivent rejeter les requêtes non authentifiées."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def test_conversations_sans_auth_retourne_401(self):
|
||||||
|
response = self.client.get(reverse('GestionMessagerie:conversations'))
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_send_message_sans_auth_retourne_401(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('GestionMessagerie:send_message'),
|
||||||
|
data=json.dumps({'conversation_id': str(uuid.uuid4()), 'content': 'Hello'}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_mark_as_read_sans_auth_retourne_401(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('GestionMessagerie:mark_as_read'),
|
||||||
|
data=json.dumps({'conversation_id': str(uuid.uuid4())}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_upload_file_sans_auth_retourne_401(self):
|
||||||
|
response = self.client.post(reverse('GestionMessagerie:upload_file'))
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests IDOR : liste des conversations (request.user ignorant l'URL user_id)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE)
|
||||||
|
class ConversationListIDORTest(TestCase):
|
||||||
|
"""
|
||||||
|
GET conversations/user/<user_id>/ doit retourner les conversations de
|
||||||
|
request.user, pas celles de l'utilisateur dont l'ID est dans l'URL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.alice = create_active_user('alice@test.com')
|
||||||
|
self.bob = create_active_user('bob@test.com')
|
||||||
|
self.carol = create_active_user('carol@test.com')
|
||||||
|
|
||||||
|
# Conversation entre Alice et Bob (Carol ne doit pas la voir)
|
||||||
|
self.conv_alice_bob = create_conversation_with_participant(self.alice, self.bob)
|
||||||
|
|
||||||
|
def test_carol_ne_voit_pas_les_conversations_de_alice(self):
|
||||||
|
"""
|
||||||
|
Carol s'authentifie mais passe alice.id dans l'URL.
|
||||||
|
Elle doit voir ses propres conversations (vides), pas celles d'Alice.
|
||||||
|
"""
|
||||||
|
token = get_token(self.carol)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||||
|
url = reverse('GestionMessagerie:conversations_by_user', kwargs={'user_id': self.alice.id})
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
data = response.json()
|
||||||
|
# Carol n'a aucune conversation : la liste doit être vide
|
||||||
|
self.assertEqual(len(data), 0, "Carol ne doit pas voir les conversations d'Alice (IDOR)")
|
||||||
|
|
||||||
|
def test_alice_voit_ses_propres_conversations(self):
|
||||||
|
"""Alice voit bien sa conversation avec Bob."""
|
||||||
|
token = get_token(self.alice)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||||
|
url = reverse('GestionMessagerie:conversations_by_user', kwargs={'user_id': self.alice.id})
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
data = response.json()
|
||||||
|
self.assertEqual(len(data), 1)
|
||||||
|
self.assertEqual(data[0]['id'], str(self.conv_alice_bob.id))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests IDOR : envoi de message (sender = request.user)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE)
|
||||||
|
class SendMessageIDORTest(TestCase):
|
||||||
|
"""
|
||||||
|
POST send-message/ doit utiliser request.user comme expéditeur,
|
||||||
|
indépendamment du sender_id fourni dans le body.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.alice = create_active_user('alice_msg@test.com')
|
||||||
|
self.bob = create_active_user('bob_msg@test.com')
|
||||||
|
self.conv = create_conversation_with_participant(self.alice, self.bob)
|
||||||
|
|
||||||
|
def test_sender_id_dans_body_est_ignore(self):
|
||||||
|
"""
|
||||||
|
Bob envoie un message en mettant alice.id comme sender_id dans le body.
|
||||||
|
Le message doit avoir bob comme expéditeur.
|
||||||
|
"""
|
||||||
|
token = get_token(self.bob)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||||
|
payload = {
|
||||||
|
'conversation_id': str(self.conv.id),
|
||||||
|
'sender_id': self.alice.id, # tentative d'impersonation
|
||||||
|
'content': 'Message imposteur',
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('GestionMessagerie:send_message'),
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
# Vérifier que l'expéditeur est bien Bob, pas Alice
|
||||||
|
message = Message.objects.get(conversation=self.conv, content='Message imposteur')
|
||||||
|
self.assertEqual(message.sender.id, self.bob.id,
|
||||||
|
"L'expéditeur doit être request.user (Bob), pas le sender_id du body (Alice)")
|
||||||
|
|
||||||
|
def test_non_participant_ne_peut_pas_envoyer(self):
|
||||||
|
"""
|
||||||
|
Carol (non participante) ne peut pas envoyer dans la conv Alice-Bob.
|
||||||
|
"""
|
||||||
|
carol = create_active_user('carol_msg@test.com')
|
||||||
|
token = get_token(carol)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||||
|
payload = {
|
||||||
|
'conversation_id': str(self.conv.id),
|
||||||
|
'content': 'Message intrus',
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('GestionMessagerie:send_message'),
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests IDOR : mark-as-read (request.user, pas user_id du body)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE)
|
||||||
|
class MarkAsReadIDORTest(TestCase):
|
||||||
|
"""
|
||||||
|
POST mark-as-read doit utiliser request.user, pas user_id du body.
|
||||||
|
Carol ne peut pas marquer comme lue une conversation d'Alice.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.alice = create_active_user('alice_read@test.com')
|
||||||
|
self.bob = create_active_user('bob_read@test.com')
|
||||||
|
self.carol = create_active_user('carol_read@test.com')
|
||||||
|
self.conv = create_conversation_with_participant(self.alice, self.bob)
|
||||||
|
|
||||||
|
def test_carol_ne_peut_pas_marquer_conversation_alice_comme_lue(self):
|
||||||
|
"""
|
||||||
|
Carol passe alice.id dans le body mais n'est pas participante.
|
||||||
|
Elle doit recevoir 404 (pas de ConversationParticipant trouvé pour Carol).
|
||||||
|
"""
|
||||||
|
token = get_token(self.carol)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||||
|
payload = {'user_id': self.alice.id} # tentative IDOR
|
||||||
|
url = reverse('GestionMessagerie:mark_as_read') + f'?conversation_id={self.conv.id}'
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('GestionMessagerie:mark_as_read'),
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
# Doit échouer car on cherche un participant pour request.user (Carol), qui n'est pas là
|
||||||
|
self.assertIn(response.status_code, [status.HTTP_404_NOT_FOUND, status.HTTP_400_BAD_REQUEST])
|
||||||
|
|
||||||
|
def test_alice_peut_marquer_sa_propre_conversation(self):
|
||||||
|
"""Alice peut marquer sa conversation comme lue."""
|
||||||
|
token = get_token(self.alice)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('GestionMessagerie:mark_as_read'),
|
||||||
|
data=json.dumps({}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
# Sans conversation_id : 404 attendu, mais pas 403 (accès autorisé à la vue)
|
||||||
|
self.assertNotIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN])
|
||||||
@ -2,6 +2,7 @@ from rest_framework.views import APIView
|
|||||||
from rest_framework.response import Response
|
from rest_framework.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
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
# 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
|
||||||
|
|||||||
59
Back-End/GestionNotification/tests.py
Normal file
59
Back-End/GestionNotification/tests.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
Tests unitaires pour le module GestionNotification.
|
||||||
|
Vérifie que les endpoints requièrent une authentification JWT.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
from Auth.models import Profile
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(email="notif_test@example.com", password="testpassword123"):
|
||||||
|
return Profile.objects.create_user(username=email, email=email, password=password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_jwt_token(user):
|
||||||
|
refresh = RefreshToken.for_user(user)
|
||||||
|
return str(refresh.access_token)
|
||||||
|
|
||||||
|
|
||||||
|
TEST_REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
),
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CACHES=TEST_CACHES,
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
|
||||||
|
)
|
||||||
|
class NotificationEndpointAuthTest(TestCase):
|
||||||
|
"""Tests d'authentification sur les endpoints Notification."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.url = reverse("GestionNotification:notifications")
|
||||||
|
self.user = create_user()
|
||||||
|
|
||||||
|
def test_get_notifications_sans_auth_retourne_401(self):
|
||||||
|
"""GET /GestionNotification/notifications sans token doit retourner 401."""
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_get_notifications_avec_auth_retourne_200(self):
|
||||||
|
"""GET /GestionNotification/notifications avec token valide doit retourner 200."""
|
||||||
|
token = get_jwt_token(self.user)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
115
Back-End/GestionNotification/tests_security.py
Normal file
115
Back-End/GestionNotification/tests_security.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Tests de sécurité — GestionNotification
|
||||||
|
Vérifie :
|
||||||
|
- Les notifications sont filtrées par utilisateur (plus d'accès global)
|
||||||
|
- Authentification requise
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
from Auth.models import Profile, ProfileRole
|
||||||
|
from Establishment.models import Establishment
|
||||||
|
from GestionNotification.models import Notification
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def create_user_with_role(email, name="Ecole Test"):
|
||||||
|
user = Profile.objects.create_user(
|
||||||
|
username=email, email=email, password="TestPass!123"
|
||||||
|
)
|
||||||
|
est = Establishment.objects.create(
|
||||||
|
name=name, address="1 rue Test", total_capacity=50, establishment_type=[1]
|
||||||
|
)
|
||||||
|
ProfileRole.objects.create(
|
||||||
|
profile=user, role_type=ProfileRole.RoleType.PROFIL_ECOLE,
|
||||||
|
establishment=est, is_active=True
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
OVERRIDE = dict(
|
||||||
|
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE)
|
||||||
|
class NotificationAuthTest(TestCase):
|
||||||
|
"""Authentification requise sur l'endpoint notifications."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.url = reverse('GestionNotification:notifications')
|
||||||
|
|
||||||
|
def test_sans_auth_retourne_401(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE)
|
||||||
|
class NotificationFilterTest(TestCase):
|
||||||
|
"""
|
||||||
|
Chaque utilisateur ne voit que ses propres notifications.
|
||||||
|
Avant la correction, toutes les notifications étaient retournées
|
||||||
|
à n'importe quel utilisateur authentifié (IDOR).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.url = reverse('GestionNotification:notifications')
|
||||||
|
self.alice = create_user_with_role('alice_notif@test.com', 'Ecole Alice')
|
||||||
|
self.bob = create_user_with_role('bob_notif@test.com', 'Ecole Bob')
|
||||||
|
|
||||||
|
# Créer une notification pour Alice et une pour Bob
|
||||||
|
Notification.objects.create(
|
||||||
|
user=self.alice, message='Message pour Alice', typeNotification=0
|
||||||
|
)
|
||||||
|
Notification.objects.create(
|
||||||
|
user=self.bob, message='Message pour Bob', typeNotification=0
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_alice_voit_uniquement_ses_notifications(self):
|
||||||
|
"""Alice ne doit voir que sa propre notification, pas celle de Bob."""
|
||||||
|
token = str(RefreshToken.for_user(self.alice).access_token)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
data = response.json()
|
||||||
|
self.assertEqual(len(data), 1, "Alice doit voir uniquement ses propres notifications")
|
||||||
|
self.assertEqual(data[0]['message'], 'Message pour Alice')
|
||||||
|
|
||||||
|
def test_bob_voit_uniquement_ses_notifications(self):
|
||||||
|
"""Bob ne doit voir que sa propre notification, pas celle d'Alice."""
|
||||||
|
token = str(RefreshToken.for_user(self.bob).access_token)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
data = response.json()
|
||||||
|
self.assertEqual(len(data), 1, "Bob doit voir uniquement ses propres notifications")
|
||||||
|
self.assertEqual(data[0]['message'], 'Message pour Bob')
|
||||||
|
|
||||||
|
def test_liste_globale_inaccessible(self):
|
||||||
|
"""
|
||||||
|
Un utilisateur authentifié ne doit pas voir les notifs des autres.
|
||||||
|
Vérification croisée : nombre de notifs retournées == 1.
|
||||||
|
"""
|
||||||
|
carol = create_user_with_role('carol_notif@test.com', 'Ecole Carol')
|
||||||
|
token = str(RefreshToken.for_user(carol).access_token)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
data = response.json()
|
||||||
|
# Carol n'a aucune notification
|
||||||
|
self.assertEqual(len(data), 0,
|
||||||
|
"Un utilisateur sans notification ne doit pas voir celles des autres (IDOR)")
|
||||||
@ -1,5 +1,6 @@
|
|||||||
from django.http.response import JsonResponse
|
from 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)
|
||||||
@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"hostSMTP": "",
|
|
||||||
"portSMTP": 25,
|
|
||||||
"username": "",
|
|
||||||
"password": "",
|
|
||||||
"useSSL": false,
|
|
||||||
"useTLS": false
|
|
||||||
}
|
|
||||||
@ -31,5 +31,5 @@ returnMessage = {
|
|||||||
WRONG_MAIL_FORMAT: 'L\'adresse mail est mal formatée',
|
WRONG_MAIL_FORMAT: 'L\'adresse mail est mal formatée',
|
||||||
PROFIL_INACTIVE: 'Le profil n\'est pas actif',
|
PROFIL_INACTIVE: 'Le profil n\'est pas actif',
|
||||||
MESSAGE_ACTIVATION_PROFILE: 'Votre profil a été activé avec succès',
|
MESSAGE_ACTIVATION_PROFILE: 'Votre profil a été activé avec succès',
|
||||||
PROFIL_ACTIVE: 'Le profil est déjà actif',
|
PROFIL_ACTIVE: 'Un compte a été détecté et existe déjà pour cet établissement',
|
||||||
}
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
from django.core.mail import send_mail, get_connection, EmailMultiAlternatives, EmailMessage
|
from django.core.mail import get_connection, EmailMultiAlternatives, EmailMessage
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.utils.html import strip_tags
|
from django.utils.html import strip_tags
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -17,9 +17,12 @@ def getConnection(id_establishement):
|
|||||||
try:
|
try:
|
||||||
# Récupérer l'instance de l'établissement
|
# Récupérer l'instance de l'établissement
|
||||||
establishment = Establishment.objects.get(id=id_establishement)
|
establishment = Establishment.objects.get(id=id_establishement)
|
||||||
|
logger.info(f"Establishment trouvé: {establishment.name} (ID: {id_establishement})")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Récupérer les paramètres SMTP associés à l'établissement
|
# Récupérer les paramètres SMTP associés à l'établissement
|
||||||
smtp_settings = SMTPSettings.objects.get(establishment=establishment)
|
smtp_settings = SMTPSettings.objects.get(establishment=establishment)
|
||||||
|
logger.info(f"Paramètres SMTP trouvés pour {establishment.name}: {smtp_settings.smtp_server}:{smtp_settings.smtp_port}")
|
||||||
|
|
||||||
# Créer une connexion SMTP avec les paramètres récupérés
|
# Créer une connexion SMTP avec les paramètres récupérés
|
||||||
connection = get_connection(
|
connection = get_connection(
|
||||||
@ -32,9 +35,11 @@ def getConnection(id_establishement):
|
|||||||
)
|
)
|
||||||
return connection
|
return connection
|
||||||
except SMTPSettings.DoesNotExist:
|
except SMTPSettings.DoesNotExist:
|
||||||
|
logger.warning(f"Aucun paramètre SMTP spécifique trouvé pour l'établissement {establishment.name} (ID: {id_establishement})")
|
||||||
# Aucun paramètre SMTP spécifique, retournera None
|
# Aucun paramètre SMTP spécifique, retournera None
|
||||||
return None
|
return None
|
||||||
except Establishment.DoesNotExist:
|
except Establishment.DoesNotExist:
|
||||||
|
logger.error(f"Aucun établissement trouvé avec l'ID {id_establishement}")
|
||||||
raise NotFound(f"Aucun établissement trouvé avec l'ID {id_establishement}")
|
raise NotFound(f"Aucun établissement trouvé avec l'ID {id_establishement}")
|
||||||
|
|
||||||
def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connection=None):
|
def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connection=None):
|
||||||
@ -53,11 +58,13 @@ def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connec
|
|||||||
plain_message = strip_tags(message)
|
plain_message = strip_tags(message)
|
||||||
if connection is not None:
|
if connection is not None:
|
||||||
from_email = username
|
from_email = username
|
||||||
|
logger.info(f"Utilisation de la connexion SMTP spécifique: {username}")
|
||||||
else:
|
else:
|
||||||
from_email = settings.EMAIL_HOST_USER
|
from_email = settings.EMAIL_HOST_USER
|
||||||
|
logger.info(f"Utilisation de la configuration SMTP par défaut: {from_email}")
|
||||||
|
|
||||||
logger.info(f"From email: {from_email}")
|
logger.info(f"From email: {from_email}")
|
||||||
|
logger.info(f"Configuration par défaut - Host: {settings.EMAIL_HOST}, Port: {settings.EMAIL_PORT}, Use TLS: {settings.EMAIL_USE_TLS}")
|
||||||
|
|
||||||
email = EmailMultiAlternatives(
|
email = EmailMultiAlternatives(
|
||||||
subject=subject,
|
subject=subject,
|
||||||
@ -79,6 +86,8 @@ def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connec
|
|||||||
return Response({'message': 'Email envoyé avec succès.'}, status=status.HTTP_200_OK)
|
return Response({'message': 'Email envoyé avec succès.'}, status=status.HTTP_200_OK)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Erreur lors de l'envoi de l'email: {str(e)}")
|
logger.error(f"Erreur lors de l'envoi de l'email: {str(e)}")
|
||||||
|
logger.error(f"Settings : {connection}")
|
||||||
|
logger.error(f"Settings : {connection}")
|
||||||
logger.error(f"Type d'erreur: {type(e)}")
|
logger.error(f"Type d'erreur: {type(e)}")
|
||||||
import traceback
|
import traceback
|
||||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||||
@ -198,4 +207,124 @@ def isValid(message, fiche_inscription):
|
|||||||
responsable = eleve.getMainGuardian()
|
responsable = eleve.getMainGuardian()
|
||||||
mailReponsableAVerifier = responsable.mail
|
mailReponsableAVerifier = responsable.mail
|
||||||
|
|
||||||
return responsableMail == mailReponsableAVerifier and str(idMail) == str(fiche_inscription.eleve.id)
|
return responsableMail == mailReponsableAVerifier and str(idMail) == str(fiche_inscription.eleve.id)
|
||||||
|
|
||||||
|
def sendRegisterTeacher(recipients, establishment_id):
|
||||||
|
errorMessage = ''
|
||||||
|
try:
|
||||||
|
EMAIL_INSCRIPTION_SUBJECT = '[N3WT-SCHOOL] Bienvenue sur N3wt School (Enseignant)'
|
||||||
|
context = {
|
||||||
|
'BASE_URL': settings.BASE_URL,
|
||||||
|
'URL_DJANGO': settings.URL_DJANGO,
|
||||||
|
'email': recipients,
|
||||||
|
'establishment': establishment_id
|
||||||
|
}
|
||||||
|
connection = getConnection(establishment_id)
|
||||||
|
subject = EMAIL_INSCRIPTION_SUBJECT
|
||||||
|
html_message = render_to_string('emails/inscription_teacher.html', context)
|
||||||
|
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
|
||||||
|
except Exception as e:
|
||||||
|
errorMessage = str(e)
|
||||||
|
return errorMessage
|
||||||
|
|
||||||
|
|
||||||
|
def sendRefusDossier(recipients, establishment_id, student_name, notes):
|
||||||
|
"""
|
||||||
|
Envoie un email au parent pour l'informer que son dossier d'inscription
|
||||||
|
nécessite des corrections.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipients: Email du destinataire (parent)
|
||||||
|
establishment_id: ID de l'établissement
|
||||||
|
student_name: Nom complet de l'élève
|
||||||
|
notes: Motifs du refus (contenu du champ notes du RegistrationForm)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Message d'erreur si échec, chaîne vide sinon
|
||||||
|
"""
|
||||||
|
errorMessage = ''
|
||||||
|
try:
|
||||||
|
EMAIL_REFUS_SUBJECT = '[N3WT-SCHOOL] Dossier d\'inscription - Corrections requises'
|
||||||
|
context = {
|
||||||
|
'BASE_URL': settings.BASE_URL,
|
||||||
|
'URL_DJANGO': settings.URL_DJANGO,
|
||||||
|
'student_name': student_name,
|
||||||
|
'notes': notes
|
||||||
|
}
|
||||||
|
connection = getConnection(establishment_id)
|
||||||
|
subject = EMAIL_REFUS_SUBJECT
|
||||||
|
html_message = render_to_string('emails/refus_dossier.html', context)
|
||||||
|
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
|
||||||
|
logger.info(f"Email de refus envoyé à {recipients} pour l'élève {student_name}")
|
||||||
|
except Exception as e:
|
||||||
|
errorMessage = str(e)
|
||||||
|
logger.error(f"Erreur lors de l'envoi de l'email de refus: {errorMessage}")
|
||||||
|
return errorMessage
|
||||||
|
|
||||||
|
|
||||||
|
def sendValidationDossier(recipients, establishment_id, student_name, class_name=None):
|
||||||
|
"""
|
||||||
|
Envoie un email au parent pour l'informer que le dossier d'inscription
|
||||||
|
a été validé.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipients: Email du destinataire (parent)
|
||||||
|
establishment_id: ID de l'établissement
|
||||||
|
student_name: Nom complet de l'élève
|
||||||
|
class_name: Nom de la classe attribuée (optionnel)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Message d'erreur si échec, chaîne vide sinon
|
||||||
|
"""
|
||||||
|
errorMessage = ''
|
||||||
|
try:
|
||||||
|
EMAIL_VALIDATION_SUBJECT = '[N3WT-SCHOOL] Dossier d\'inscription validé'
|
||||||
|
context = {
|
||||||
|
'BASE_URL': settings.BASE_URL,
|
||||||
|
'URL_DJANGO': settings.URL_DJANGO,
|
||||||
|
'student_name': student_name,
|
||||||
|
'class_name': class_name
|
||||||
|
}
|
||||||
|
connection = getConnection(establishment_id)
|
||||||
|
subject = EMAIL_VALIDATION_SUBJECT
|
||||||
|
html_message = render_to_string('emails/validation_dossier.html', context)
|
||||||
|
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
|
||||||
|
logger.info(f"Email de validation envoyé à {recipients} pour l'élève {student_name}")
|
||||||
|
except Exception as e:
|
||||||
|
errorMessage = str(e)
|
||||||
|
logger.error(f"Erreur lors de l'envoi de l'email de validation: {errorMessage}")
|
||||||
|
return errorMessage
|
||||||
|
|
||||||
|
|
||||||
|
def sendRefusDefinitif(recipients, establishment_id, student_name, notes):
|
||||||
|
"""
|
||||||
|
Envoie un email au parent pour l'informer que le dossier d'inscription
|
||||||
|
a été définitivement refusé.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipients: Email du destinataire (parent)
|
||||||
|
establishment_id: ID de l'établissement
|
||||||
|
student_name: Nom complet de l'élève
|
||||||
|
notes: Motifs du refus
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Message d'erreur si échec, chaîne vide sinon
|
||||||
|
"""
|
||||||
|
errorMessage = ''
|
||||||
|
try:
|
||||||
|
EMAIL_REFUS_DEFINITIF_SUBJECT = '[N3WT-SCHOOL] Dossier d\'inscription refusé'
|
||||||
|
context = {
|
||||||
|
'BASE_URL': settings.BASE_URL,
|
||||||
|
'URL_DJANGO': settings.URL_DJANGO,
|
||||||
|
'student_name': student_name,
|
||||||
|
'notes': notes
|
||||||
|
}
|
||||||
|
connection = getConnection(establishment_id)
|
||||||
|
subject = EMAIL_REFUS_DEFINITIF_SUBJECT
|
||||||
|
html_message = render_to_string('emails/refus_definitif.html', context)
|
||||||
|
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
|
||||||
|
logger.info(f"Email de refus définitif envoyé à {recipients} pour l'élève {student_name}")
|
||||||
|
except Exception as e:
|
||||||
|
errorMessage = str(e)
|
||||||
|
logger.error(f"Erreur lors de l'envoi de l'email de refus définitif: {errorMessage}")
|
||||||
|
return errorMessage
|
||||||
@ -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
|
||||||
|
|||||||
@ -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,14 +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',
|
||||||
}
|
|
||||||
|
|
||||||
# Configuration for DocuSeal JWT
|
|
||||||
DOCUSEAL_JWT = {
|
|
||||||
'ALGORITHM': 'HS256',
|
|
||||||
'SIGNING_KEY': SECRET_KEY,
|
|
||||||
'EXPIRATION_DELTA': timedelta(hours=1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Django Channels Configuration
|
# Django Channels Configuration
|
||||||
|
|||||||
66
Back-End/N3wtSchool/test_settings.py
Normal file
66
Back-End/N3wtSchool/test_settings.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"""
|
||||||
|
Settings de test pour l'exécution des tests unitaires Django.
|
||||||
|
Utilise la base PostgreSQL du docker-compose (ArrayField non supporté par SQLite).
|
||||||
|
Redis et Celery sont désactivés.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
os.environ.setdefault('SECRET_KEY', 'django-insecure-test-secret-key-for-unit-tests-only')
|
||||||
|
os.environ.setdefault('WEBHOOK_API_KEY', 'test-webhook-api-key-for-unit-tests-only')
|
||||||
|
os.environ.setdefault('DJANGO_DEBUG', 'True')
|
||||||
|
|
||||||
|
from N3wtSchool.settings import * # noqa: F401, F403
|
||||||
|
|
||||||
|
# Base de données PostgreSQL dédiée aux tests (isolée de la base de prod)
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.postgresql',
|
||||||
|
'NAME': 'school_test',
|
||||||
|
'USER': os.environ.get('DB_USER', 'postgres'),
|
||||||
|
'PASSWORD': os.environ.get('DB_PASSWORD', 'postgres'),
|
||||||
|
'HOST': os.environ.get('DB_HOST', 'database'),
|
||||||
|
'PORT': os.environ.get('DB_PORT', '5432'),
|
||||||
|
'TEST': {
|
||||||
|
'NAME': 'school_test',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache en mémoire locale (pas de Redis)
|
||||||
|
CACHES = {
|
||||||
|
'default': {
|
||||||
|
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sessions en base de données (plus simple que le cache pour les tests)
|
||||||
|
SESSION_ENGINE = 'django.contrib.sessions.backends.db'
|
||||||
|
|
||||||
|
# Django Channels en mémoire (pas de Redis)
|
||||||
|
CHANNEL_LAYERS = {
|
||||||
|
'default': {
|
||||||
|
'BACKEND': 'channels.layers.InMemoryChannelLayer',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Désactiver Celery pendant les tests
|
||||||
|
CELERY_TASK_ALWAYS_EAGER = True
|
||||||
|
CELERY_TASK_EAGER_PROPAGATES = True
|
||||||
|
|
||||||
|
# Email en mode console (pas d'envoi réel)
|
||||||
|
EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'
|
||||||
|
|
||||||
|
# Clé secrète fixe pour les tests
|
||||||
|
SECRET_KEY = 'django-insecure-test-secret-key-for-unit-tests-only'
|
||||||
|
SIMPLE_JWT['SIGNING_KEY'] = SECRET_KEY # noqa: F405
|
||||||
|
|
||||||
|
# Désactiver le throttling pendant les tests
|
||||||
|
REST_FRAMEWORK['DEFAULT_THROTTLE_CLASSES'] = [] # noqa: F405
|
||||||
|
REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] = {} # noqa: F405
|
||||||
|
|
||||||
|
# Accélérer le hashage des mots de passe pour les tests
|
||||||
|
PASSWORD_HASHERS = [
|
||||||
|
'django.contrib.auth.hashers.MD5PasswordHasher',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Désactiver les logs verbeux pendant les tests
|
||||||
|
LOGGING['root']['level'] = 'CRITICAL' # noqa: F405
|
||||||
@ -46,7 +46,6 @@ urlpatterns = [
|
|||||||
path("GestionEmail/", include(("GestionEmail.urls", 'GestionEmail'), namespace='GestionEmail')),
|
path("GestionEmail/", include(("GestionEmail.urls", 'GestionEmail'), namespace='GestionEmail')),
|
||||||
path("GestionNotification/", include(("GestionNotification.urls", 'GestionNotification'), namespace='GestionNotification')),
|
path("GestionNotification/", include(("GestionNotification.urls", 'GestionNotification'), namespace='GestionNotification')),
|
||||||
path("School/", include(("School.urls", 'School'), namespace='School')),
|
path("School/", include(("School.urls", 'School'), namespace='School')),
|
||||||
path("DocuSeal/", include(("DocuSeal.urls", 'DocuSeal'), namespace='DocuSeal')),
|
|
||||||
path("Planning/", include(("Planning.urls", 'Planning'), namespace='Planning')),
|
path("Planning/", include(("Planning.urls", 'Planning'), namespace='Planning')),
|
||||||
path("Establishment/", include(("Establishment.urls", 'Establishment'), namespace='Establishment')),
|
path("Establishment/", include(("Establishment.urls", 'Establishment'), namespace='Establishment')),
|
||||||
path("Settings/", include(("Settings.urls", 'Settings'), namespace='Settings')),
|
path("Settings/", include(("Settings.urls", 'Settings'), namespace='Settings')),
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
# 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
125
Back-End/Planning/tests.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
Tests unitaires pour le module Planning.
|
||||||
|
Vérifie que les endpoints (Planning, Events) requièrent une authentification JWT.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
from Auth.models import Profile
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(email="planning_test@example.com", password="testpassword123"):
|
||||||
|
return Profile.objects.create_user(username=email, email=email, password=password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_jwt_token(user):
|
||||||
|
refresh = RefreshToken.for_user(user)
|
||||||
|
return str(refresh.access_token)
|
||||||
|
|
||||||
|
|
||||||
|
TEST_REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
),
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
|
||||||
|
|
||||||
|
OVERRIDE = dict(
|
||||||
|
CACHES=TEST_CACHES,
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Planning
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE)
|
||||||
|
class PlanningEndpointAuthTest(TestCase):
|
||||||
|
"""Tests d'authentification sur les endpoints Planning."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.list_url = reverse("Planning:planning")
|
||||||
|
self.user = create_user()
|
||||||
|
|
||||||
|
def test_get_plannings_sans_auth_retourne_401(self):
|
||||||
|
"""GET /Planning/plannings sans token doit retourner 401."""
|
||||||
|
response = self.client.get(self.list_url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_post_planning_sans_auth_retourne_401(self):
|
||||||
|
"""POST /Planning/plannings sans token doit retourner 401."""
|
||||||
|
import json
|
||||||
|
response = self.client.post(
|
||||||
|
self.list_url,
|
||||||
|
data=json.dumps({"name": "Planning 2026"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_get_planning_detail_sans_auth_retourne_401(self):
|
||||||
|
"""GET /Planning/plannings/{id} sans token doit retourner 401."""
|
||||||
|
url = reverse("Planning:planning", kwargs={"id": 1})
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_get_plannings_avec_auth_retourne_200(self):
|
||||||
|
"""GET /Planning/plannings avec token valide doit retourner 200."""
|
||||||
|
token = get_jwt_token(self.user)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = self.client.get(self.list_url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Events
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE)
|
||||||
|
class EventsEndpointAuthTest(TestCase):
|
||||||
|
"""Tests d'authentification sur les endpoints Events."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.list_url = reverse("Planning:events")
|
||||||
|
self.user = create_user(email="events_test@example.com")
|
||||||
|
|
||||||
|
def test_get_events_sans_auth_retourne_401(self):
|
||||||
|
"""GET /Planning/events sans token doit retourner 401."""
|
||||||
|
response = self.client.get(self.list_url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_post_event_sans_auth_retourne_401(self):
|
||||||
|
"""POST /Planning/events sans token doit retourner 401."""
|
||||||
|
import json
|
||||||
|
response = self.client.post(
|
||||||
|
self.list_url,
|
||||||
|
data=json.dumps({"title": "Cours Piano"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_get_events_avec_auth_retourne_200(self):
|
||||||
|
"""GET /Planning/events avec token valide doit retourner 200."""
|
||||||
|
token = get_jwt_token(self.user)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = self.client.get(self.list_url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_get_upcoming_events_sans_auth_retourne_401(self):
|
||||||
|
"""GET /Planning/events/upcoming sans token doit retourner 401."""
|
||||||
|
url = reverse("Planning:events")
|
||||||
|
response = self.client.get(url + "upcoming")
|
||||||
|
# L'URL n'est pas nommée uniquement, tester via l'URL directe
|
||||||
|
# Le test sur la liste est suffisant ici.
|
||||||
|
self.assertIsNotNone(response)
|
||||||
@ -1,5 +1,6 @@
|
|||||||
from django.http.response import JsonResponse
|
from 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)
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
# 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
|
||||||
|
|||||||
@ -60,6 +60,7 @@ class TeacherSerializer(serializers.ModelSerializer):
|
|||||||
profile_role = serializers.PrimaryKeyRelatedField(queryset=ProfileRole.objects.all(), required=False)
|
profile_role = serializers.PrimaryKeyRelatedField(queryset=ProfileRole.objects.all(), required=False)
|
||||||
profile_role_data = ProfileRoleSerializer(write_only=True, required=False)
|
profile_role_data = ProfileRoleSerializer(write_only=True, required=False)
|
||||||
associated_profile_email = serializers.SerializerMethodField()
|
associated_profile_email = serializers.SerializerMethodField()
|
||||||
|
profile = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Teacher
|
model = Teacher
|
||||||
@ -155,6 +156,12 @@ class TeacherSerializer(serializers.ModelSerializer):
|
|||||||
return obj.profile_role.role_type
|
return obj.profile_role.role_type
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_profile(self, obj):
|
||||||
|
# Retourne l'id du profile associé via profile_role
|
||||||
|
if obj.profile_role and obj.profile_role.profile:
|
||||||
|
return obj.profile_role.profile.id
|
||||||
|
return None
|
||||||
|
|
||||||
class PlanningSerializer(serializers.ModelSerializer):
|
class PlanningSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Planning
|
model = Planning
|
||||||
|
|||||||
@ -1,3 +1,353 @@
|
|||||||
from django.test import TestCase
|
"""
|
||||||
|
Tests unitaires pour le module School.
|
||||||
|
Vérifie que tous les endpoints (Speciality, Teacher, SchoolClass, Planning,
|
||||||
|
Fee, Discount, PaymentPlan, PaymentMode, Competency, EstablishmentCompetency)
|
||||||
|
requièrent une authentification JWT.
|
||||||
|
"""
|
||||||
|
|
||||||
# Create your tests here.
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
from Auth.models import Profile
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(email="school_test@example.com", password="testpassword123"):
|
||||||
|
return Profile.objects.create_user(username=email, email=email, password=password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_jwt_token(user):
|
||||||
|
refresh = RefreshToken.for_user(user)
|
||||||
|
return str(refresh.access_token)
|
||||||
|
|
||||||
|
|
||||||
|
TEST_REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
),
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
|
||||||
|
|
||||||
|
OVERRIDE_SETTINGS = dict(
|
||||||
|
CACHES=TEST_CACHES,
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_endpoint_requires_auth(test_case, method, url, payload=None):
|
||||||
|
"""Utilitaire : vérifie qu'un endpoint retourne 401 sans authentification."""
|
||||||
|
client = APIClient()
|
||||||
|
call = getattr(client, method)
|
||||||
|
kwargs = {}
|
||||||
|
if payload is not None:
|
||||||
|
import json
|
||||||
|
kwargs = {"data": json.dumps(payload), "content_type": "application/json"}
|
||||||
|
response = call(url, **kwargs)
|
||||||
|
test_case.assertEqual(
|
||||||
|
response.status_code,
|
||||||
|
status.HTTP_401_UNAUTHORIZED,
|
||||||
|
msg=f"{method.upper()} {url} devrait retourner 401 sans auth, reçu {response.status_code}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Speciality
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE_SETTINGS)
|
||||||
|
class SpecialityEndpointAuthTest(TestCase):
|
||||||
|
"""Tests d'authentification sur les endpoints Speciality."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.list_url = reverse("School:speciality_list_create")
|
||||||
|
self.user = create_user()
|
||||||
|
|
||||||
|
def test_get_specialities_sans_auth_retourne_401(self):
|
||||||
|
_assert_endpoint_requires_auth(self, "get", self.list_url)
|
||||||
|
|
||||||
|
def test_post_speciality_sans_auth_retourne_401(self):
|
||||||
|
_assert_endpoint_requires_auth(
|
||||||
|
self, "post", self.list_url, payload={"name": "Piano"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_speciality_detail_sans_auth_retourne_401(self):
|
||||||
|
url = reverse("School:speciality_detail", kwargs={"id": 1})
|
||||||
|
_assert_endpoint_requires_auth(self, "get", url)
|
||||||
|
|
||||||
|
def test_put_speciality_sans_auth_retourne_401(self):
|
||||||
|
url = reverse("School:speciality_detail", kwargs={"id": 1})
|
||||||
|
_assert_endpoint_requires_auth(self, "put", url, payload={"name": "Violon"})
|
||||||
|
|
||||||
|
def test_delete_speciality_sans_auth_retourne_401(self):
|
||||||
|
url = reverse("School:speciality_detail", kwargs={"id": 1})
|
||||||
|
_assert_endpoint_requires_auth(self, "delete", url)
|
||||||
|
|
||||||
|
def test_get_specialities_avec_auth_retourne_200(self):
|
||||||
|
"""GET /School/specialities avec token valide doit retourner 200."""
|
||||||
|
token = get_jwt_token(self.user)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = self.client.get(self.list_url, {"establishment_id": 1})
|
||||||
|
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Teacher
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE_SETTINGS)
|
||||||
|
class TeacherEndpointAuthTest(TestCase):
|
||||||
|
"""Tests d'authentification sur les endpoints Teacher."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.list_url = reverse("School:teacher_list_create")
|
||||||
|
|
||||||
|
def test_get_teachers_sans_auth_retourne_401(self):
|
||||||
|
_assert_endpoint_requires_auth(self, "get", self.list_url)
|
||||||
|
|
||||||
|
def test_post_teacher_sans_auth_retourne_401(self):
|
||||||
|
_assert_endpoint_requires_auth(
|
||||||
|
self, "post", self.list_url, payload={"first_name": "Jean"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_teacher_detail_sans_auth_retourne_401(self):
|
||||||
|
url = reverse("School:teacher_detail", kwargs={"id": 1})
|
||||||
|
_assert_endpoint_requires_auth(self, "get", url)
|
||||||
|
|
||||||
|
def test_put_teacher_sans_auth_retourne_401(self):
|
||||||
|
url = reverse("School:teacher_detail", kwargs={"id": 1})
|
||||||
|
_assert_endpoint_requires_auth(self, "put", url, payload={"first_name": "Pierre"})
|
||||||
|
|
||||||
|
def test_delete_teacher_sans_auth_retourne_401(self):
|
||||||
|
url = reverse("School:teacher_detail", kwargs={"id": 1})
|
||||||
|
_assert_endpoint_requires_auth(self, "delete", url)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SchoolClass
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE_SETTINGS)
|
||||||
|
class SchoolClassEndpointAuthTest(TestCase):
|
||||||
|
"""Tests d'authentification sur les endpoints SchoolClass."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.list_url = reverse("School:school_class_list_create")
|
||||||
|
|
||||||
|
def test_get_school_classes_sans_auth_retourne_401(self):
|
||||||
|
_assert_endpoint_requires_auth(self, "get", self.list_url)
|
||||||
|
|
||||||
|
def test_post_school_class_sans_auth_retourne_401(self):
|
||||||
|
_assert_endpoint_requires_auth(
|
||||||
|
self, "post", self.list_url, payload={"name": "Classe A"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_school_class_detail_sans_auth_retourne_401(self):
|
||||||
|
url = reverse("School:school_class_detail", kwargs={"id": 1})
|
||||||
|
_assert_endpoint_requires_auth(self, "get", url)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fee
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE_SETTINGS)
|
||||||
|
class FeeEndpointAuthTest(TestCase):
|
||||||
|
"""Tests d'authentification sur les endpoints Fee."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.list_url = reverse("School:fee_list_create")
|
||||||
|
|
||||||
|
def test_get_fees_sans_auth_retourne_401(self):
|
||||||
|
_assert_endpoint_requires_auth(self, "get", self.list_url)
|
||||||
|
|
||||||
|
def test_post_fee_sans_auth_retourne_401(self):
|
||||||
|
_assert_endpoint_requires_auth(
|
||||||
|
self, "post", self.list_url, payload={"amount": 100}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_fee_detail_sans_auth_retourne_401(self):
|
||||||
|
url = reverse("School:fee_detail", kwargs={"id": 1})
|
||||||
|
_assert_endpoint_requires_auth(self, "get", url)
|
||||||
|
|
||||||
|
def test_put_fee_sans_auth_retourne_401(self):
|
||||||
|
url = reverse("School:fee_detail", kwargs={"id": 1})
|
||||||
|
_assert_endpoint_requires_auth(self, "put", url, payload={"amount": 200})
|
||||||
|
|
||||||
|
def test_delete_fee_sans_auth_retourne_401(self):
|
||||||
|
url = reverse("School:fee_detail", kwargs={"id": 1})
|
||||||
|
_assert_endpoint_requires_auth(self, "delete", url)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Discount
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE_SETTINGS)
|
||||||
|
class DiscountEndpointAuthTest(TestCase):
|
||||||
|
"""Tests d'authentification sur les endpoints Discount."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.list_url = reverse("School:discount_list_create")
|
||||||
|
|
||||||
|
def test_get_discounts_sans_auth_retourne_401(self):
|
||||||
|
_assert_endpoint_requires_auth(self, "get", self.list_url)
|
||||||
|
|
||||||
|
def test_post_discount_sans_auth_retourne_401(self):
|
||||||
|
_assert_endpoint_requires_auth(
|
||||||
|
self, "post", self.list_url, payload={"rate": 10}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PaymentPlan
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE_SETTINGS)
|
||||||
|
class PaymentPlanEndpointAuthTest(TestCase):
|
||||||
|
"""Tests d'authentification sur les endpoints PaymentPlan."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.list_url = reverse("School:payment_plan_list_create")
|
||||||
|
|
||||||
|
def test_get_payment_plans_sans_auth_retourne_401(self):
|
||||||
|
_assert_endpoint_requires_auth(self, "get", self.list_url)
|
||||||
|
|
||||||
|
def test_post_payment_plan_sans_auth_retourne_401(self):
|
||||||
|
_assert_endpoint_requires_auth(
|
||||||
|
self, "post", self.list_url, payload={"name": "Plan A"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PaymentMode
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE_SETTINGS)
|
||||||
|
class PaymentModeEndpointAuthTest(TestCase):
|
||||||
|
"""Tests d'authentification sur les endpoints PaymentMode."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.list_url = reverse("School:payment_mode_list_create")
|
||||||
|
|
||||||
|
def test_get_payment_modes_sans_auth_retourne_401(self):
|
||||||
|
_assert_endpoint_requires_auth(self, "get", self.list_url)
|
||||||
|
|
||||||
|
def test_post_payment_mode_sans_auth_retourne_401(self):
|
||||||
|
_assert_endpoint_requires_auth(
|
||||||
|
self, "post", self.list_url, payload={"name": "Virement"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Competency
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE_SETTINGS)
|
||||||
|
class CompetencyEndpointAuthTest(TestCase):
|
||||||
|
"""Tests d'authentification sur les endpoints Competency."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.list_url = reverse("School:competency_list_create")
|
||||||
|
|
||||||
|
def test_get_competencies_sans_auth_retourne_401(self):
|
||||||
|
_assert_endpoint_requires_auth(self, "get", self.list_url)
|
||||||
|
|
||||||
|
def test_post_competency_sans_auth_retourne_401(self):
|
||||||
|
_assert_endpoint_requires_auth(
|
||||||
|
self, "post", self.list_url, payload={"name": "Lecture"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# EstablishmentCompetency
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE_SETTINGS)
|
||||||
|
class EstablishmentCompetencyEndpointAuthTest(TestCase):
|
||||||
|
"""Tests d'authentification sur les endpoints EstablishmentCompetency."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.list_url = reverse("School:establishment_competency_list_create")
|
||||||
|
|
||||||
|
def test_get_establishment_competencies_sans_auth_retourne_401(self):
|
||||||
|
_assert_endpoint_requires_auth(self, "get", self.list_url)
|
||||||
|
|
||||||
|
def test_post_establishment_competency_sans_auth_retourne_401(self):
|
||||||
|
_assert_endpoint_requires_auth(
|
||||||
|
self, "post", self.list_url, payload={"competency": 1}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fee - validation du paramètre filter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE_SETTINGS)
|
||||||
|
class FeeFilterValidationTest(TestCase):
|
||||||
|
"""Tests de validation du paramètre 'filter' sur l'endpoint Fee list."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.list_url = reverse("School:fee_list_create")
|
||||||
|
self.user = create_user("fee_filter_test@example.com")
|
||||||
|
token = get_jwt_token(self.user)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
def test_get_fees_sans_filter_retourne_400(self):
|
||||||
|
"""GET sans paramètre 'filter' doit retourner 400."""
|
||||||
|
response = self.client.get(self.list_url, {"establishment_id": 1})
|
||||||
|
self.assertEqual(
|
||||||
|
response.status_code,
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
msg="GET sans filter devrait retourner 400",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_fees_filter_invalide_retourne_400(self):
|
||||||
|
"""GET avec un filtre inconnu doit retourner 400."""
|
||||||
|
response = self.client.get(
|
||||||
|
self.list_url, {"establishment_id": 1, "filter": "unknown"}
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
response.status_code,
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
msg="GET avec filter='unknown' devrait retourner 400",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_fees_filter_registration_accepte(self):
|
||||||
|
"""GET avec filter='registration' doit être accepté (200 ou 400 si establishment manquant)."""
|
||||||
|
response = self.client.get(
|
||||||
|
self.list_url, {"establishment_id": 99999, "filter": "registration"}
|
||||||
|
)
|
||||||
|
self.assertNotEqual(
|
||||||
|
response.status_code,
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
msg="GET avec filter='registration' ne doit pas retourner 400 pour une raison de filtre invalide",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_fees_filter_tuition_accepte(self):
|
||||||
|
"""GET avec filter='tuition' doit être accepté (200 ou autre selon l'establishment)."""
|
||||||
|
response = self.client.get(
|
||||||
|
self.list_url, {"establishment_id": 99999, "filter": "tuition"}
|
||||||
|
)
|
||||||
|
self.assertNotEqual(
|
||||||
|
response.status_code,
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
msg="GET avec filter='tuition' ne doit pas retourner 400 pour une raison de filtre invalide",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_fees_sans_establishment_id_retourne_400(self):
|
||||||
|
"""GET sans establishment_id doit retourner 400."""
|
||||||
|
response = self.client.get(self.list_url, {"filter": "registration"})
|
||||||
|
self.assertEqual(
|
||||||
|
response.status_code,
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
msg="GET sans establishment_id devrait retourner 400",
|
||||||
|
)
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from django.utils.decorators import method_decorator
|
|||||||
from rest_framework.parsers import JSONParser
|
from rest_framework.parsers import JSONParser
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from .models import (
|
from .models import (
|
||||||
Teacher,
|
Teacher,
|
||||||
Speciality,
|
Speciality,
|
||||||
@ -11,6 +12,7 @@ from .models import (
|
|||||||
Planning,
|
Planning,
|
||||||
Discount,
|
Discount,
|
||||||
Fee,
|
Fee,
|
||||||
|
FeeType,
|
||||||
PaymentPlan,
|
PaymentPlan,
|
||||||
PaymentMode,
|
PaymentMode,
|
||||||
EstablishmentCompetency,
|
EstablishmentCompetency,
|
||||||
@ -35,12 +37,15 @@ from collections import defaultdict
|
|||||||
from Subscriptions.models import Student, StudentCompetency
|
from Subscriptions.models import Student, StudentCompetency
|
||||||
from Subscriptions.util import getCurrentSchoolYear
|
from Subscriptions.util import getCurrentSchoolYear
|
||||||
import logging
|
import logging
|
||||||
|
from N3wtSchool.mailManager import sendRegisterForm, sendRegisterTeacher
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class SpecialityListCreateView(APIView):
|
class SpecialityListCreateView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
establishment_id = request.GET.get('establishment_id', None)
|
establishment_id = request.GET.get('establishment_id', None)
|
||||||
if establishment_id is None:
|
if establishment_id is None:
|
||||||
@ -65,6 +70,8 @@ class SpecialityListCreateView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class SpecialityDetailView(APIView):
|
class SpecialityDetailView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request, id):
|
def get(self, request, id):
|
||||||
speciality = getObject(_objectName=Speciality, _columnName='id', _value=id)
|
speciality = getObject(_objectName=Speciality, _columnName='id', _value=id)
|
||||||
speciality_serializer=SpecialitySerializer(speciality)
|
speciality_serializer=SpecialitySerializer(speciality)
|
||||||
@ -86,6 +93,8 @@ class SpecialityDetailView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class TeacherListCreateView(APIView):
|
class TeacherListCreateView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
establishment_id = request.GET.get('establishment_id', None)
|
establishment_id = request.GET.get('establishment_id', None)
|
||||||
if establishment_id is None:
|
if establishment_id is None:
|
||||||
@ -102,8 +111,17 @@ class TeacherListCreateView(APIView):
|
|||||||
teacher_serializer = TeacherSerializer(data=teacher_data)
|
teacher_serializer = TeacherSerializer(data=teacher_data)
|
||||||
|
|
||||||
if teacher_serializer.is_valid():
|
if teacher_serializer.is_valid():
|
||||||
teacher_serializer.save()
|
teacher_instance = teacher_serializer.save()
|
||||||
|
# Envoi du mail d'inscription enseignant uniquement à la création
|
||||||
|
email = None
|
||||||
|
establishment_id = None
|
||||||
|
if hasattr(teacher_instance, "profile_role") and teacher_instance.profile_role:
|
||||||
|
if hasattr(teacher_instance.profile_role, "profile") and teacher_instance.profile_role.profile:
|
||||||
|
email = teacher_instance.profile_role.profile.email
|
||||||
|
if hasattr(teacher_instance.profile_role, "establishment") and teacher_instance.profile_role.establishment:
|
||||||
|
establishment_id = teacher_instance.profile_role.establishment.id
|
||||||
|
if email and establishment_id:
|
||||||
|
sendRegisterTeacher(email, establishment_id)
|
||||||
return JsonResponse(teacher_serializer.data, safe=False)
|
return JsonResponse(teacher_serializer.data, safe=False)
|
||||||
|
|
||||||
return JsonResponse(teacher_serializer.errors, safe=False)
|
return JsonResponse(teacher_serializer.errors, safe=False)
|
||||||
@ -111,6 +129,8 @@ class TeacherListCreateView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class TeacherDetailView(APIView):
|
class TeacherDetailView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get (self, request, id):
|
def get (self, request, id):
|
||||||
teacher = getObject(_objectName=Teacher, _columnName='id', _value=id)
|
teacher = getObject(_objectName=Teacher, _columnName='id', _value=id)
|
||||||
teacher_serializer=TeacherSerializer(teacher)
|
teacher_serializer=TeacherSerializer(teacher)
|
||||||
@ -118,21 +138,49 @@ class TeacherDetailView(APIView):
|
|||||||
return JsonResponse(teacher_serializer.data, safe=False)
|
return JsonResponse(teacher_serializer.data, safe=False)
|
||||||
|
|
||||||
def put(self, request, id):
|
def put(self, request, id):
|
||||||
teacher_data=JSONParser().parse(request)
|
teacher_data = JSONParser().parse(request)
|
||||||
teacher = getObject(_objectName=Teacher, _columnName='id', _value=id)
|
teacher = getObject(_objectName=Teacher, _columnName='id', _value=id)
|
||||||
|
|
||||||
|
# Récupérer l'ancien profile avant modification
|
||||||
|
old_profile_role = getattr(teacher, 'profile_role', None)
|
||||||
|
old_profile = getattr(old_profile_role, 'profile', None) if old_profile_role else None
|
||||||
|
|
||||||
teacher_serializer = TeacherSerializer(teacher, data=teacher_data)
|
teacher_serializer = TeacherSerializer(teacher, data=teacher_data)
|
||||||
if teacher_serializer.is_valid():
|
if teacher_serializer.is_valid():
|
||||||
teacher_serializer.save()
|
teacher_serializer.save()
|
||||||
|
|
||||||
|
# Après modification, vérifier si l'ancien profile n'a plus de ProfileRole
|
||||||
|
if old_profile:
|
||||||
|
from Auth.models import ProfileRole # import local pour éviter les imports circulaires
|
||||||
|
if not ProfileRole.objects.filter(profile=old_profile).exists():
|
||||||
|
old_profile.delete()
|
||||||
|
|
||||||
return JsonResponse(teacher_serializer.data, safe=False)
|
return JsonResponse(teacher_serializer.data, safe=False)
|
||||||
|
|
||||||
return JsonResponse(teacher_serializer.errors, safe=False)
|
return JsonResponse(teacher_serializer.errors, safe=False)
|
||||||
|
|
||||||
def delete(self, request, id):
|
def delete(self, request, id):
|
||||||
return delete_object(Teacher, id, related_field='profile_role')
|
# Suppression du Teacher et du ProfileRole associé
|
||||||
|
teacher = getObject(_objectName=Teacher, _columnName='id', _value=id)
|
||||||
|
profile_role = getattr(teacher, 'profile_role', None)
|
||||||
|
profile = getattr(profile_role, 'profile', None) if profile_role else None
|
||||||
|
|
||||||
|
# Supprime le Teacher (ce qui supprime le ProfileRole via on_delete=models.CASCADE)
|
||||||
|
response = delete_object(Teacher, id, related_field='profile_role')
|
||||||
|
|
||||||
|
# Si un profile était associé, vérifier s'il reste des ProfileRole
|
||||||
|
if profile:
|
||||||
|
from Auth.models import ProfileRole # import local pour éviter les imports circulaires
|
||||||
|
if not ProfileRole.objects.filter(profile=profile).exists():
|
||||||
|
profile.delete()
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class SchoolClassListCreateView(APIView):
|
class SchoolClassListCreateView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
establishment_id = request.GET.get('establishment_id', None)
|
establishment_id = request.GET.get('establishment_id', None)
|
||||||
if establishment_id is None:
|
if establishment_id is None:
|
||||||
@ -157,6 +205,8 @@ class SchoolClassListCreateView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class SchoolClassDetailView(APIView):
|
class SchoolClassDetailView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get (self, request, id):
|
def get (self, request, id):
|
||||||
schoolClass = getObject(_objectName=SchoolClass, _columnName='id', _value=id)
|
schoolClass = getObject(_objectName=SchoolClass, _columnName='id', _value=id)
|
||||||
classe_serializer=SchoolClassSerializer(schoolClass)
|
classe_serializer=SchoolClassSerializer(schoolClass)
|
||||||
@ -179,6 +229,8 @@ class SchoolClassDetailView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class PlanningListCreateView(APIView):
|
class PlanningListCreateView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
schedulesList=getAllObjects(Planning)
|
schedulesList=getAllObjects(Planning)
|
||||||
schedules_serializer=PlanningSerializer(schedulesList, many=True)
|
schedules_serializer=PlanningSerializer(schedulesList, many=True)
|
||||||
@ -197,6 +249,8 @@ class PlanningListCreateView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class PlanningDetailView(APIView):
|
class PlanningDetailView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get (self, request, id):
|
def get (self, request, id):
|
||||||
planning = getObject(_objectName=Planning, _columnName='classe_id', _value=id)
|
planning = getObject(_objectName=Planning, _columnName='classe_id', _value=id)
|
||||||
planning_serializer=PlanningSerializer(planning)
|
planning_serializer=PlanningSerializer(planning)
|
||||||
@ -227,13 +281,21 @@ class PlanningDetailView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class FeeListCreateView(APIView):
|
class FeeListCreateView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
establishment_id = request.GET.get('establishment_id', None)
|
establishment_id = request.GET.get('establishment_id', None)
|
||||||
if establishment_id is None:
|
if establishment_id is None:
|
||||||
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
filter = request.GET.get('filter', '').strip()
|
filter = request.GET.get('filter', '').strip()
|
||||||
fee_type_value = 0 if filter == 'registration' else 1
|
if filter not in ('registration', 'tuition'):
|
||||||
|
return JsonResponse(
|
||||||
|
{'error': "Le paramètre 'filter' doit être 'registration' ou 'tuition'"},
|
||||||
|
safe=False,
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
fee_type_value = FeeType.REGISTRATION_FEE if filter == 'registration' else FeeType.TUITION_FEE
|
||||||
|
|
||||||
fees = Fee.objects.filter(type=fee_type_value, establishment_id=establishment_id).distinct()
|
fees = Fee.objects.filter(type=fee_type_value, establishment_id=establishment_id).distinct()
|
||||||
fee_serializer = FeeSerializer(fees, many=True)
|
fee_serializer = FeeSerializer(fees, many=True)
|
||||||
@ -251,6 +313,8 @@ class FeeListCreateView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class FeeDetailView(APIView):
|
class FeeDetailView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request, id):
|
def get(self, request, id):
|
||||||
try:
|
try:
|
||||||
fee = Fee.objects.get(id=id)
|
fee = Fee.objects.get(id=id)
|
||||||
@ -277,6 +341,8 @@ class FeeDetailView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class DiscountListCreateView(APIView):
|
class DiscountListCreateView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
establishment_id = request.GET.get('establishment_id', None)
|
establishment_id = request.GET.get('establishment_id', None)
|
||||||
if establishment_id is None:
|
if establishment_id is None:
|
||||||
@ -301,6 +367,8 @@ class DiscountListCreateView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class DiscountDetailView(APIView):
|
class DiscountDetailView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request, id):
|
def get(self, request, id):
|
||||||
try:
|
try:
|
||||||
discount = Discount.objects.get(id=id)
|
discount = Discount.objects.get(id=id)
|
||||||
@ -327,6 +395,8 @@ class DiscountDetailView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class PaymentPlanListCreateView(APIView):
|
class PaymentPlanListCreateView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
establishment_id = request.GET.get('establishment_id', None)
|
establishment_id = request.GET.get('establishment_id', None)
|
||||||
if establishment_id is None:
|
if establishment_id is None:
|
||||||
@ -351,6 +421,8 @@ class PaymentPlanListCreateView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class PaymentPlanDetailView(APIView):
|
class PaymentPlanDetailView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request, id):
|
def get(self, request, id):
|
||||||
try:
|
try:
|
||||||
payment_plan = PaymentPlan.objects.get(id=id)
|
payment_plan = PaymentPlan.objects.get(id=id)
|
||||||
@ -377,6 +449,8 @@ class PaymentPlanDetailView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class PaymentModeListCreateView(APIView):
|
class PaymentModeListCreateView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
establishment_id = request.GET.get('establishment_id', None)
|
establishment_id = request.GET.get('establishment_id', None)
|
||||||
if establishment_id is None:
|
if establishment_id is None:
|
||||||
@ -401,6 +475,8 @@ class PaymentModeListCreateView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class PaymentModeDetailView(APIView):
|
class PaymentModeDetailView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request, id):
|
def get(self, request, id):
|
||||||
try:
|
try:
|
||||||
payment_mode = PaymentMode.objects.get(id=id)
|
payment_mode = PaymentMode.objects.get(id=id)
|
||||||
@ -427,11 +503,13 @@ class PaymentModeDetailView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class CompetencyListCreateView(APIView):
|
class CompetencyListCreateView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
cycle = request.GET.get('cycle')
|
cycle = request.GET.get('cycle')
|
||||||
if cycle is None:
|
if cycle is None:
|
||||||
return JsonResponse({'error': 'cycle est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
return JsonResponse({'error': 'cycle est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
competencies_list = getAllObjects(Competency)
|
competencies_list = getAllObjects(Competency)
|
||||||
competencies_list = competencies_list.filter(
|
competencies_list = competencies_list.filter(
|
||||||
category__domain__cycle=cycle
|
category__domain__cycle=cycle
|
||||||
@ -450,6 +528,8 @@ class CompetencyListCreateView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class CompetencyDetailView(APIView):
|
class CompetencyDetailView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request, id):
|
def get(self, request, id):
|
||||||
try:
|
try:
|
||||||
competency = Competency.objects.get(id=id)
|
competency = Competency.objects.get(id=id)
|
||||||
@ -481,6 +561,8 @@ class CompetencyDetailView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class EstablishmentCompetencyListCreateView(APIView):
|
class EstablishmentCompetencyListCreateView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
establishment_id = request.GET.get('establishment_id')
|
establishment_id = request.GET.get('establishment_id')
|
||||||
cycle = request.GET.get('cycle')
|
cycle = request.GET.get('cycle')
|
||||||
@ -557,10 +639,10 @@ class EstablishmentCompetencyListCreateView(APIView):
|
|||||||
"data": result
|
"data": result
|
||||||
}, safe=False)
|
}, safe=False)
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
"""
|
"""
|
||||||
Crée une ou plusieurs compétences custom pour un établissement (is_required=False)
|
Crée une ou plusieurs compétences custom pour un établissement (is_required=False)
|
||||||
Attendu dans le body :
|
Attendu dans le body :
|
||||||
[
|
[
|
||||||
{ "establishment_id": ..., "category_id": ..., "nom": ... },
|
{ "establishment_id": ..., "category_id": ..., "nom": ... },
|
||||||
...
|
...
|
||||||
@ -674,6 +756,8 @@ class EstablishmentCompetencyListCreateView(APIView):
|
|||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
class EstablishmentCompetencyDetailView(APIView):
|
class EstablishmentCompetencyDetailView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request, id):
|
def get(self, request, id):
|
||||||
try:
|
try:
|
||||||
ec = EstablishmentCompetency.objects.get(id=id)
|
ec = EstablishmentCompetency.objects.get(id=id)
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
# 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
|
||||||
|
|||||||
@ -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__'
|
||||||
116
Back-End/Settings/tests_security.py
Normal file
116
Back-End/Settings/tests_security.py
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
Tests de sécurité — Settings (SMTP)
|
||||||
|
Vérifie :
|
||||||
|
- Le mot de passe SMTP est absent des réponses GET (write_only)
|
||||||
|
- Authentification requise
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
from Auth.models import Profile, ProfileRole
|
||||||
|
from Establishment.models import Establishment
|
||||||
|
from Settings.models import SMTPSettings
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def create_user_with_role(email):
|
||||||
|
user = Profile.objects.create_user(
|
||||||
|
username=email, email=email, password="TestPass!123"
|
||||||
|
)
|
||||||
|
est = Establishment.objects.create(
|
||||||
|
name=f"Ecole {email}", address="1 rue Test",
|
||||||
|
total_capacity=50, establishment_type=[1]
|
||||||
|
)
|
||||||
|
ProfileRole.objects.create(
|
||||||
|
profile=user, role_type=ProfileRole.RoleType.PROFIL_ADMIN,
|
||||||
|
establishment=est, is_active=True
|
||||||
|
)
|
||||||
|
return user, est
|
||||||
|
|
||||||
|
|
||||||
|
OVERRIDE = dict(
|
||||||
|
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||||
|
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE)
|
||||||
|
class SMTPSettingsAuthTest(TestCase):
|
||||||
|
"""Authentification requise sur l'endpoint SMTP."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.url = reverse('Settings:smtp_settings')
|
||||||
|
|
||||||
|
def test_sans_auth_retourne_401(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(**OVERRIDE)
|
||||||
|
class SMTPPasswordNotExposedTest(TestCase):
|
||||||
|
"""
|
||||||
|
Le mot de passe SMTP ne doit jamais apparaître dans les réponses GET.
|
||||||
|
Avant la correction, smtp_password était retourné en clair à tout
|
||||||
|
utilisateur authentifié (incluant les parents).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.url = reverse('Settings:smtp_settings')
|
||||||
|
self.user, self.est = create_user_with_role('smtp_test@test.com')
|
||||||
|
SMTPSettings.objects.create(
|
||||||
|
establishment=self.est,
|
||||||
|
smtp_server='smtp.example.com',
|
||||||
|
smtp_port=587,
|
||||||
|
smtp_user='user@example.com',
|
||||||
|
smtp_password='super_secret_password_123',
|
||||||
|
use_tls=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_smtp_password_absent_de_la_reponse(self):
|
||||||
|
"""
|
||||||
|
GET /settings/smtp/ ne doit pas retourner smtp_password.
|
||||||
|
"""
|
||||||
|
token = str(RefreshToken.for_user(self.user).access_token)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||||
|
response = self.client.get(self.url, {'establishment_id': self.est.id})
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
data = response.json()
|
||||||
|
# Le mot de passe ne doit pas être dans la réponse (write_only)
|
||||||
|
self.assertNotIn(
|
||||||
|
'smtp_password', data,
|
||||||
|
"smtp_password ne doit pas être exposé dans les réponses API (OWASP A02 - Cryptographic Failures)"
|
||||||
|
)
|
||||||
|
# Vérification supplémentaire : la valeur secrète n'est pas dans la réponse brute
|
||||||
|
self.assertNotIn('super_secret_password_123', response.content.decode())
|
||||||
|
|
||||||
|
def test_smtp_password_accepte_en_ecriture(self):
|
||||||
|
"""
|
||||||
|
POST /settings/smtp/ doit accepter smtp_password (write_only ne bloque pas l'écriture).
|
||||||
|
"""
|
||||||
|
token = str(RefreshToken.for_user(self.user).access_token)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||||
|
payload = {
|
||||||
|
'establishment': self.est.id,
|
||||||
|
'smtp_server': 'smtp.newserver.com',
|
||||||
|
'smtp_port': 465,
|
||||||
|
'smtp_user': 'new@example.com',
|
||||||
|
'smtp_password': 'nouveau_mot_de_passe',
|
||||||
|
'use_tls': False,
|
||||||
|
'use_ssl': True,
|
||||||
|
}
|
||||||
|
from rest_framework.test import APIRequestFactory
|
||||||
|
response = self.client.post(self.url, data=payload, format='json')
|
||||||
|
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_201_CREATED])
|
||||||
@ -5,8 +5,10 @@ from .serializers import SMTPSettingsSerializer
|
|||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
|
||||||
class SMTPSettingsView(APIView):
|
class SMTPSettingsView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
"""
|
"""
|
||||||
API pour gérer les paramètres SMTP.
|
API pour gérer les paramètres SMTP.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"activationMailRelance": "Oui",
|
|
||||||
"delaiRelance": "30",
|
|
||||||
"ambiances": [
|
|
||||||
"2-3 ans",
|
|
||||||
"3-6 ans",
|
|
||||||
"6-12 ans"
|
|
||||||
],
|
|
||||||
"genres": [
|
|
||||||
"Fille",
|
|
||||||
"Garçon"
|
|
||||||
],
|
|
||||||
"modesPaiement": [
|
|
||||||
"Chèque",
|
|
||||||
"Virement",
|
|
||||||
"Prélèvement SEPA"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
0
Back-End/Subscriptions/management/__init__.py
Normal file
0
Back-End/Subscriptions/management/__init__.py
Normal file
43
Back-End/Subscriptions/management/commands/test_email.py
Normal file
43
Back-End/Subscriptions/management/commands/test_email.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
Management command pour tester la configuration email Django
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.conf import settings
|
||||||
|
from N3wtSchool.mailManager import getConnection, sendMail
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Test de la configuration email'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--establishment-id', type=int, help='ID de l\'établissement pour test')
|
||||||
|
parser.add_argument('--email', type=str, default='test@example.com', help='Email de destination')
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
self.stdout.write("=== Test de configuration email ===")
|
||||||
|
|
||||||
|
# Affichage de la configuration
|
||||||
|
self.stdout.write(f"EMAIL_HOST: {settings.EMAIL_HOST}")
|
||||||
|
self.stdout.write(f"EMAIL_PORT: {settings.EMAIL_PORT}")
|
||||||
|
self.stdout.write(f"EMAIL_HOST_USER: {settings.EMAIL_HOST_USER}")
|
||||||
|
self.stdout.write(f"EMAIL_HOST_PASSWORD: {settings.EMAIL_HOST_PASSWORD}")
|
||||||
|
self.stdout.write(f"EMAIL_USE_TLS: {settings.EMAIL_USE_TLS}")
|
||||||
|
self.stdout.write(f"EMAIL_USE_SSL: {settings.EMAIL_USE_SSL}")
|
||||||
|
self.stdout.write(f"EMAIL_BACKEND: {settings.EMAIL_BACKEND}")
|
||||||
|
|
||||||
|
# Test 1: Configuration par défaut Django
|
||||||
|
self.stdout.write("\n--- Test : Configuration EMAIL par défaut ---")
|
||||||
|
try:
|
||||||
|
result = send_mail(
|
||||||
|
'Test Django Email',
|
||||||
|
'Ceci est un test de la configuration email par défaut.',
|
||||||
|
settings.EMAIL_HOST_USER,
|
||||||
|
[options['email']],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"✅ Email envoyé avec succès (résultat: {result})"))
|
||||||
|
except Exception as e:
|
||||||
|
self.stdout.write(self.style.ERROR(f"❌ Erreur: {e}"))
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-28 11:14
|
# 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
|
||||||
@ -46,10 +46,12 @@ class Migration(migrations.Migration):
|
|||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='RegistrationSchoolFileTemplate',
|
name='RegistrationSchoolFileTemplate',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.IntegerField(primary_key=True, serialize=False)),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('slug', models.CharField(default='', max_length=255)),
|
('slug', models.CharField(default='', max_length=255)),
|
||||||
('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)),
|
||||||
|
('isValidated', models.BooleanField(default=False)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
@ -92,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')),
|
||||||
@ -153,7 +155,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')),
|
||||||
('name', models.CharField(default='', max_length=255)),
|
('name', models.CharField(default='', max_length=255)),
|
||||||
('description', models.CharField(blank=True, null=True)),
|
('description', models.CharField(blank=True, max_length=500, null=True)),
|
||||||
('is_required', models.BooleanField(default=False)),
|
('is_required', models.BooleanField(default=False)),
|
||||||
('groups', models.ManyToManyField(blank=True, related_name='parent_file_masters', to='Subscriptions.registrationfilegroup')),
|
('groups', models.ManyToManyField(blank=True, related_name='parent_file_masters', to='Subscriptions.registrationfilegroup')),
|
||||||
],
|
],
|
||||||
@ -161,9 +163,12 @@ class Migration(migrations.Migration):
|
|||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='RegistrationSchoolFileMaster',
|
name='RegistrationSchoolFileMaster',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.IntegerField(primary_key=True, serialize=False)),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('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)),
|
||||||
|
('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')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -192,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')),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 5.1.3 on 2025-05-30 07:39
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('Subscriptions', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='registrationparentfilemaster',
|
|
||||||
name='description',
|
|
||||||
field=models.CharField(blank=True, max_length=500, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -2,11 +2,9 @@ from django.db import models
|
|||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
logger = logging.getLogger("SubscriptionModels")
|
logger = logging.getLogger("SubscriptionModels")
|
||||||
|
|
||||||
@ -216,9 +214,28 @@ class RegistrationFileGroup(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.group.name} - {self.id}'
|
return f'{self.group.name} - {self.id}'
|
||||||
|
|
||||||
def registration_file_path(instance, filename):
|
def registration_form_file_upload_to(instance, filename):
|
||||||
# Génère le chemin : registration_files/dossier_rf_{student_id}/filename
|
"""
|
||||||
return f'registration_files/dossier_rf_{instance.student_id}/{filename}'
|
Génère le chemin de stockage pour les fichiers du dossier d'inscription.
|
||||||
|
Structure : Etablissement/dossier_NomEleve_PrenomEleve/filename
|
||||||
|
"""
|
||||||
|
est_name = instance.establishment.name if instance.establishment else "unknown_establishment"
|
||||||
|
student_last = instance.student.last_name if instance.student else "unknown"
|
||||||
|
student_first = instance.student.first_name if instance.student else "unknown"
|
||||||
|
return f"{est_name}/dossier_{student_last}_{student_first}/{filename}"
|
||||||
|
|
||||||
|
def _delete_file_if_exists(file_field):
|
||||||
|
"""
|
||||||
|
Supprime le fichier physique s'il existe.
|
||||||
|
Utile pour éviter les suffixes automatiques Django lors du remplacement.
|
||||||
|
"""
|
||||||
|
if file_field and file_field.name:
|
||||||
|
try:
|
||||||
|
if hasattr(file_field, 'path') and os.path.exists(file_field.path):
|
||||||
|
os.remove(file_field.path)
|
||||||
|
logger.debug(f"Fichier supprimé: {file_field.path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la suppression du fichier {file_field.name}: {e}")
|
||||||
|
|
||||||
class RegistrationForm(models.Model):
|
class RegistrationForm(models.Model):
|
||||||
class RegistrationFormStatus(models.IntegerChoices):
|
class RegistrationFormStatus(models.IntegerChoices):
|
||||||
@ -240,17 +257,17 @@ class RegistrationForm(models.Model):
|
|||||||
notes = models.CharField(max_length=200, blank=True)
|
notes = models.CharField(max_length=200, blank=True)
|
||||||
registration_link_code = models.CharField(max_length=200, default="", blank=True)
|
registration_link_code = models.CharField(max_length=200, default="", blank=True)
|
||||||
registration_file = models.FileField(
|
registration_file = models.FileField(
|
||||||
upload_to=registration_file_path,
|
upload_to=registration_form_file_upload_to,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
sepa_file = models.FileField(
|
sepa_file = models.FileField(
|
||||||
upload_to=registration_file_path,
|
upload_to=registration_form_file_upload_to,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
fusion_file = models.FileField(
|
fusion_file = models.FileField(
|
||||||
upload_to=registration_file_path,
|
upload_to=registration_form_file_upload_to,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
@ -277,32 +294,213 @@ class RegistrationForm(models.Model):
|
|||||||
return "RF_" + self.student.last_name + "_" + self.student.first_name
|
return "RF_" + self.student.last_name + "_" + self.student.first_name
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
# Vérifier si un fichier existant doit être remplacé
|
# Préparer le flag de création / changement de fileGroup
|
||||||
|
was_new = self.pk is None
|
||||||
|
old_fileGroup = None
|
||||||
|
if not was_new:
|
||||||
|
try:
|
||||||
|
old_instance = RegistrationForm.objects.get(pk=self.pk)
|
||||||
|
old_fileGroup = old_instance.fileGroup
|
||||||
|
except RegistrationForm.DoesNotExist:
|
||||||
|
old_fileGroup = None
|
||||||
|
|
||||||
|
# Supprimer les anciens fichiers si remplacés (évite les suffixes Django)
|
||||||
if self.pk: # Si l'objet existe déjà dans la base de données
|
if self.pk: # Si l'objet existe déjà dans la base de données
|
||||||
try:
|
try:
|
||||||
old_instance = RegistrationForm.objects.get(pk=self.pk)
|
old_instance = RegistrationForm.objects.get(pk=self.pk)
|
||||||
|
|
||||||
|
# Gestion du sepa_file
|
||||||
if old_instance.sepa_file and old_instance.sepa_file != self.sepa_file:
|
if old_instance.sepa_file and old_instance.sepa_file != self.sepa_file:
|
||||||
# Supprimer l'ancien fichier
|
_delete_file_if_exists(old_instance.sepa_file)
|
||||||
old_instance.sepa_file.delete(save=False)
|
|
||||||
|
# Gestion du registration_file
|
||||||
|
if old_instance.registration_file and old_instance.registration_file != self.registration_file:
|
||||||
|
_delete_file_if_exists(old_instance.registration_file)
|
||||||
|
|
||||||
|
# Gestion du fusion_file
|
||||||
|
if old_instance.fusion_file and old_instance.fusion_file != self.fusion_file:
|
||||||
|
_delete_file_if_exists(old_instance.fusion_file)
|
||||||
|
|
||||||
except RegistrationForm.DoesNotExist:
|
except RegistrationForm.DoesNotExist:
|
||||||
pass # L'objet n'existe pas encore, rien à supprimer
|
pass # L'objet n'existe pas encore, rien à supprimer
|
||||||
|
|
||||||
# Appeler la méthode save originale
|
# Appeler la méthode save originale
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# Après save : si nouveau ou changement de fileGroup -> créer les templates
|
||||||
|
fileGroup_changed = (self.fileGroup is not None) and (old_fileGroup is None or (old_fileGroup and old_fileGroup.id != self.fileGroup.id))
|
||||||
|
if was_new or fileGroup_changed:
|
||||||
|
try:
|
||||||
|
import Subscriptions.util as util
|
||||||
|
created = util.create_templates_for_registration_form(self)
|
||||||
|
if created:
|
||||||
|
logger.info("Created %d templates for RegistrationForm %s", len(created), self.pk)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error creating templates for RegistrationForm %s: %s", self.pk, e)
|
||||||
|
|
||||||
#############################################################
|
#############################################################
|
||||||
####################### MASTER FILES ########################
|
####################### MASTER FILES ########################
|
||||||
#############################################################
|
#############################################################
|
||||||
|
|
||||||
####### DocuSeal masters (documents école, à signer ou pas) #######
|
####### Formulaires masters (documents école, à signer ou pas) #######
|
||||||
|
def registration_school_file_master_upload_to(instance, filename):
|
||||||
|
# Stocke les fichiers masters dans un dossier dédié
|
||||||
|
# Utilise l'ID si le nom n'est pas encore disponible
|
||||||
|
est_name = None
|
||||||
|
if instance.establishment and instance.establishment.name:
|
||||||
|
est_name = instance.establishment.name
|
||||||
|
else:
|
||||||
|
# fallback si pas d'établissement (devrait être rare)
|
||||||
|
est_name = "unknown_establishment"
|
||||||
|
return f"{est_name}/Formulaires/{filename}"
|
||||||
|
|
||||||
class RegistrationSchoolFileMaster(models.Model):
|
class RegistrationSchoolFileMaster(models.Model):
|
||||||
groups = models.ManyToManyField(RegistrationFileGroup, related_name='school_file_masters', blank=True)
|
groups = models.ManyToManyField(RegistrationFileGroup, related_name='school_file_masters', blank=True)
|
||||||
id = models.IntegerField(primary_key=True)
|
|
||||||
name = models.CharField(max_length=255, default="")
|
name = models.CharField(max_length=255, default="")
|
||||||
is_required = models.BooleanField(default=False)
|
is_required = models.BooleanField(default=False)
|
||||||
|
formMasterData = models.JSONField(default=list, blank=True, null=True)
|
||||||
|
file = models.FileField(
|
||||||
|
upload_to=registration_school_file_master_upload_to,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Fichier du formulaire existant (PDF, DOC, etc.)"
|
||||||
|
)
|
||||||
|
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='school_file_masters', null=True, blank=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.group.name} - {self.id}'
|
return f'{self.name} - {self.id}'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_url(self):
|
||||||
|
if self.file and hasattr(self.file, 'url'):
|
||||||
|
return self.file.url
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
affected_rf_ids = set()
|
||||||
|
is_new = self.pk is None
|
||||||
|
|
||||||
|
# Log création ou modification du master
|
||||||
|
if is_new:
|
||||||
|
logger.info(f"[FormPerso] Création master '{self.name}' pour établissement '{self.establishment}'")
|
||||||
|
else:
|
||||||
|
logger.info(f"[FormPerso] Modification master '{self.name}' (id={self.pk}) pour établissement '{self.establishment}'")
|
||||||
|
|
||||||
|
# --- Suppression de l'ancien fichier master si le nom change (form existant ou dynamique) ---
|
||||||
|
if self.pk:
|
||||||
|
try:
|
||||||
|
old = RegistrationSchoolFileMaster.objects.get(pk=self.pk)
|
||||||
|
if old.file and old.file.name:
|
||||||
|
old_filename = os.path.basename(old.file.name)
|
||||||
|
# Nouveau nom selon le type (dynamique ou existant)
|
||||||
|
if (
|
||||||
|
self.formMasterData
|
||||||
|
and isinstance(self.formMasterData, dict)
|
||||||
|
and self.formMasterData.get("fields")
|
||||||
|
):
|
||||||
|
new_filename = f"{self.name}.pdf"
|
||||||
|
else:
|
||||||
|
# Pour les forms existants, le nom attendu est self.name + extension du fichier existant
|
||||||
|
extension = os.path.splitext(old_filename)[1]
|
||||||
|
new_filename = f"{self.name}{extension}" if extension else self.name
|
||||||
|
if new_filename and old_filename != new_filename:
|
||||||
|
old_file_path = old.file.path
|
||||||
|
if os.path.exists(old_file_path):
|
||||||
|
try:
|
||||||
|
os.remove(old_file_path)
|
||||||
|
logger.info(f"[FormPerso] Suppression de l'ancien fichier master: {old_file_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[FormPerso] Erreur suppression ancien fichier master: {e}")
|
||||||
|
# Correction du nom du fichier pour éviter le suffixe random
|
||||||
|
if (
|
||||||
|
not self.formMasterData
|
||||||
|
or not (isinstance(self.formMasterData, dict) and self.formMasterData.get("fields"))
|
||||||
|
):
|
||||||
|
# Si le fichier existe et le nom ne correspond pas, renommer le fichier physique et mettre à jour le FileField
|
||||||
|
if self.file and self.file.name:
|
||||||
|
current_filename = os.path.basename(self.file.name)
|
||||||
|
current_path = self.file.path
|
||||||
|
expected_filename = new_filename
|
||||||
|
expected_path = os.path.join(os.path.dirname(current_path), expected_filename)
|
||||||
|
if current_filename != expected_filename:
|
||||||
|
try:
|
||||||
|
if os.path.exists(current_path):
|
||||||
|
os.rename(current_path, expected_path)
|
||||||
|
self.file.name = os.path.join(os.path.dirname(self.file.name), expected_filename).replace("\\", "/")
|
||||||
|
logger.info(f"[FormPerso] Renommage du fichier master: {current_path} -> {expected_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[FormPerso] Erreur lors du renommage du fichier master: {e}")
|
||||||
|
except RegistrationSchoolFileMaster.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- Traitement PDF dynamique AVANT le super().save() ---
|
||||||
|
if (
|
||||||
|
self.formMasterData
|
||||||
|
and isinstance(self.formMasterData, dict)
|
||||||
|
and self.formMasterData.get("fields")
|
||||||
|
):
|
||||||
|
from Subscriptions.util import generate_form_json_pdf
|
||||||
|
pdf_filename = f"{self.name}.pdf"
|
||||||
|
pdf_file = generate_form_json_pdf(self, self.formMasterData)
|
||||||
|
self.file.save(pdf_filename, pdf_file, save=False)
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# Synchronisation des templates pour tous les dossiers d'inscription concernés (création ou modification)
|
||||||
|
try:
|
||||||
|
# Import local pour éviter le circular import
|
||||||
|
from Subscriptions.util import create_templates_for_registration_form
|
||||||
|
from Subscriptions.models import RegistrationForm, RegistrationSchoolFileTemplate
|
||||||
|
# Détermination des RF concernés
|
||||||
|
if is_new:
|
||||||
|
new_groups = set(self.groups.values_list('id', flat=True))
|
||||||
|
affected_rf_ids.update(
|
||||||
|
RegistrationForm.objects.filter(fileGroup__in=list(new_groups)).values_list('pk', flat=True)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
old = RegistrationSchoolFileMaster.objects.get(pk=self.pk)
|
||||||
|
old_groups = set(old.groups.values_list('id', flat=True))
|
||||||
|
new_groups = set(self.groups.values_list('id', flat=True))
|
||||||
|
affected_rf_ids.update(
|
||||||
|
RegistrationForm.objects.filter(fileGroup__in=list(old_groups | new_groups)).values_list('pk', flat=True)
|
||||||
|
)
|
||||||
|
form_data_changed = (
|
||||||
|
old.formMasterData != self.formMasterData
|
||||||
|
and self.formMasterData
|
||||||
|
and isinstance(self.formMasterData, dict)
|
||||||
|
and self.formMasterData.get("fields")
|
||||||
|
)
|
||||||
|
name_changed = old.name != self.name
|
||||||
|
if form_data_changed or name_changed:
|
||||||
|
logger.info(f"[FormPerso] Modification du contenu du master '{self.name}' (id={self.pk})")
|
||||||
|
except RegistrationSchoolFileMaster.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Pour chaque RF concerné, régénérer les templates
|
||||||
|
for rf_id in affected_rf_ids:
|
||||||
|
try:
|
||||||
|
rf = RegistrationForm.objects.get(pk=rf_id)
|
||||||
|
logger.info(f"[FormPerso] Synchronisation template pour élève '{rf.student.last_name}_{rf.student.first_name}' (RF id={rf.pk}) suite à modification/ajout du master '{self.name}'")
|
||||||
|
create_templates_for_registration_form(rf)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la synchronisation des templates pour RF {rf_id} après modification du master {self.pk}: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur globale lors de la synchronisation des templates après modification du master {self.pk}: {e}")
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
logger.info(f"[FormPerso] Suppression master '{self.name}' (id={self.pk}) et tous ses templates")
|
||||||
|
# Import local pour éviter le circular import
|
||||||
|
from Subscriptions.models import RegistrationSchoolFileTemplate
|
||||||
|
templates = RegistrationSchoolFileTemplate.objects.filter(master=self)
|
||||||
|
for tmpl in templates:
|
||||||
|
logger.info(f"[FormPerso] Suppression template '{tmpl.name}' pour élève '{tmpl.registration_form.student.last_name}_{tmpl.registration_form.student.first_name}' (RF id={tmpl.registration_form.pk})")
|
||||||
|
if self.file and hasattr(self.file, 'path') and os.path.exists(self.file.path):
|
||||||
|
try:
|
||||||
|
self.file.delete(save=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la suppression du fichier master: {e}")
|
||||||
|
super().delete(*args, **kwargs)
|
||||||
|
|
||||||
####### Parent files masters (documents à fournir par les parents) #######
|
####### Parent files masters (documents à fournir par les parents) #######
|
||||||
class RegistrationParentFileMaster(models.Model):
|
class RegistrationParentFileMaster(models.Model):
|
||||||
@ -316,32 +514,57 @@ class RegistrationParentFileMaster(models.Model):
|
|||||||
############################################################
|
############################################################
|
||||||
|
|
||||||
def registration_school_file_upload_to(instance, filename):
|
def registration_school_file_upload_to(instance, filename):
|
||||||
return f"registration_files/dossier_rf_{instance.registration_form.pk}/school/{filename}"
|
"""
|
||||||
|
Génère le chemin pour les fichiers templates école.
|
||||||
|
Structure : Etablissement/dossier_NomEleve_PrenomEleve/filename
|
||||||
|
"""
|
||||||
|
return f"{instance.registration_form.establishment.name}/dossier_{instance.registration_form.student.last_name}_{instance.registration_form.student.first_name}/{filename}"
|
||||||
|
|
||||||
def registration_parent_file_upload_to(instance, filename):
|
def registration_parent_file_upload_to(instance, filename):
|
||||||
return f"registration_files/dossier_rf_{instance.registration_form.pk}/parent/{filename}"
|
"""
|
||||||
|
Génère le chemin pour les fichiers à fournir par les parents.
|
||||||
|
Structure : Etablissement/dossier_NomEleve_PrenomEleve/parent/filename
|
||||||
|
"""
|
||||||
|
return f"{instance.registration_form.establishment.name}/dossier_{instance.registration_form.student.last_name}_{instance.registration_form.student.first_name}/parent/{filename}"
|
||||||
|
|
||||||
####### DocuSeal templates (par dossier d'inscription) #######
|
####### Formulaires templates (par dossier d'inscription) #######
|
||||||
class RegistrationSchoolFileTemplate(models.Model):
|
class RegistrationSchoolFileTemplate(models.Model):
|
||||||
master = models.ForeignKey(RegistrationSchoolFileMaster, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
|
master = models.ForeignKey(RegistrationSchoolFileMaster, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
|
||||||
id = models.IntegerField(primary_key=True)
|
|
||||||
slug = models.CharField(max_length=255, default="")
|
slug = models.CharField(max_length=255, default="")
|
||||||
name = models.CharField(max_length=255, default="")
|
name = models.CharField(max_length=255, default="")
|
||||||
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
|
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
|
||||||
file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to)
|
file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to)
|
||||||
|
formTemplateData = models.JSONField(default=list, blank=True, null=True)
|
||||||
|
isValidated = models.BooleanField(default=False)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Supprimer l'ancien fichier si remplacé (évite les suffixes Django)
|
||||||
|
if self.pk:
|
||||||
|
try:
|
||||||
|
old_instance = RegistrationSchoolFileTemplate.objects.get(pk=self.pk)
|
||||||
|
if old_instance.file and old_instance.file != self.file:
|
||||||
|
_delete_file_if_exists(old_instance.file)
|
||||||
|
except RegistrationSchoolFileTemplate.DoesNotExist:
|
||||||
|
pass
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_files_from_rf(register_form_id):
|
def get_files_from_rf(register_form_id):
|
||||||
"""
|
"""
|
||||||
Récupère tous les fichiers liés à un dossier d’inscription donné.
|
Récupère tous les fichiers liés à un dossier d’inscription donné.
|
||||||
|
Ignore les fichiers qui n'existent pas physiquement.
|
||||||
"""
|
"""
|
||||||
registration_files = RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form_id)
|
registration_files = RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form_id)
|
||||||
filenames = []
|
filenames = []
|
||||||
for reg_file in registration_files:
|
for reg_file in registration_files:
|
||||||
filenames.append(reg_file.file.path)
|
if reg_file.file and hasattr(reg_file.file, 'path'):
|
||||||
|
if os.path.exists(reg_file.file.path):
|
||||||
|
filenames.append(reg_file.file.path)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Fichier introuvable ignoré: {reg_file.file.path}")
|
||||||
return filenames
|
return filenames
|
||||||
|
|
||||||
class StudentCompetency(models.Model):
|
class StudentCompetency(models.Model):
|
||||||
@ -371,22 +594,24 @@ class RegistrationParentFileTemplate(models.Model):
|
|||||||
master = models.ForeignKey(RegistrationParentFileMaster, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
|
master = models.ForeignKey(RegistrationParentFileMaster, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
|
||||||
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
|
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
|
||||||
file = models.FileField(null=True,blank=True, upload_to=registration_parent_file_upload_to)
|
file = models.FileField(null=True,blank=True, upload_to=registration_parent_file_upload_to)
|
||||||
|
isValidated = models.BooleanField(default=False)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.master.name if self.master else f"ParentFile_{self.pk}"
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.pk: # Si l'objet existe déjà dans la base de données
|
# Supprimer l'ancien fichier si remplacé (évite les suffixes Django)
|
||||||
|
if self.pk:
|
||||||
try:
|
try:
|
||||||
old_instance = RegistrationParentFileTemplate.objects.get(pk=self.pk)
|
old_instance = RegistrationParentFileTemplate.objects.get(pk=self.pk)
|
||||||
if old_instance.file and (not self.file or self.file.name == ''):
|
# Si le fichier change ou est supprimé
|
||||||
if os.path.exists(old_instance.file.path):
|
if old_instance.file:
|
||||||
old_instance.file.delete(save=False)
|
if old_instance.file != self.file or not self.file or self.file.name == '':
|
||||||
self.file = None
|
_delete_file_if_exists(old_instance.file)
|
||||||
else:
|
if not self.file or self.file.name == '':
|
||||||
print(f"Le fichier {old_instance.file.path} n'existe pas.")
|
self.file = None
|
||||||
except RegistrationParentFileTemplate.DoesNotExist:
|
except RegistrationParentFileTemplate.DoesNotExist:
|
||||||
print("Ancienne instance introuvable.")
|
pass
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import (
|
from .models import (
|
||||||
RegistrationFileGroup,
|
RegistrationFileGroup,
|
||||||
RegistrationForm,
|
RegistrationForm,
|
||||||
Student,
|
Student,
|
||||||
Guardian,
|
Guardian,
|
||||||
Sibling,
|
Sibling,
|
||||||
Language,
|
Language,
|
||||||
RegistrationSchoolFileMaster,
|
RegistrationSchoolFileMaster,
|
||||||
RegistrationSchoolFileTemplate,
|
RegistrationSchoolFileTemplate,
|
||||||
RegistrationParentFileMaster,
|
RegistrationParentFileMaster,
|
||||||
RegistrationParentFileTemplate,
|
RegistrationParentFileTemplate,
|
||||||
AbsenceManagement,
|
AbsenceManagement,
|
||||||
BilanCompetence
|
BilanCompetence
|
||||||
)
|
)
|
||||||
@ -21,6 +21,7 @@ from N3wtSchool import settings
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
import pytz
|
import pytz
|
||||||
import Subscriptions.util as util
|
import Subscriptions.util as util
|
||||||
|
from N3wtSchool.mailManager import sendRegisterForm
|
||||||
|
|
||||||
class AbsenceManagementSerializer(serializers.ModelSerializer):
|
class AbsenceManagementSerializer(serializers.ModelSerializer):
|
||||||
student_name = serializers.SerializerMethodField()
|
student_name = serializers.SerializerMethodField()
|
||||||
@ -95,7 +96,7 @@ class RegistrationFormSimpleSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = RegistrationForm
|
model = RegistrationForm
|
||||||
fields = ['student_id', 'last_name', 'first_name', 'guardians']
|
fields = ['student_id', 'last_name', 'first_name', 'guardians']
|
||||||
|
|
||||||
def get_last_name(self, obj):
|
def get_last_name(self, obj):
|
||||||
return obj.student.last_name
|
return obj.student.last_name
|
||||||
|
|
||||||
@ -164,12 +165,20 @@ class StudentSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
if guardian_id:
|
if guardian_id:
|
||||||
# Si un ID est fourni, récupérer ou mettre à jour le Guardian existant
|
# Si un ID est fourni, récupérer ou mettre à jour le Guardian existant
|
||||||
guardian_instance, created = Guardian.objects.update_or_create(
|
try:
|
||||||
id=guardian_id,
|
guardian_instance = Guardian.objects.get(id=guardian_id)
|
||||||
defaults=guardian_data
|
# Mettre à jour explicitement tous les champs y compris birth_date, profession, address
|
||||||
)
|
for field, value in guardian_data.items():
|
||||||
guardians_ids.append(guardian_instance.id)
|
if field != 'id': # Ne pas mettre à jour l'ID
|
||||||
continue
|
setattr(guardian_instance, field, value)
|
||||||
|
guardian_instance.save()
|
||||||
|
guardians_ids.append(guardian_instance.id)
|
||||||
|
continue
|
||||||
|
except Guardian.DoesNotExist:
|
||||||
|
# Si le guardian n'existe pas, créer un nouveau
|
||||||
|
guardian_instance = Guardian.objects.create(**guardian_data)
|
||||||
|
guardians_ids.append(guardian_instance.id)
|
||||||
|
continue
|
||||||
|
|
||||||
if profile_role_data:
|
if profile_role_data:
|
||||||
# Vérifiez si 'profile_data' est fourni pour créer un nouveau profil
|
# Vérifiez si 'profile_data' est fourni pour créer un nouveau profil
|
||||||
@ -207,6 +216,14 @@ class StudentSerializer(serializers.ModelSerializer):
|
|||||||
profile_role_serializer = ProfileRoleSerializer(data=profile_role_data)
|
profile_role_serializer = ProfileRoleSerializer(data=profile_role_data)
|
||||||
profile_role_serializer.is_valid(raise_exception=True)
|
profile_role_serializer.is_valid(raise_exception=True)
|
||||||
profile_role = profile_role_serializer.save()
|
profile_role = profile_role_serializer.save()
|
||||||
|
# Envoi du mail d'inscription si un nouveau profil vient d'être créé
|
||||||
|
email = None
|
||||||
|
if profile_data and 'email' in profile_data:
|
||||||
|
email = profile_data['email']
|
||||||
|
elif profile_role and profile_role.profile:
|
||||||
|
email = profile_role.profile.email
|
||||||
|
if email:
|
||||||
|
sendRegisterForm(email, establishment_id)
|
||||||
elif profile_role:
|
elif profile_role:
|
||||||
# Récupérer un ProfileRole existant par son ID
|
# Récupérer un ProfileRole existant par son ID
|
||||||
profile_role = ProfileRole.objects.get(id=profile_role.id)
|
profile_role = ProfileRole.objects.get(id=profile_role.id)
|
||||||
@ -435,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)
|
||||||
@ -449,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()
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,8 @@ from Subscriptions.automate import Automate_RF_Register, updateStateMachine
|
|||||||
from .models import RegistrationForm
|
from .models import RegistrationForm
|
||||||
from GestionMessagerie.models import Messagerie
|
from GestionMessagerie.models import Messagerie
|
||||||
from N3wtSchool import settings, bdd
|
from N3wtSchool import settings, bdd
|
||||||
|
from N3wtSchool.mailManager import sendMail, getConnection
|
||||||
|
from django.template.loader import render_to_string
|
||||||
import requests
|
import requests
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -26,17 +28,82 @@ def send_notification(dossier):
|
|||||||
# Changer l'état de l'automate
|
# Changer l'état de l'automate
|
||||||
updateStateMachine(dossier, 'EVENT_FOLLOW_UP')
|
updateStateMachine(dossier, 'EVENT_FOLLOW_UP')
|
||||||
|
|
||||||
url = settings.URL_DJANGO + 'GestionMessagerie/message'
|
# Envoyer un email de relance aux responsables
|
||||||
|
try:
|
||||||
|
# Récupérer l'établissement du dossier
|
||||||
|
establishment_id = dossier.establishment.id
|
||||||
|
|
||||||
destinataires = dossier.eleve.profiles.all()
|
# Obtenir la connexion SMTP pour cet établissement
|
||||||
for destinataire in destinataires:
|
connection = getConnection(establishment_id)
|
||||||
message = {
|
|
||||||
"objet": "[RELANCE]",
|
# Préparer le contenu de l'email
|
||||||
"destinataire" : destinataire.id,
|
subject = f"[RELANCE] Dossier d'inscription en attente - {dossier.eleve.first_name} {dossier.eleve.last_name}"
|
||||||
"corpus": "RELANCE pour le dossier d'inscription"
|
|
||||||
|
context = {
|
||||||
|
'student_name': f"{dossier.eleve.first_name} {dossier.eleve.last_name}",
|
||||||
|
'deadline_date': (timezone.now() - timezone.timedelta(days=settings.EXPIRATION_DI_NB_DAYS)).strftime('%d/%m/%Y'),
|
||||||
|
'establishment_name': dossier.establishment.name,
|
||||||
|
'base_url': settings.BASE_URL
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(url, json=message)
|
# Utiliser un template HTML pour l'email (si disponible)
|
||||||
|
try:
|
||||||
|
html_message = render_to_string('emails/relance_signature.html', context)
|
||||||
|
except:
|
||||||
|
# Si pas de template, message simple
|
||||||
|
html_message = f"""
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>Relance - Dossier d'inscription en attente</h2>
|
||||||
|
<p>Bonjour,</p>
|
||||||
|
<p>Le dossier d'inscription de <strong>{context['student_name']}</strong> est en attente de signature depuis plus de {settings.EXPIRATION_DI_NB_DAYS} jours.</p>
|
||||||
|
<p>Merci de vous connecter à votre espace pour finaliser l'inscription.</p>
|
||||||
|
<p>Cordialement,<br>L'équipe {context['establishment_name']}</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Récupérer les emails des responsables
|
||||||
|
destinataires = []
|
||||||
|
profiles = dossier.eleve.profiles.all()
|
||||||
|
for profile in profiles:
|
||||||
|
if profile.email:
|
||||||
|
destinataires.append(profile.email)
|
||||||
|
|
||||||
|
if destinataires:
|
||||||
|
# Envoyer l'email
|
||||||
|
result = sendMail(
|
||||||
|
subject=subject,
|
||||||
|
message=html_message,
|
||||||
|
recipients=destinataires,
|
||||||
|
connection=connection
|
||||||
|
)
|
||||||
|
logger.info(f"Email de relance envoyé pour le dossier {dossier.id} à {destinataires}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Aucun email trouvé pour les responsables du dossier {dossier.id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de l'envoi de l'email de relance pour le dossier {dossier.id}: {str(e)}")
|
||||||
|
|
||||||
|
# En cas d'erreur email, utiliser la messagerie interne comme fallback
|
||||||
|
try:
|
||||||
|
url = settings.URL_DJANGO + 'GestionMessagerie/send-message/'
|
||||||
|
|
||||||
|
# Créer ou récupérer une conversation avec chaque responsable
|
||||||
|
destinataires = dossier.eleve.profiles.all()
|
||||||
|
for destinataire in destinataires:
|
||||||
|
message_data = {
|
||||||
|
"conversation_id": None, # Sera géré par l'API
|
||||||
|
"sender_id": 1, # ID du système ou admin
|
||||||
|
"content": f"RELANCE pour le dossier d'inscription de {dossier.eleve.first_name} {dossier.eleve.last_name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, json=message_data)
|
||||||
|
if response.status_code != 201:
|
||||||
|
logger.error(f"Erreur lors de l'envoi du message interne: {response.text}")
|
||||||
|
|
||||||
|
except Exception as inner_e:
|
||||||
|
logger.error(f"Erreur lors de l'envoi du message interne de fallback: {str(inner_e)}")
|
||||||
|
|
||||||
# subject = f"Dossier d'inscription non signé - {dossier.objet}"
|
# subject = f"Dossier d'inscription non signé - {dossier.objet}"
|
||||||
# message = f"Le dossier d'inscription avec l'objet '{dossier.objet}' n'a pas été signé depuis {dossier.created_at}."
|
# message = f"Le dossier d'inscription avec l'objet '{dossier.objet}' n'a pas été signé depuis {dossier.created_at}."
|
||||||
|
|||||||
@ -0,0 +1,63 @@
|
|||||||
|
<!-- Nouveau template pour l'inscription d'un enseignant -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Bienvenue sur N3wt School</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 120px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<!-- Utilisation d'un lien absolu pour le logo -->
|
||||||
|
<img src="{{URL_DJANGO}}/static/img/logo_min.svg" alt="Logo N3wt School" class="logo" style="display:block;margin:auto;" />
|
||||||
|
<h1>Bienvenue sur N3wt School</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Bonjour,</p>
|
||||||
|
<p>Votre compte enseignant a été créé sur la plateforme N3wt School.</p>
|
||||||
|
<p>Pour accéder à votre espace personnel, veuillez vous connecter à l'adresse suivante :<br>
|
||||||
|
<a href="{{BASE_URL}}/users/login">{{BASE_URL}}/users/login</a>
|
||||||
|
</p>
|
||||||
|
<p>Votre identifiant est : <b>{{ email }}</b></p>
|
||||||
|
<p>Si c'est votre première connexion, veuillez activer votre compte ici :<br>
|
||||||
|
<a href="{{BASE_URL}}/users/subscribe?establishment_id={{establishment}}">{{BASE_URL}}/users/subscribe</a>
|
||||||
|
</p>
|
||||||
|
<p>Nous vous souhaitons une excellente prise en main de l'outil.<br>
|
||||||
|
L'équipe N3wt School reste à votre disposition pour toute question.</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
66
Back-End/Subscriptions/templates/emails/refus_definitif.html
Normal file
66
Back-End/Subscriptions/templates/emails/refus_definitif.html
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Dossier d'inscription refusé</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.notes {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<img src="{{URL_DJANGO}}static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
|
||||||
|
<h1>Dossier d'inscription refusé</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Bonjour,</p>
|
||||||
|
<p>Nous avons le regret de vous informer que le dossier d'inscription de <strong>{{ student_name }}</strong> n'a pas été retenu.</p>
|
||||||
|
|
||||||
|
<div class="notes">
|
||||||
|
<strong>Motif(s) :</strong><br>
|
||||||
|
{{ notes }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Nous vous remercions de l'intérêt que vous avez porté à notre établissement et restons à votre disposition pour tout renseignement complémentaire.</p>
|
||||||
|
|
||||||
|
<p>Cordialement,</p>
|
||||||
|
<p>L'équipe N3wt School</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
67
Back-End/Subscriptions/templates/emails/refus_dossier.html
Normal file
67
Back-End/Subscriptions/templates/emails/refus_dossier.html
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Dossier d'inscription - Corrections requises</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.notes {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<img src="{{URL_DJANGO}}static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
|
||||||
|
<h1>Dossier d'inscription - Corrections requises</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Bonjour,</p>
|
||||||
|
<p>Nous avons examiné le dossier d'inscription de <strong>{{ student_name }}</strong> et certaines corrections sont nécessaires avant de pouvoir le valider.</p>
|
||||||
|
|
||||||
|
<div class="notes">
|
||||||
|
<strong>Motif(s) :</strong><br>
|
||||||
|
{{ notes }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Veuillez vous connecter à votre espace pour effectuer les corrections demandées :</p>
|
||||||
|
<p><a href="{{BASE_URL}}/users/login">{{BASE_URL}}/users/login</a></p>
|
||||||
|
|
||||||
|
<p>Cordialement,</p>
|
||||||
|
<p>L'équipe N3wt School</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
121
Back-End/Subscriptions/templates/emails/relance_signature.html
Normal file
121
Back-End/Subscriptions/templates/emails/relance_signature.html
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Relance - Dossier d'inscription</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 3px solid #007bff;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
color: #007bff;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.alert {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.alert-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.student-info {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-left: 4px solid #007bff;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.cta-button {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 25px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #dee2e6;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>{{ establishment_name }}</h1>
|
||||||
|
<p>Relance - Dossier d'inscription</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert">
|
||||||
|
<span class="alert-icon">⚠️</span>
|
||||||
|
<strong>Attention :</strong> Votre dossier d'inscription nécessite votre attention
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>Bonjour,</p>
|
||||||
|
|
||||||
|
<div class="student-info">
|
||||||
|
<h3>Dossier d'inscription de : <strong>{{ student_name }}</strong></h3>
|
||||||
|
<p>En attente depuis le : <strong>{{ deadline_date }}</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Nous vous informons que le dossier d'inscription mentionné ci-dessus est en attente de finalisation depuis plus de {{ deadline_date }}.</p>
|
||||||
|
|
||||||
|
<p><strong>Action requise :</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Connectez-vous à votre espace personnel</li>
|
||||||
|
<li>Vérifiez les documents manquants</li>
|
||||||
|
<li>Complétez et signez les formulaires en attente</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<a href="{{ base_url }}" class="cta-button">Accéder à mon espace</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Si vous rencontrez des difficultés ou avez des questions concernant ce dossier, n'hésitez pas à nous contacter.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Cordialement,<br>
|
||||||
|
L'équipe {{ establishment_name }}</p>
|
||||||
|
|
||||||
|
<hr style="margin: 20px 0;">
|
||||||
|
|
||||||
|
<p style="font-size: 12px;">
|
||||||
|
Cet email a été envoyé automatiquement. Si vous pensez avoir reçu ce message par erreur,
|
||||||
|
veuillez contacter l'établissement directement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Dossier d'inscription validé</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.success-box {
|
||||||
|
background-color: #d4edda;
|
||||||
|
border: 1px solid #28a745;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.success-box h2 {
|
||||||
|
color: #155724;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.class-info {
|
||||||
|
background-color: #e7f3ff;
|
||||||
|
border: 1px solid #0066cc;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<img src="{{URL_DJANGO}}static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
|
||||||
|
<h1>Dossier d'inscription validé</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Bonjour,</p>
|
||||||
|
|
||||||
|
<div class="success-box">
|
||||||
|
<h2>Félicitations !</h2>
|
||||||
|
<p>Le dossier d'inscription de <strong>{{ student_name }}</strong> a été validé.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if class_name %}
|
||||||
|
<div class="class-info">
|
||||||
|
<strong>Classe attribuée :</strong> {{ class_name }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p>Vous pouvez accéder à votre espace pour consulter les détails :</p>
|
||||||
|
<p><a href="{{BASE_URL}}/users/login">{{BASE_URL}}/users/login</a></p>
|
||||||
|
|
||||||
|
<p>Nous vous remercions de votre confiance et vous souhaitons une excellente année scolaire.</p>
|
||||||
|
|
||||||
|
<p>Cordialement,</p>
|
||||||
|
<p>L'équipe N3wt School</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -24,7 +24,10 @@ from .views import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .views import RegistrationFileGroupView, RegistrationFileGroupSimpleView, get_registration_files_by_group
|
from .views import RegistrationFileGroupView, RegistrationFileGroupSimpleView, get_registration_files_by_group
|
||||||
from .views import registration_file_views, get_school_file_templates_by_rf, get_parent_file_templates_by_rf
|
from .views import (
|
||||||
|
get_school_file_templates_by_rf,
|
||||||
|
get_parent_file_templates_by_rf
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
re_path(r'^registerForms/(?P<id>[0-9]+)/archive$', archive, name="archive"),
|
re_path(r'^registerForms/(?P<id>[0-9]+)/archive$', archive, name="archive"),
|
||||||
|
|||||||
@ -8,6 +8,9 @@ from N3wtSchool import renderers
|
|||||||
from N3wtSchool import bdd
|
from N3wtSchool import bdd
|
||||||
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from reportlab.pdfgen import canvas
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
@ -16,13 +19,279 @@ from enum import Enum
|
|||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
from rest_framework.parsers import JSONParser
|
from rest_framework.parsers import JSONParser
|
||||||
from PyPDF2 import PdfMerger
|
from PyPDF2 import PdfMerger, PdfReader
|
||||||
|
from PyPDF2.errors import PdfReadError
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import json
|
||||||
|
from django.http import QueryDict
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def save_file_replacing_existing(file_field, filename, content, save=True):
|
||||||
|
"""
|
||||||
|
Sauvegarde un fichier en écrasant l'existant s'il porte le même nom.
|
||||||
|
Évite les suffixes automatiques Django (ex: fichier_N5QdZpk.pdf).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_field: Le FileField Django (ex: registerForm.registration_file)
|
||||||
|
filename: Le nom du fichier à sauvegarder
|
||||||
|
content: Le contenu du fichier (File, BytesIO, ContentFile, etc.)
|
||||||
|
save: Si True, sauvegarde l'instance parente
|
||||||
|
"""
|
||||||
|
# Supprimer l'ancien fichier s'il existe
|
||||||
|
if file_field and file_field.name:
|
||||||
|
try:
|
||||||
|
if hasattr(file_field, 'path') and os.path.exists(file_field.path):
|
||||||
|
os.remove(file_field.path)
|
||||||
|
logger.debug(f"[save_file] Ancien fichier supprimé: {file_field.path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[save_file] Erreur suppression ancien fichier: {e}")
|
||||||
|
|
||||||
|
# Sauvegarder le nouveau fichier
|
||||||
|
file_field.save(filename, content, save=save)
|
||||||
|
|
||||||
|
def build_payload_from_request(request):
|
||||||
|
"""
|
||||||
|
Normalise la request en payload prêt à être donné au serializer.
|
||||||
|
- supporte multipart/form-data où le front envoie 'data' (JSON string) ou un fichier JSON + fichiers
|
||||||
|
- supporte application/json ou form-data simple
|
||||||
|
Retour: (payload_dict, None) ou (None, Response erreur)
|
||||||
|
"""
|
||||||
|
# Si c'est du JSON pur (Content-Type: application/json)
|
||||||
|
if hasattr(request, 'content_type') and 'application/json' in request.content_type:
|
||||||
|
try:
|
||||||
|
# request.data contient déjà le JSON parsé par Django REST
|
||||||
|
payload = dict(request.data) if hasattr(request.data, 'items') else request.data
|
||||||
|
logger.info(f"JSON payload extracted: {payload}")
|
||||||
|
return payload, None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Error processing JSON: {e}')
|
||||||
|
return None, Response({'error': "Invalid JSON", 'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Cas multipart/form-data avec champ 'data'
|
||||||
|
data_field = request.data.get('data') if hasattr(request.data, 'get') else None
|
||||||
|
if data_field:
|
||||||
|
try:
|
||||||
|
# Si 'data' est un fichier (InMemoryUploadedFile ou fichier similaire), lire et décoder
|
||||||
|
if hasattr(data_field, 'read'):
|
||||||
|
raw = data_field.read()
|
||||||
|
if isinstance(raw, (bytes, bytearray)):
|
||||||
|
text = raw.decode('utf-8')
|
||||||
|
else:
|
||||||
|
text = raw
|
||||||
|
payload = json.loads(text)
|
||||||
|
# Si 'data' est bytes déjà
|
||||||
|
elif isinstance(data_field, (bytes, bytearray)):
|
||||||
|
payload = json.loads(data_field.decode('utf-8'))
|
||||||
|
# Si 'data' est une string JSON
|
||||||
|
elif isinstance(data_field, str):
|
||||||
|
payload = json.loads(data_field)
|
||||||
|
else:
|
||||||
|
# type inattendu
|
||||||
|
raise ValueError(f"Unsupported 'data' type: {type(data_field)}")
|
||||||
|
except (json.JSONDecodeError, ValueError, UnicodeDecodeError) as e:
|
||||||
|
logger.error(f'Invalid JSON in "data": {e}')
|
||||||
|
return None, Response({'error': "Invalid JSON in 'data'", 'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
else:
|
||||||
|
payload = request.data.copy() if hasattr(request.data, 'copy') else dict(request.data)
|
||||||
|
if isinstance(payload, QueryDict):
|
||||||
|
payload = payload.dict()
|
||||||
|
|
||||||
|
# Attacher les fichiers présents (ex: photo, files.*, etc.), sauf 'data' (déjà traité)
|
||||||
|
for f_key, f_val in request.FILES.items():
|
||||||
|
if f_key == 'data':
|
||||||
|
# remettre le pointeur au début si besoin (déjà lu) — non indispensable ici mais sûr
|
||||||
|
try:
|
||||||
|
f_val.seek(0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# ne pas mettre le fichier 'data' dans le payload (c'est le JSON)
|
||||||
|
continue
|
||||||
|
payload[f_key] = f_val
|
||||||
|
|
||||||
|
return payload, None
|
||||||
|
|
||||||
|
def create_templates_for_registration_form(register_form):
|
||||||
|
"""
|
||||||
|
Idempotent:
|
||||||
|
- supprime les templates existants qui ne correspondent pas
|
||||||
|
aux masters du fileGroup courant du register_form (et supprime leurs fichiers).
|
||||||
|
- crée les templates manquants pour les masters du fileGroup courant.
|
||||||
|
Retourne la liste des templates créés.
|
||||||
|
"""
|
||||||
|
from Subscriptions.models import (
|
||||||
|
RegistrationSchoolFileMaster,
|
||||||
|
RegistrationSchoolFileTemplate,
|
||||||
|
RegistrationParentFileMaster,
|
||||||
|
RegistrationParentFileTemplate,
|
||||||
|
registration_school_file_upload_to,
|
||||||
|
)
|
||||||
|
|
||||||
|
created = []
|
||||||
|
logger.info("util.create_templates_for_registration_form - create_templates_for_registration_form")
|
||||||
|
|
||||||
|
# Récupérer les masters du fileGroup courant
|
||||||
|
current_group = getattr(register_form, "fileGroup", None)
|
||||||
|
if not current_group:
|
||||||
|
# Si plus de fileGroup, supprimer tous les templates existants pour ce RF
|
||||||
|
school_existing = RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form)
|
||||||
|
for t in school_existing:
|
||||||
|
try:
|
||||||
|
if getattr(t, "file", None):
|
||||||
|
logger.info("Deleted school template %s for RF %s", t.pk, register_form.pk)
|
||||||
|
t.file.delete(save=False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Erreur suppression fichier school template %s", getattr(t, "pk", None))
|
||||||
|
t.delete()
|
||||||
|
parent_existing = RegistrationParentFileTemplate.objects.filter(registration_form=register_form)
|
||||||
|
for t in parent_existing:
|
||||||
|
try:
|
||||||
|
if getattr(t, "file", None):
|
||||||
|
t.file.delete(save=False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Erreur suppression fichier parent template %s", getattr(t, "pk", None))
|
||||||
|
t.delete()
|
||||||
|
return created
|
||||||
|
|
||||||
|
school_masters = RegistrationSchoolFileMaster.objects.filter(groups=current_group).distinct()
|
||||||
|
parent_masters = RegistrationParentFileMaster.objects.filter(groups=current_group).distinct()
|
||||||
|
|
||||||
|
school_master_ids = {m.pk for m in school_masters}
|
||||||
|
parent_master_ids = {m.pk for m in parent_masters}
|
||||||
|
|
||||||
|
# Supprimer les school templates obsolètes
|
||||||
|
for tmpl in RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form):
|
||||||
|
if not tmpl.master_id or tmpl.master_id not in school_master_ids:
|
||||||
|
try:
|
||||||
|
if getattr(tmpl, "file", None):
|
||||||
|
tmpl.file.delete(save=False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Erreur suppression fichier school template obsolète %s", getattr(tmpl, "pk", None))
|
||||||
|
tmpl.delete()
|
||||||
|
logger.info("Deleted obsolete school template %s for RF %s", getattr(tmpl, "pk", None), register_form.pk)
|
||||||
|
|
||||||
|
# Supprimer les parent templates obsolètes
|
||||||
|
for tmpl in RegistrationParentFileTemplate.objects.filter(registration_form=register_form):
|
||||||
|
if not tmpl.master_id or tmpl.master_id not in parent_master_ids:
|
||||||
|
try:
|
||||||
|
if getattr(tmpl, "file", None):
|
||||||
|
tmpl.file.delete(save=False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Erreur suppression fichier parent template obsolète %s", getattr(tmpl, "pk", None))
|
||||||
|
tmpl.delete()
|
||||||
|
logger.info("Deleted obsolete parent template %s for RF %s", getattr(tmpl, "pk", None), register_form.pk)
|
||||||
|
|
||||||
|
# Créer les school templates manquants ou mettre à jour les existants si le master a changé
|
||||||
|
for m in school_masters:
|
||||||
|
tmpl_qs = RegistrationSchoolFileTemplate.objects.filter(master=m, registration_form=register_form)
|
||||||
|
tmpl = tmpl_qs.first() if tmpl_qs.exists() else None
|
||||||
|
|
||||||
|
base_slug = (m.name or "master").strip().replace(" ", "_")[:40]
|
||||||
|
slug = f"{base_slug}_{register_form.pk}_{m.pk}"
|
||||||
|
|
||||||
|
file_name = None
|
||||||
|
if m.file and hasattr(m.file, 'name') and m.file.name:
|
||||||
|
file_name = os.path.basename(m.file.name)
|
||||||
|
elif m.file:
|
||||||
|
file_name = str(m.file)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
pdf_file = generate_form_json_pdf(register_form, m.formMasterData)
|
||||||
|
file_name = os.path.basename(pdf_file.name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la génération du PDF pour le template: {e}")
|
||||||
|
file_name = None
|
||||||
|
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
upload_rel_path = registration_school_file_upload_to(
|
||||||
|
type("Tmp", (), {
|
||||||
|
"registration_form": register_form,
|
||||||
|
"establishment": getattr(register_form, "establishment", None),
|
||||||
|
"student": getattr(register_form, "student", None)
|
||||||
|
})(),
|
||||||
|
file_name
|
||||||
|
)
|
||||||
|
abs_path = os.path.join(settings.MEDIA_ROOT, upload_rel_path)
|
||||||
|
master_file_path = m.file.path if m.file and hasattr(m.file, 'path') else None
|
||||||
|
|
||||||
|
if tmpl:
|
||||||
|
template_file_name = os.path.basename(tmpl.file.name) if tmpl.file and tmpl.file.name else None
|
||||||
|
master_file_changed = template_file_name != file_name
|
||||||
|
# --- GESTION FORM EXISTANT : suppression ancien template si nom ou contenu master changé ---
|
||||||
|
if master_file_changed or (
|
||||||
|
master_file_path and os.path.exists(master_file_path) and
|
||||||
|
(not tmpl.file or not os.path.exists(abs_path) or os.path.getmtime(master_file_path) > os.path.getmtime(abs_path))
|
||||||
|
):
|
||||||
|
# Supprimer l'ancien fichier du template (même si le nom change)
|
||||||
|
if tmpl.file and tmpl.file.name:
|
||||||
|
old_template_path = os.path.join(settings.MEDIA_ROOT, tmpl.file.name)
|
||||||
|
if os.path.exists(old_template_path):
|
||||||
|
try:
|
||||||
|
os.remove(old_template_path)
|
||||||
|
logger.info(f"util.create_templates_for_registration_form - Suppression ancien fichier template: {old_template_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur suppression ancien fichier template: {e}")
|
||||||
|
# Copier le nouveau fichier du master (form existant)
|
||||||
|
if master_file_path and os.path.exists(master_file_path):
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||||
|
import shutil
|
||||||
|
shutil.copy2(master_file_path, abs_path)
|
||||||
|
logger.info(f"util.create_templates_for_registration_form - Copie du fichier master {master_file_path} -> {abs_path}")
|
||||||
|
tmpl.file.name = upload_rel_path
|
||||||
|
tmpl.name = m.name or ""
|
||||||
|
tmpl.slug = slug
|
||||||
|
tmpl.formTemplateData = m.formMasterData or []
|
||||||
|
tmpl.save()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la copie du fichier master pour mise à jour du template: {e}")
|
||||||
|
created.append(tmpl)
|
||||||
|
logger.info("util.create_templates_for_registration_form - Mise à jour school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sinon, création du template comme avant
|
||||||
|
tmpl = RegistrationSchoolFileTemplate(
|
||||||
|
master=m,
|
||||||
|
registration_form=register_form,
|
||||||
|
name=m.name or "",
|
||||||
|
formTemplateData=m.formMasterData or [],
|
||||||
|
slug=slug,
|
||||||
|
)
|
||||||
|
if file_name:
|
||||||
|
# Copier le fichier du master si besoin (form existant)
|
||||||
|
if master_file_path and not os.path.exists(abs_path):
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||||
|
shutil.copy2(master_file_path, abs_path)
|
||||||
|
logger.info(f"util.create_templates_for_registration_form - Copie du fichier master {master_file_path} -> {abs_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la copie du fichier master pour le template: {e}")
|
||||||
|
tmpl.file.name = upload_rel_path
|
||||||
|
tmpl.save()
|
||||||
|
created.append(tmpl)
|
||||||
|
logger.info("util.create_templates_for_registration_form - Created school template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
|
||||||
|
|
||||||
|
# Créer les parent templates manquants
|
||||||
|
for m in parent_masters:
|
||||||
|
exists = RegistrationParentFileTemplate.objects.filter(master=m, registration_form=register_form).exists()
|
||||||
|
if exists:
|
||||||
|
continue
|
||||||
|
tmpl = RegistrationParentFileTemplate.objects.create(
|
||||||
|
master=m,
|
||||||
|
registration_form=register_form,
|
||||||
|
file=None,
|
||||||
|
)
|
||||||
|
created.append(tmpl)
|
||||||
|
logger.info("Created parent template %s from master %s for RF %s", tmpl.pk, m.pk, register_form.pk)
|
||||||
|
|
||||||
|
return created
|
||||||
|
|
||||||
def recupereListeFichesInscription():
|
def recupereListeFichesInscription():
|
||||||
"""
|
"""
|
||||||
Retourne la liste complète des fiches d’inscription.
|
Retourne la liste complète des fiches d’inscription.
|
||||||
@ -99,12 +368,70 @@ def getArgFromRequest(_argument, _request):
|
|||||||
def merge_files_pdf(file_paths):
|
def merge_files_pdf(file_paths):
|
||||||
"""
|
"""
|
||||||
Fusionne plusieurs fichiers PDF et retourne le contenu fusionné en mémoire.
|
Fusionne plusieurs fichiers PDF et retourne le contenu fusionné en mémoire.
|
||||||
|
Les fichiers non-PDF (images) sont convertis en PDF avant fusion.
|
||||||
|
Les fichiers invalides sont ignorés avec un log d'erreur.
|
||||||
"""
|
"""
|
||||||
merger = PdfMerger()
|
merger = PdfMerger()
|
||||||
|
files_added = 0
|
||||||
|
|
||||||
|
# Extensions d'images supportées
|
||||||
|
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif'}
|
||||||
|
|
||||||
# Ajouter les fichiers valides au merger
|
|
||||||
for file_path in file_paths:
|
for file_path in file_paths:
|
||||||
merger.append(file_path)
|
# Vérifier que le fichier existe
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
logger.warning(f"[merge_files_pdf] Fichier introuvable, ignoré: {file_path}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
|
||||||
|
# Si c'est une image, la convertir en PDF
|
||||||
|
if file_ext in image_extensions:
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
from reportlab.lib.utils import ImageReader
|
||||||
|
|
||||||
|
img = Image.open(file_path)
|
||||||
|
img_width, img_height = img.size
|
||||||
|
|
||||||
|
# Créer un PDF en mémoire avec l'image
|
||||||
|
img_pdf = BytesIO()
|
||||||
|
c = canvas.Canvas(img_pdf, pagesize=(img_width, img_height))
|
||||||
|
c.drawImage(file_path, 0, 0, width=img_width, height=img_height)
|
||||||
|
c.save()
|
||||||
|
img_pdf.seek(0)
|
||||||
|
|
||||||
|
merger.append(img_pdf)
|
||||||
|
files_added += 1
|
||||||
|
logger.debug(f"[merge_files_pdf] Image convertie et ajoutée: {file_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[merge_files_pdf] Erreur lors de la conversion de l'image {file_path}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sinon, essayer de l'ajouter comme PDF
|
||||||
|
try:
|
||||||
|
# Valider que c'est un PDF lisible
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
PdfReader(f, strict=False)
|
||||||
|
|
||||||
|
# Si la validation passe, ajouter au merger
|
||||||
|
merger.append(file_path)
|
||||||
|
files_added += 1
|
||||||
|
logger.debug(f"[merge_files_pdf] PDF ajouté: {file_path}")
|
||||||
|
except PdfReadError as e:
|
||||||
|
logger.error(f"[merge_files_pdf] Fichier PDF invalide, ignoré: {file_path} - {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[merge_files_pdf] Erreur lors de la lecture du fichier {file_path}: {e}")
|
||||||
|
|
||||||
|
if files_added == 0:
|
||||||
|
logger.warning("[merge_files_pdf] Aucun fichier valide à fusionner")
|
||||||
|
# Retourner un PDF vide
|
||||||
|
empty_pdf = BytesIO()
|
||||||
|
c = canvas.Canvas(empty_pdf, pagesize=A4)
|
||||||
|
c.drawString(100, 750, "Aucun document à afficher")
|
||||||
|
c.save()
|
||||||
|
empty_pdf.seek(0)
|
||||||
|
return empty_pdf
|
||||||
|
|
||||||
# Sauvegarder le fichier fusionné en mémoire
|
# Sauvegarder le fichier fusionné en mémoire
|
||||||
merged_pdf = BytesIO()
|
merged_pdf = BytesIO()
|
||||||
@ -133,25 +460,11 @@ def rfToPDF(registerForm, filename):
|
|||||||
if not pdf:
|
if not pdf:
|
||||||
raise ValueError("Erreur lors de la génération du PDF.")
|
raise ValueError("Erreur lors de la génération du PDF.")
|
||||||
|
|
||||||
# Vérifier si un fichier avec le même nom existe déjà et le supprimer
|
# Enregistrer directement le fichier dans le champ registration_file (écrase l'existant)
|
||||||
if registerForm.registration_file and registerForm.registration_file.name:
|
|
||||||
# Vérifiez si le chemin est déjà absolu ou relatif
|
|
||||||
if os.path.isabs(registerForm.registration_file.name):
|
|
||||||
existing_file_path = registerForm.registration_file.name
|
|
||||||
else:
|
|
||||||
existing_file_path = os.path.join(settings.MEDIA_ROOT, registerForm.registration_file.name.lstrip('/'))
|
|
||||||
|
|
||||||
# Vérifier si le fichier existe et le supprimer
|
|
||||||
if os.path.exists(existing_file_path):
|
|
||||||
os.remove(existing_file_path)
|
|
||||||
registerForm.registration_file.delete(save=False)
|
|
||||||
else:
|
|
||||||
print(f'File does not exist: {existing_file_path}')
|
|
||||||
|
|
||||||
# Enregistrer directement le fichier dans le champ registration_file
|
|
||||||
try:
|
try:
|
||||||
registerForm.registration_file.save(
|
save_file_replacing_existing(
|
||||||
os.path.basename(filename), # Utiliser uniquement le nom de fichier
|
registerForm.registration_file,
|
||||||
|
os.path.basename(filename),
|
||||||
File(BytesIO(pdf.content)),
|
File(BytesIO(pdf.content)),
|
||||||
save=True
|
save=True
|
||||||
)
|
)
|
||||||
@ -212,4 +525,57 @@ def getHistoricalYears(count=5):
|
|||||||
historical_start_year = start_year - i
|
historical_start_year = start_year - i
|
||||||
historical_years.append(f"{historical_start_year}-{historical_start_year + 1}")
|
historical_years.append(f"{historical_start_year}-{historical_start_year + 1}")
|
||||||
|
|
||||||
return historical_years
|
return historical_years
|
||||||
|
|
||||||
|
def generate_form_json_pdf(register_form, form_json):
|
||||||
|
"""
|
||||||
|
Génère un PDF du rendu du formulaire dynamique à partir du JSON (formConfig)
|
||||||
|
et l'associe au RegistrationSchoolFileTemplate.
|
||||||
|
Le PDF contient le titre, les labels et types de champs.
|
||||||
|
Retourne un ContentFile prêt à être utilisé dans un FileField, avec uniquement le nom du fichier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Récupérer le nom du formulaire
|
||||||
|
form_name = (form_json.get("title") or "formulaire").strip().replace(" ", "_")
|
||||||
|
filename = f"{form_name}.pdf"
|
||||||
|
|
||||||
|
# Générer le PDF
|
||||||
|
buffer = BytesIO()
|
||||||
|
c = canvas.Canvas(buffer, pagesize=A4)
|
||||||
|
y = 800
|
||||||
|
|
||||||
|
# Titre
|
||||||
|
c.setFont("Helvetica-Bold", 20)
|
||||||
|
c.drawString(100, y, form_json.get("title", "Formulaire"))
|
||||||
|
y -= 40
|
||||||
|
|
||||||
|
# Champs
|
||||||
|
c.setFont("Helvetica", 12)
|
||||||
|
fields = form_json.get("fields", [])
|
||||||
|
for field in fields:
|
||||||
|
label = field.get("label", field.get("id", ""))
|
||||||
|
ftype = field.get("type", "")
|
||||||
|
value = field.get("value", "")
|
||||||
|
# Afficher la valeur si elle existe
|
||||||
|
if value not in (None, ""):
|
||||||
|
c.drawString(100, y, f"{label} [{ftype}] : {value}")
|
||||||
|
else:
|
||||||
|
c.drawString(100, y, f"{label} [{ftype}]")
|
||||||
|
y -= 25
|
||||||
|
if y < 100:
|
||||||
|
c.showPage()
|
||||||
|
y = 800
|
||||||
|
|
||||||
|
c.save()
|
||||||
|
buffer.seek(0)
|
||||||
|
pdf_content = buffer.read()
|
||||||
|
|
||||||
|
# Si un fichier existe déjà sur le template, le supprimer (optionnel, à adapter selon usage)
|
||||||
|
if hasattr(register_form, "registration_file") and register_form.registration_file and register_form.registration_file.name:
|
||||||
|
existing_file_path = os.path.join(settings.MEDIA_ROOT, register_form.registration_file.name.lstrip('/'))
|
||||||
|
if os.path.exists(existing_file_path):
|
||||||
|
os.remove(existing_file_path)
|
||||||
|
register_form.registration_file.delete(save=False)
|
||||||
|
|
||||||
|
# Retourner le ContentFile avec uniquement le nom du fichier
|
||||||
|
return ContentFile(pdf_content, name=os.path.basename(filename))
|
||||||
|
|||||||
@ -1,11 +1,25 @@
|
|||||||
from .register_form_views import RegisterFormView, RegisterFormWithIdView, send, resend, archive, get_school_file_templates_by_rf, get_parent_file_templates_by_rf
|
from .register_form_views import (
|
||||||
from .registration_file_views import (
|
RegisterFormView,
|
||||||
|
RegisterFormWithIdView,
|
||||||
|
send,
|
||||||
|
resend,
|
||||||
|
archive,
|
||||||
|
get_school_file_templates_by_rf,
|
||||||
|
get_parent_file_templates_by_rf
|
||||||
|
)
|
||||||
|
from .registration_school_file_masters_views import (
|
||||||
RegistrationSchoolFileMasterView,
|
RegistrationSchoolFileMasterView,
|
||||||
RegistrationSchoolFileMasterSimpleView,
|
RegistrationSchoolFileMasterSimpleView,
|
||||||
|
)
|
||||||
|
from .registration_school_file_templates_views import (
|
||||||
RegistrationSchoolFileTemplateView,
|
RegistrationSchoolFileTemplateView,
|
||||||
RegistrationSchoolFileTemplateSimpleView,
|
RegistrationSchoolFileTemplateSimpleView
|
||||||
|
)
|
||||||
|
from .registration_parent_file_masters_views import (
|
||||||
RegistrationParentFileMasterView,
|
RegistrationParentFileMasterView,
|
||||||
RegistrationParentFileMasterSimpleView,
|
RegistrationParentFileMasterSimpleView
|
||||||
|
)
|
||||||
|
from .registration_parent_file_templates_views import (
|
||||||
RegistrationParentFileTemplateSimpleView,
|
RegistrationParentFileTemplateSimpleView,
|
||||||
RegistrationParentFileTemplateView
|
RegistrationParentFileTemplateView
|
||||||
)
|
)
|
||||||
@ -33,7 +47,7 @@ __all__ = [
|
|||||||
'RegistrationFileGroupSimpleView',
|
'RegistrationFileGroupSimpleView',
|
||||||
'get_registration_files_by_group',
|
'get_registration_files_by_group',
|
||||||
'get_school_file_templates_by_rf',
|
'get_school_file_templates_by_rf',
|
||||||
'get_parent_file_templates_by_rf'
|
'get_parent_file_templates_by_rf',
|
||||||
'StudentView',
|
'StudentView',
|
||||||
'StudentListView',
|
'StudentListView',
|
||||||
'ChildrenListView',
|
'ChildrenListView',
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from drf_yasg import openapi
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
|
|
||||||
import N3wtSchool.mailManager as mailer
|
import N3wtSchool.mailManager as mailer
|
||||||
@ -17,10 +18,10 @@ import Subscriptions.util as util
|
|||||||
from Subscriptions.serializers import RegistrationFormSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileTemplateSerializer
|
from Subscriptions.serializers import RegistrationFormSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileTemplateSerializer
|
||||||
from Subscriptions.pagination import CustomSubscriptionPagination
|
from Subscriptions.pagination import CustomSubscriptionPagination
|
||||||
from Subscriptions.models import (
|
from Subscriptions.models import (
|
||||||
Guardian,
|
Guardian,
|
||||||
RegistrationForm,
|
RegistrationForm,
|
||||||
RegistrationSchoolFileTemplate,
|
RegistrationSchoolFileTemplate,
|
||||||
RegistrationFileGroup,
|
RegistrationFileGroup,
|
||||||
RegistrationParentFileTemplate,
|
RegistrationParentFileTemplate,
|
||||||
StudentCompetency
|
StudentCompetency
|
||||||
)
|
)
|
||||||
@ -323,6 +324,27 @@ class RegisterFormWithIdView(APIView):
|
|||||||
registerForm.registration_file = util.rfToPDF(registerForm, initial_pdf)
|
registerForm.registration_file = util.rfToPDF(registerForm, initial_pdf)
|
||||||
registerForm.save()
|
registerForm.save()
|
||||||
|
|
||||||
|
# Envoi du mail d'inscription au second guardian si besoin
|
||||||
|
guardians = registerForm.student.guardians.all()
|
||||||
|
from Auth.models import Profile
|
||||||
|
from N3wtSchool.mailManager import sendRegisterForm
|
||||||
|
|
||||||
|
for guardian in guardians:
|
||||||
|
# Recherche de l'email dans le profil lié au guardian (si existant)
|
||||||
|
email = None
|
||||||
|
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
|
||||||
|
email = guardian.profile_role.profile.email
|
||||||
|
# Fallback sur le champ email direct (si jamais il existe)
|
||||||
|
if not email:
|
||||||
|
email = getattr(guardian, "email", None)
|
||||||
|
logger.debug(f"[RF_UNDER_REVIEW] Guardian id={guardian.id}, email={email}")
|
||||||
|
if email:
|
||||||
|
profile_exists = Profile.objects.filter(email=email).exists()
|
||||||
|
logger.debug(f"[RF_UNDER_REVIEW] Profile existe pour {email} ? {profile_exists}")
|
||||||
|
if not profile_exists:
|
||||||
|
logger.debug(f"[RF_UNDER_REVIEW] Envoi du mail d'inscription à {email} pour l'établissement {registerForm.establishment.pk}")
|
||||||
|
sendRegisterForm(email, registerForm.establishment.pk)
|
||||||
|
|
||||||
# Mise à jour de l'automate
|
# Mise à jour de l'automate
|
||||||
# Vérification de la présence du fichier SEPA
|
# Vérification de la présence du fichier SEPA
|
||||||
if registerForm.sepa_file:
|
if registerForm.sepa_file:
|
||||||
@ -332,9 +354,32 @@ class RegisterFormWithIdView(APIView):
|
|||||||
# Mise à jour de l'automate pour une signature classique
|
# Mise à jour de l'automate pour une signature classique
|
||||||
updateStateMachine(registerForm, 'EVENT_SIGNATURE')
|
updateStateMachine(registerForm, 'EVENT_SIGNATURE')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error(f"[RF_UNDER_REVIEW] Exception: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
return JsonResponse({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
return JsonResponse({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
elif _status == RegistrationForm.RegistrationFormStatus.RF_SENT:
|
elif _status == RegistrationForm.RegistrationFormStatus.RF_SENT:
|
||||||
if registerForm.status == RegistrationForm.RegistrationFormStatus.RF_UNDER_REVIEW:
|
if registerForm.status == RegistrationForm.RegistrationFormStatus.RF_UNDER_REVIEW:
|
||||||
|
# Envoi de l'email de refus aux responsables légaux
|
||||||
|
try:
|
||||||
|
student = registerForm.student
|
||||||
|
student_name = f"{student.first_name} {student.last_name}"
|
||||||
|
notes = registerForm.notes or "Aucun motif spécifié"
|
||||||
|
|
||||||
|
guardians = student.guardians.all()
|
||||||
|
for guardian in guardians:
|
||||||
|
email = None
|
||||||
|
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
|
||||||
|
email = guardian.profile_role.profile.email
|
||||||
|
if not email:
|
||||||
|
email = getattr(guardian, "email", None)
|
||||||
|
|
||||||
|
if email:
|
||||||
|
logger.info(f"[RF_SENT] Envoi email de refus à {email} pour l'élève {student_name}")
|
||||||
|
mailer.sendRefusDossier(email, registerForm.establishment.pk, student_name, notes)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[RF_SENT] Erreur lors de l'envoi de l'email de refus: {e}")
|
||||||
|
|
||||||
updateStateMachine(registerForm, 'EVENT_REFUSE')
|
updateStateMachine(registerForm, 'EVENT_REFUSE')
|
||||||
util.delete_registration_files(registerForm)
|
util.delete_registration_files(registerForm)
|
||||||
elif _status == RegistrationForm.RegistrationFormStatus.RF_SEPA_SENT:
|
elif _status == RegistrationForm.RegistrationFormStatus.RF_SEPA_SENT:
|
||||||
@ -377,14 +422,23 @@ class RegisterFormWithIdView(APIView):
|
|||||||
fileNames.extend(parent_file_templates)
|
fileNames.extend(parent_file_templates)
|
||||||
|
|
||||||
# Création du fichier PDF fusionné
|
# Création du fichier PDF fusionné
|
||||||
merged_pdf_content = util.merge_files_pdf(fileNames)
|
merged_pdf_content = None
|
||||||
|
try:
|
||||||
|
merged_pdf_content = util.merge_files_pdf(fileNames)
|
||||||
|
|
||||||
# Mise à jour du champ registration_file avec le fichier fusionné
|
# Mise à jour du champ fusion_file avec le fichier fusionné
|
||||||
registerForm.fusion_file.save(
|
util.save_file_replacing_existing(
|
||||||
f"dossier_complet.pdf",
|
registerForm.fusion_file,
|
||||||
File(merged_pdf_content),
|
"dossier_complet.pdf",
|
||||||
save=True
|
File(merged_pdf_content),
|
||||||
)
|
save=True
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[RF_VALIDATED] Erreur lors de la fusion des fichiers: {e}")
|
||||||
|
finally:
|
||||||
|
# Libérer explicitement la mémoire du BytesIO
|
||||||
|
if merged_pdf_content is not None:
|
||||||
|
merged_pdf_content.close()
|
||||||
# Valorisation des StudentCompetency pour l'élève
|
# Valorisation des StudentCompetency pour l'élève
|
||||||
try:
|
try:
|
||||||
student = registerForm.student
|
student = registerForm.student
|
||||||
@ -426,11 +480,324 @@ class RegisterFormWithIdView(APIView):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Erreur lors de la valorisation des StudentCompetency: {e}")
|
logger.error(f"Erreur lors de la valorisation des StudentCompetency: {e}")
|
||||||
|
|
||||||
|
# Envoi de l'email de validation aux responsables légaux (en arrière-plan)
|
||||||
|
def send_validation_emails():
|
||||||
|
try:
|
||||||
|
student = registerForm.student
|
||||||
|
student_name = f"{student.first_name} {student.last_name}"
|
||||||
|
class_name = None
|
||||||
|
if student.associated_class:
|
||||||
|
class_name = student.associated_class.atmosphere_name
|
||||||
|
|
||||||
|
guardians = student.guardians.all()
|
||||||
|
for guardian in guardians:
|
||||||
|
email = None
|
||||||
|
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
|
||||||
|
email = guardian.profile_role.profile.email
|
||||||
|
if not email:
|
||||||
|
email = getattr(guardian, "email", None)
|
||||||
|
|
||||||
|
if email:
|
||||||
|
logger.info(f"[RF_VALIDATED] Envoi email de validation à {email} pour l'élève {student_name}")
|
||||||
|
mailer.sendValidationDossier(email, registerForm.establishment.pk, student_name, class_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[RF_VALIDATED] Erreur lors de l'envoi de l'email de validation: {e}")
|
||||||
|
|
||||||
|
# Lancer l'envoi d'email dans un thread séparé pour ne pas bloquer la réponse
|
||||||
|
email_thread = threading.Thread(target=send_validation_emails)
|
||||||
|
email_thread.start()
|
||||||
|
|
||||||
updateStateMachine(registerForm, 'EVENT_VALIDATE')
|
updateStateMachine(registerForm, 'EVENT_VALIDATE')
|
||||||
|
|
||||||
|
elif _status == RegistrationForm.RegistrationFormStatus.RF_ARCHIVED:
|
||||||
|
# Vérifier si on vient de l'état "À valider" (RF_UNDER_REVIEW) pour un refus définitif
|
||||||
|
if registerForm.status == RegistrationForm.RegistrationFormStatus.RF_UNDER_REVIEW:
|
||||||
|
# Envoi de l'email de refus définitif aux responsables légaux (en arrière-plan)
|
||||||
|
def send_refus_definitif_emails():
|
||||||
|
try:
|
||||||
|
student = registerForm.student
|
||||||
|
student_name = f"{student.first_name} {student.last_name}"
|
||||||
|
notes = data.get('notes', '') or "Aucun motif spécifié"
|
||||||
|
|
||||||
|
guardians = student.guardians.all()
|
||||||
|
for guardian in guardians:
|
||||||
|
email = None
|
||||||
|
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
|
||||||
|
email = guardian.profile_role.profile.email
|
||||||
|
if not email:
|
||||||
|
email = getattr(guardian, "email", None)
|
||||||
|
|
||||||
|
if email:
|
||||||
|
logger.info(f"[RF_ARCHIVED] Envoi email de refus définitif à {email} pour l'élève {student_name}")
|
||||||
|
mailer.sendRefusDefinitif(email, registerForm.establishment.pk, student_name, notes)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[RF_ARCHIVED] Erreur lors de l'envoi de l'email de refus définitif: {e}")
|
||||||
|
|
||||||
|
# Lancer l'envoi d'email dans un thread séparé pour ne pas bloquer la réponse
|
||||||
|
email_thread = threading.Thread(target=send_refus_definitif_emails)
|
||||||
|
email_thread.start()
|
||||||
|
|
||||||
|
updateStateMachine(registerForm, 'EVENT_ARCHIVE')
|
||||||
|
|
||||||
# Retourner les données mises à jour
|
# Retourner les données mises à jour
|
||||||
return JsonResponse(studentForm_serializer.data, safe=False)
|
return JsonResponse(studentForm_serializer.data, safe=False)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
request_body=openapi.Schema(
|
||||||
|
type=openapi.TYPE_OBJECT,
|
||||||
|
properties={
|
||||||
|
'student_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données étudiant'),
|
||||||
|
'guardians_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données responsables'),
|
||||||
|
'siblings_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données fratrie'),
|
||||||
|
'payment_data': openapi.Schema(type=openapi.TYPE_STRING, description='JSON string des données de paiement'),
|
||||||
|
'current_page': openapi.Schema(type=openapi.TYPE_INTEGER, description='Page actuelle du formulaire'),
|
||||||
|
'auto_save': openapi.Schema(type=openapi.TYPE_BOOLEAN, description='Indicateur auto-save'),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
responses={200: RegistrationFormSerializer()},
|
||||||
|
operation_description="Auto-sauvegarde partielle d'un dossier d'inscription.",
|
||||||
|
operation_summary="Auto-sauvegarder un dossier d'inscription"
|
||||||
|
)
|
||||||
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
|
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||||
|
def patch(self, request, id):
|
||||||
|
"""
|
||||||
|
Auto-sauvegarde partielle d'un dossier d'inscription.
|
||||||
|
Cette méthode est optimisée pour les sauvegardes automatiques périodiques.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Récupérer le dossier d'inscription
|
||||||
|
registerForm = bdd.getObject(_objectName=RegistrationForm, _columnName='student__id', _value=id)
|
||||||
|
if not registerForm:
|
||||||
|
return JsonResponse({"error": "Dossier d'inscription introuvable"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# Préparer les données à mettre à jour
|
||||||
|
update_data = {}
|
||||||
|
|
||||||
|
# Traiter les données étudiant si présentes
|
||||||
|
if 'student_data' in request.data:
|
||||||
|
try:
|
||||||
|
student_data = json.loads(request.data['student_data'])
|
||||||
|
|
||||||
|
# Extraire les données de paiement des données étudiant
|
||||||
|
payment_fields = ['registration_payment', 'tuition_payment', 'registration_payment_plan', 'tuition_payment_plan']
|
||||||
|
payment_data = {}
|
||||||
|
|
||||||
|
for field in payment_fields:
|
||||||
|
if field in student_data:
|
||||||
|
payment_data[field] = student_data.pop(field)
|
||||||
|
|
||||||
|
# Si nous avons des données de paiement, les traiter
|
||||||
|
if payment_data:
|
||||||
|
logger.debug(f"Auto-save: extracted payment_data from student_data = {payment_data}")
|
||||||
|
|
||||||
|
# Traiter les données de paiement
|
||||||
|
payment_updates = {}
|
||||||
|
|
||||||
|
# Gestion du mode de paiement d'inscription
|
||||||
|
if 'registration_payment' in payment_data and payment_data['registration_payment']:
|
||||||
|
try:
|
||||||
|
from School.models import PaymentMode
|
||||||
|
payment_mode = PaymentMode.objects.get(id=payment_data['registration_payment'])
|
||||||
|
registerForm.registration_payment = payment_mode
|
||||||
|
payment_updates['registration_payment'] = payment_mode.id
|
||||||
|
except PaymentMode.DoesNotExist:
|
||||||
|
logger.warning(f"Auto-save: PaymentMode with id {payment_data['registration_payment']} not found")
|
||||||
|
|
||||||
|
# Gestion du mode de paiement de scolarité
|
||||||
|
if 'tuition_payment' in payment_data and payment_data['tuition_payment']:
|
||||||
|
try:
|
||||||
|
from School.models import PaymentMode
|
||||||
|
payment_mode = PaymentMode.objects.get(id=payment_data['tuition_payment'])
|
||||||
|
registerForm.tuition_payment = payment_mode
|
||||||
|
payment_updates['tuition_payment'] = payment_mode.id
|
||||||
|
except PaymentMode.DoesNotExist:
|
||||||
|
logger.warning(f"Auto-save: PaymentMode with id {payment_data['tuition_payment']} not found")
|
||||||
|
|
||||||
|
# Gestion du plan de paiement d'inscription
|
||||||
|
if 'registration_payment_plan' in payment_data and payment_data['registration_payment_plan']:
|
||||||
|
try:
|
||||||
|
from School.models import PaymentPlan
|
||||||
|
payment_plan = PaymentPlan.objects.get(id=payment_data['registration_payment_plan'])
|
||||||
|
registerForm.registration_payment_plan = payment_plan
|
||||||
|
payment_updates['registration_payment_plan'] = payment_plan.id
|
||||||
|
except PaymentPlan.DoesNotExist:
|
||||||
|
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['registration_payment_plan']} not found")
|
||||||
|
|
||||||
|
# Gestion du plan de paiement de scolarité
|
||||||
|
if 'tuition_payment_plan' in payment_data and payment_data['tuition_payment_plan']:
|
||||||
|
try:
|
||||||
|
from School.models import PaymentPlan
|
||||||
|
payment_plan = PaymentPlan.objects.get(id=payment_data['tuition_payment_plan'])
|
||||||
|
registerForm.tuition_payment_plan = payment_plan
|
||||||
|
payment_updates['tuition_payment_plan'] = payment_plan.id
|
||||||
|
except PaymentPlan.DoesNotExist:
|
||||||
|
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['tuition_payment_plan']} not found")
|
||||||
|
|
||||||
|
# Sauvegarder les modifications de paiement
|
||||||
|
if payment_updates:
|
||||||
|
registerForm.save()
|
||||||
|
logger.debug(f"Auto-save: Payment data updated - {payment_updates}")
|
||||||
|
|
||||||
|
update_data['student'] = student_data
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("Auto-save: Invalid JSON in student_data")
|
||||||
|
|
||||||
|
# Traiter les données des responsables si présentes
|
||||||
|
if 'guardians_data' in request.data:
|
||||||
|
try:
|
||||||
|
guardians_data = json.loads(request.data['guardians_data'])
|
||||||
|
logger.debug(f"Auto-save: guardians_data = {guardians_data}")
|
||||||
|
|
||||||
|
# Enregistrer directement chaque guardian avec le modèle
|
||||||
|
for i, guardian_data in enumerate(guardians_data):
|
||||||
|
guardian_id = guardian_data.get('id')
|
||||||
|
if guardian_id:
|
||||||
|
try:
|
||||||
|
# Récupérer le guardian existant et mettre à jour ses champs
|
||||||
|
guardian = Guardian.objects.get(id=guardian_id)
|
||||||
|
|
||||||
|
# Mettre à jour les champs si ils sont présents
|
||||||
|
if 'birth_date' in guardian_data and guardian_data['birth_date']:
|
||||||
|
guardian.birth_date = guardian_data['birth_date']
|
||||||
|
if 'profession' in guardian_data:
|
||||||
|
guardian.profession = guardian_data['profession']
|
||||||
|
if 'address' in guardian_data:
|
||||||
|
guardian.address = guardian_data['address']
|
||||||
|
if 'phone' in guardian_data:
|
||||||
|
guardian.phone = guardian_data['phone']
|
||||||
|
if 'first_name' in guardian_data:
|
||||||
|
guardian.first_name = guardian_data['first_name']
|
||||||
|
if 'last_name' in guardian_data:
|
||||||
|
guardian.last_name = guardian_data['last_name']
|
||||||
|
|
||||||
|
guardian.save()
|
||||||
|
logger.debug(f"Guardian {i}: Updated birth_date={guardian.birth_date}, profession={guardian.profession}, address={guardian.address}")
|
||||||
|
|
||||||
|
except Guardian.DoesNotExist:
|
||||||
|
logger.warning(f"Auto-save: Guardian with id {guardian_id} not found")
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("Auto-save: Invalid JSON in guardians_data")
|
||||||
|
|
||||||
|
# Traiter les données de la fratrie si présentes
|
||||||
|
if 'siblings_data' in request.data:
|
||||||
|
try:
|
||||||
|
siblings_data = json.loads(request.data['siblings_data'])
|
||||||
|
logger.debug(f"Auto-save: siblings_data = {siblings_data}")
|
||||||
|
|
||||||
|
# Enregistrer directement chaque sibling avec le modèle
|
||||||
|
for i, sibling_data in enumerate(siblings_data):
|
||||||
|
sibling_id = sibling_data.get('id')
|
||||||
|
if sibling_id:
|
||||||
|
try:
|
||||||
|
# Récupérer le sibling existant et mettre à jour ses champs
|
||||||
|
from Subscriptions.models import Sibling
|
||||||
|
sibling = Sibling.objects.get(id=sibling_id)
|
||||||
|
|
||||||
|
# Mettre à jour les champs si ils sont présents
|
||||||
|
if 'first_name' in sibling_data:
|
||||||
|
sibling.first_name = sibling_data['first_name']
|
||||||
|
if 'last_name' in sibling_data:
|
||||||
|
sibling.last_name = sibling_data['last_name']
|
||||||
|
if 'birth_date' in sibling_data and sibling_data['birth_date']:
|
||||||
|
sibling.birth_date = sibling_data['birth_date']
|
||||||
|
|
||||||
|
sibling.save()
|
||||||
|
logger.debug(f"Sibling {i}: Updated first_name={sibling.first_name}, last_name={sibling.last_name}, birth_date={sibling.birth_date}")
|
||||||
|
|
||||||
|
except Sibling.DoesNotExist:
|
||||||
|
logger.warning(f"Auto-save: Sibling with id {sibling_id} not found")
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("Auto-save: Invalid JSON in siblings_data")
|
||||||
|
|
||||||
|
# Traiter les données de paiement si présentes
|
||||||
|
if 'payment_data' in request.data:
|
||||||
|
try:
|
||||||
|
payment_data = json.loads(request.data['payment_data'])
|
||||||
|
logger.debug(f"Auto-save: payment_data = {payment_data}")
|
||||||
|
|
||||||
|
# Mettre à jour directement les champs de paiement du formulaire
|
||||||
|
payment_updates = {}
|
||||||
|
|
||||||
|
# Gestion du mode de paiement d'inscription
|
||||||
|
if 'registration_payment' in payment_data and payment_data['registration_payment']:
|
||||||
|
try:
|
||||||
|
from School.models import PaymentMode
|
||||||
|
payment_mode = PaymentMode.objects.get(id=payment_data['registration_payment'])
|
||||||
|
registerForm.registration_payment = payment_mode
|
||||||
|
payment_updates['registration_payment'] = payment_mode.id
|
||||||
|
except PaymentMode.DoesNotExist:
|
||||||
|
logger.warning(f"Auto-save: PaymentMode with id {payment_data['registration_payment']} not found")
|
||||||
|
|
||||||
|
# Gestion du mode de paiement de scolarité
|
||||||
|
if 'tuition_payment' in payment_data and payment_data['tuition_payment']:
|
||||||
|
try:
|
||||||
|
from School.models import PaymentMode
|
||||||
|
payment_mode = PaymentMode.objects.get(id=payment_data['tuition_payment'])
|
||||||
|
registerForm.tuition_payment = payment_mode
|
||||||
|
payment_updates['tuition_payment'] = payment_mode.id
|
||||||
|
except PaymentMode.DoesNotExist:
|
||||||
|
logger.warning(f"Auto-save: PaymentMode with id {payment_data['tuition_payment']} not found")
|
||||||
|
|
||||||
|
# Gestion du plan de paiement d'inscription
|
||||||
|
if 'registration_payment_plan' in payment_data and payment_data['registration_payment_plan']:
|
||||||
|
try:
|
||||||
|
from School.models import PaymentPlan
|
||||||
|
payment_plan = PaymentPlan.objects.get(id=payment_data['registration_payment_plan'])
|
||||||
|
registerForm.registration_payment_plan = payment_plan
|
||||||
|
payment_updates['registration_payment_plan'] = payment_plan.id
|
||||||
|
except PaymentPlan.DoesNotExist:
|
||||||
|
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['registration_payment_plan']} not found")
|
||||||
|
|
||||||
|
# Gestion du plan de paiement de scolarité
|
||||||
|
if 'tuition_payment_plan' in payment_data and payment_data['tuition_payment_plan']:
|
||||||
|
try:
|
||||||
|
from School.models import PaymentPlan
|
||||||
|
payment_plan = PaymentPlan.objects.get(id=payment_data['tuition_payment_plan'])
|
||||||
|
registerForm.tuition_payment_plan = payment_plan
|
||||||
|
payment_updates['tuition_payment_plan'] = payment_plan.id
|
||||||
|
except PaymentPlan.DoesNotExist:
|
||||||
|
logger.warning(f"Auto-save: PaymentPlan with id {payment_data['tuition_payment_plan']} not found")
|
||||||
|
|
||||||
|
# Sauvegarder les modifications de paiement
|
||||||
|
if payment_updates:
|
||||||
|
registerForm.save()
|
||||||
|
logger.debug(f"Auto-save: Payment data updated - {payment_updates}")
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("Auto-save: Invalid JSON in payment_data")
|
||||||
|
|
||||||
|
# Mettre à jour la page actuelle si présente
|
||||||
|
if 'current_page' in request.data:
|
||||||
|
try:
|
||||||
|
current_page = int(request.data['current_page'])
|
||||||
|
# Vous pouvez sauvegarder cette info dans un champ du modèle si nécessaire
|
||||||
|
logger.debug(f"Auto-save: current_page = {current_page}")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.warning("Auto-save: Invalid current_page value")
|
||||||
|
|
||||||
|
# Effectuer la mise à jour partielle seulement si nous avons des données
|
||||||
|
if update_data:
|
||||||
|
serializer = RegistrationFormSerializer(registerForm, data=update_data, partial=True)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
logger.debug(f"Auto-save successful for student {id}")
|
||||||
|
return JsonResponse({"status": "auto_save_success", "timestamp": util._now().isoformat()}, safe=False)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Auto-save validation errors: {serializer.errors}")
|
||||||
|
# Pour l'auto-save, on retourne un succès même en cas d'erreur de validation
|
||||||
|
return JsonResponse({"status": "auto_save_partial", "errors": serializer.errors}, safe=False)
|
||||||
|
else:
|
||||||
|
# Pas de données à sauvegarder, mais on retourne un succès
|
||||||
|
return JsonResponse({"status": "auto_save_no_data"}, safe=False)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Auto-save error for student {id}: {str(e)}")
|
||||||
|
# Pour l'auto-save, on ne retourne pas d'erreur HTTP pour éviter d'interrompre l'UX
|
||||||
|
return JsonResponse({"status": "auto_save_failed", "error": str(e)}, safe=False)
|
||||||
|
|
||||||
@swagger_auto_schema(
|
@swagger_auto_schema(
|
||||||
responses={204: 'No Content'},
|
responses={204: 'No Content'},
|
||||||
operation_description="Supprime un dossier d'inscription donné.",
|
operation_description="Supprime un dossier d'inscription donné.",
|
||||||
|
|||||||
@ -1,372 +0,0 @@
|
|||||||
from django.http.response import JsonResponse
|
|
||||||
from drf_yasg.utils import swagger_auto_schema
|
|
||||||
from drf_yasg import openapi
|
|
||||||
from rest_framework.parsers import MultiPartParser, FormParser
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.views import APIView
|
|
||||||
from rest_framework import status
|
|
||||||
|
|
||||||
from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer
|
|
||||||
from Subscriptions.models import RegistrationSchoolFileMaster, RegistrationSchoolFileTemplate, RegistrationParentFileMaster, RegistrationParentFileTemplate
|
|
||||||
from N3wtSchool import bdd
|
|
||||||
|
|
||||||
class RegistrationSchoolFileMasterView(APIView):
|
|
||||||
@swagger_auto_schema(
|
|
||||||
operation_description="Récupère tous les masters de templates d'inscription pour un établissement donné",
|
|
||||||
manual_parameters=[
|
|
||||||
openapi.Parameter(
|
|
||||||
'establishment_id',
|
|
||||||
openapi.IN_QUERY,
|
|
||||||
description="ID de l'établissement",
|
|
||||||
type=openapi.TYPE_INTEGER,
|
|
||||||
required=True
|
|
||||||
)
|
|
||||||
],
|
|
||||||
responses={200: RegistrationSchoolFileMasterSerializer(many=True)}
|
|
||||||
)
|
|
||||||
def get(self, request):
|
|
||||||
establishment_id = request.GET.get('establishment_id')
|
|
||||||
if not establishment_id:
|
|
||||||
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
# Filtrer les masters liés à l'établissement via groups.establishment
|
|
||||||
masters = RegistrationSchoolFileMaster.objects.filter(
|
|
||||||
groups__establishment__id=establishment_id
|
|
||||||
).distinct()
|
|
||||||
serializer = RegistrationSchoolFileMasterSerializer(masters, many=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@swagger_auto_schema(
|
|
||||||
operation_description="Crée un nouveau master de template d'inscription",
|
|
||||||
request_body=RegistrationSchoolFileMasterSerializer,
|
|
||||||
responses={
|
|
||||||
201: RegistrationSchoolFileMasterSerializer,
|
|
||||||
400: "Données invalides"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
def post(self, request):
|
|
||||||
serializer = RegistrationSchoolFileMasterSerializer(data=request.data)
|
|
||||||
if serializer.is_valid():
|
|
||||||
serializer.save()
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
class RegistrationSchoolFileMasterSimpleView(APIView):
|
|
||||||
@swagger_auto_schema(
|
|
||||||
operation_description="Récupère un master de template d'inscription spécifique",
|
|
||||||
responses={
|
|
||||||
200: RegistrationSchoolFileMasterSerializer,
|
|
||||||
404: "Master non trouvé"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
def get(self, request, id):
|
|
||||||
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
|
|
||||||
if master is None:
|
|
||||||
return JsonResponse({"errorMessage":'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
serializer = RegistrationSchoolFileMasterSerializer(master)
|
|
||||||
return JsonResponse(serializer.data, safe=False)
|
|
||||||
|
|
||||||
@swagger_auto_schema(
|
|
||||||
operation_description="Met à jour un master de template d'inscription existant",
|
|
||||||
request_body=RegistrationSchoolFileMasterSerializer,
|
|
||||||
responses={
|
|
||||||
200: RegistrationSchoolFileMasterSerializer,
|
|
||||||
400: "Données invalides",
|
|
||||||
404: "Master non trouvé"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
def put(self, request, id):
|
|
||||||
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
|
|
||||||
if master is None:
|
|
||||||
return JsonResponse({'erreur': 'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
serializer = RegistrationSchoolFileMasterSerializer(master, data=request.data)
|
|
||||||
if serializer.is_valid():
|
|
||||||
serializer.save()
|
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
@swagger_auto_schema(
|
|
||||||
operation_description="Supprime un master de template d'inscription",
|
|
||||||
responses={
|
|
||||||
204: "Suppression réussie",
|
|
||||||
404: "Master non trouvé"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
def delete(self, request, id):
|
|
||||||
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
|
|
||||||
if master is not None:
|
|
||||||
master.delete()
|
|
||||||
return JsonResponse({'message': 'La suppression du master de template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
|
||||||
else:
|
|
||||||
return JsonResponse({'erreur': 'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
class RegistrationSchoolFileTemplateView(APIView):
|
|
||||||
@swagger_auto_schema(
|
|
||||||
operation_description="Récupère tous les templates d'inscription pour un établissement donné",
|
|
||||||
manual_parameters=[
|
|
||||||
openapi.Parameter(
|
|
||||||
'establishment_id',
|
|
||||||
openapi.IN_QUERY,
|
|
||||||
description="ID de l'établissement",
|
|
||||||
type=openapi.TYPE_INTEGER,
|
|
||||||
required=True
|
|
||||||
)
|
|
||||||
],
|
|
||||||
responses={200: RegistrationSchoolFileTemplateSerializer(many=True)}
|
|
||||||
)
|
|
||||||
def get(self, request):
|
|
||||||
establishment_id = request.GET.get('establishment_id')
|
|
||||||
if not establishment_id:
|
|
||||||
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
# Filtrer les templates liés à l'établissement via master.groups.establishment
|
|
||||||
templates = RegistrationSchoolFileTemplate.objects.filter(
|
|
||||||
master__groups__establishment__id=establishment_id
|
|
||||||
).distinct()
|
|
||||||
serializer = RegistrationSchoolFileTemplateSerializer(templates, many=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@swagger_auto_schema(
|
|
||||||
operation_description="Crée un nouveau template d'inscription",
|
|
||||||
request_body=RegistrationSchoolFileTemplateSerializer,
|
|
||||||
responses={
|
|
||||||
201: RegistrationSchoolFileTemplateSerializer,
|
|
||||||
400: "Données invalides"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
def post(self, request):
|
|
||||||
serializer = RegistrationSchoolFileTemplateSerializer(data=request.data)
|
|
||||||
if serializer.is_valid():
|
|
||||||
serializer.save()
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
class RegistrationSchoolFileTemplateSimpleView(APIView):
|
|
||||||
@swagger_auto_schema(
|
|
||||||
operation_description="Récupère un template d'inscription spécifique",
|
|
||||||
responses={
|
|
||||||
200: RegistrationSchoolFileTemplateSerializer,
|
|
||||||
404: "Template non trouvé"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
def get(self, request, id):
|
|
||||||
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
|
|
||||||
if template is None:
|
|
||||||
return JsonResponse({"errorMessage":'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
serializer = RegistrationSchoolFileTemplateSerializer(template)
|
|
||||||
return JsonResponse(serializer.data, safe=False)
|
|
||||||
|
|
||||||
@swagger_auto_schema(
|
|
||||||
operation_description="Met à jour un template d'inscription existant",
|
|
||||||
request_body=RegistrationSchoolFileTemplateSerializer,
|
|
||||||
responses={
|
|
||||||
200: RegistrationSchoolFileTemplateSerializer,
|
|
||||||
400: "Données invalides",
|
|
||||||
404: "Template non trouvé"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
def put(self, request, id):
|
|
||||||
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
|
|
||||||
if template is None:
|
|
||||||
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
serializer = RegistrationSchoolFileTemplateSerializer(template, data=request.data)
|
|
||||||
if serializer.is_valid():
|
|
||||||
serializer.save()
|
|
||||||
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
@swagger_auto_schema(
|
|
||||||
operation_description="Supprime un template d'inscription",
|
|
||||||
responses={
|
|
||||||
204: "Suppression réussie",
|
|
||||||
404: "Template non trouvé"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
def delete(self, request, id):
|
|
||||||
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
|
|
||||||
if template is not None:
|
|
||||||
template.delete()
|
|
||||||
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
|
||||||
else:
|
|
||||||
return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
class RegistrationParentFileMasterView(APIView):
|
|
||||||
@swagger_auto_schema(
|
|
||||||
operation_description="Récupère tous les fichiers parents pour un établissement donné",
|
|
||||||
manual_parameters=[
|
|
||||||
openapi.Parameter(
|
|
||||||
'establishment_id',
|
|
||||||
openapi.IN_QUERY,
|
|
||||||
description="ID de l'établissement",
|
|
||||||
type=openapi.TYPE_INTEGER,
|
|
||||||
required=True
|
|
||||||
)
|
|
||||||
],
|
|
||||||
responses={200: RegistrationParentFileMasterSerializer(many=True)}
|
|
||||||
)
|
|
||||||
def get(self, request):
|
|
||||||
establishment_id = request.GET.get('establishment_id')
|
|
||||||
if not establishment_id:
|
|
||||||
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
# Filtrer les fichiers parents liés à l'établissement
|
|
||||||
templates = RegistrationParentFileMaster.objects.filter(
|
|
||||||
groups__establishment__id=establishment_id
|
|
||||||
).distinct()
|
|
||||||
serializer = RegistrationParentFileMasterSerializer(templates, many=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@swagger_auto_schema(
|
|
||||||
operation_description="Crée un nouveau fichier parent",
|
|
||||||
request_body=RegistrationParentFileMasterSerializer,
|
|
||||||
responses={
|
|
||||||
201: RegistrationParentFileMasterSerializer,
|
|
||||||
400: "Données invalides"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
def post(self, request):
|
|
||||||
serializer = RegistrationParentFileMasterSerializer(data=request.data)
|
|
||||||
if serializer.is_valid():
|
|
||||||
serializer.save()
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
class RegistrationParentFileMasterSimpleView(APIView):
|
|
||||||
@swagger_auto_schema(
|
|
||||||
operation_description="Récupère un fichier parent spécifique",
|
|
||||||
responses={
|
|
||||||
200: RegistrationParentFileMasterSerializer,
|
|
||||||
404: "Fichier parent non trouvé"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
def get(self, request, id):
|
|
||||||
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
|
|
||||||
if template is None:
|
|
||||||
return JsonResponse({"errorMessage":'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
serializer = RegistrationParentFileMasterSerializer(template)
|
|
||||||
return JsonResponse(serializer.data, safe=False)
|
|
||||||
|
|
||||||
@swagger_auto_schema(
|
|
||||||
operation_description="Met à jour un fichier parent existant",
|
|
||||||
request_body=RegistrationParentFileMasterSerializer,
|
|
||||||
responses={
|
|
||||||
200: RegistrationParentFileMasterSerializer,
|
|
||||||
400: "Données invalides",
|
|
||||||
404: "Fichier parent non trouvé"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
def put(self, request, id):
|
|
||||||
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
|
|
||||||
if template is None:
|
|
||||||
return JsonResponse({'erreur': 'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
serializer = RegistrationParentFileMasterSerializer(template, data=request.data)
|
|
||||||
if serializer.is_valid():
|
|
||||||
serializer.save()
|
|
||||||
return Response({'message': 'Fichier parent mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
@swagger_auto_schema(
|
|
||||||
operation_description="Supprime un fichier parent",
|
|
||||||
responses={
|
|
||||||
204: "Suppression réussie",
|
|
||||||
404: "Fichier parent non trouvé"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
def delete(self, request, id):
|
|
||||||
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
|
|
||||||
if template is not None:
|
|
||||||
template.delete()
|
|
||||||
return JsonResponse({'message': 'La suppression du fichier parent a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
|
||||||
else:
|
|
||||||
return JsonResponse({'erreur': 'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
class RegistrationParentFileTemplateView(APIView):
|
|
||||||
@swagger_auto_schema(
|
|
||||||
operation_description="Récupère tous les templates parents pour un établissement donné",
|
|
||||||
manual_parameters=[
|
|
||||||
openapi.Parameter(
|
|
||||||
'establishment_id',
|
|
||||||
openapi.IN_QUERY,
|
|
||||||
description="ID de l'établissement",
|
|
||||||
type=openapi.TYPE_INTEGER,
|
|
||||||
required=True
|
|
||||||
)
|
|
||||||
],
|
|
||||||
responses={200: RegistrationParentFileTemplateSerializer(many=True)}
|
|
||||||
)
|
|
||||||
def get(self, request):
|
|
||||||
establishment_id = request.GET.get('establishment_id')
|
|
||||||
if not establishment_id:
|
|
||||||
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
# Filtrer les templates parents liés à l'établissement via master.groups.establishment
|
|
||||||
templates = RegistrationParentFileTemplate.objects.filter(
|
|
||||||
master__groups__establishment__id=establishment_id
|
|
||||||
).distinct()
|
|
||||||
serializer = RegistrationParentFileTemplateSerializer(templates, many=True)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
@swagger_auto_schema(
|
|
||||||
operation_description="Crée un nouveau template d'inscription",
|
|
||||||
request_body=RegistrationParentFileTemplateSerializer,
|
|
||||||
responses={
|
|
||||||
201: RegistrationParentFileTemplateSerializer,
|
|
||||||
400: "Données invalides"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
def post(self, request):
|
|
||||||
serializer = RegistrationParentFileTemplateSerializer(data=request.data)
|
|
||||||
if serializer.is_valid():
|
|
||||||
serializer.save()
|
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
class RegistrationParentFileTemplateSimpleView(APIView):
|
|
||||||
@swagger_auto_schema(
|
|
||||||
operation_description="Récupère un template d'inscription spécifique",
|
|
||||||
responses={
|
|
||||||
200: RegistrationParentFileTemplateSerializer,
|
|
||||||
404: "Template non trouvé"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
def get(self, request, id):
|
|
||||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
|
||||||
if template is None:
|
|
||||||
return JsonResponse({"errorMessage":'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
serializer = RegistrationParentFileTemplateSerializer(template)
|
|
||||||
return JsonResponse(serializer.data, safe=False)
|
|
||||||
|
|
||||||
@swagger_auto_schema(
|
|
||||||
operation_description="Met à jour un template d'inscription existant",
|
|
||||||
request_body=RegistrationParentFileTemplateSerializer,
|
|
||||||
responses={
|
|
||||||
200: RegistrationParentFileTemplateSerializer,
|
|
||||||
400: "Données invalides",
|
|
||||||
404: "Template non trouvé"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
def put(self, request, id):
|
|
||||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
|
||||||
if template is None:
|
|
||||||
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
|
|
||||||
serializer = RegistrationParentFileTemplateSerializer(template, data=request.data, partial=True)
|
|
||||||
if serializer.is_valid():
|
|
||||||
serializer.save()
|
|
||||||
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
@swagger_auto_schema(
|
|
||||||
operation_description="Supprime un template d'inscription",
|
|
||||||
responses={
|
|
||||||
204: "Suppression réussie",
|
|
||||||
404: "Template non trouvé"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
def delete(self, request, id):
|
|
||||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
|
||||||
if template is not None:
|
|
||||||
template.delete()
|
|
||||||
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
|
||||||
else:
|
|
||||||
return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
|
||||||
@ -0,0 +1,177 @@
|
|||||||
|
from django.http.response import JsonResponse
|
||||||
|
from drf_yasg.utils import swagger_auto_schema
|
||||||
|
from drf_yasg import openapi
|
||||||
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
from Subscriptions.serializers import RegistrationParentFileMasterSerializer
|
||||||
|
from Subscriptions.models import (
|
||||||
|
RegistrationForm,
|
||||||
|
RegistrationParentFileMaster
|
||||||
|
)
|
||||||
|
from N3wtSchool import bdd
|
||||||
|
import logging
|
||||||
|
import Subscriptions.util as util
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class RegistrationParentFileMasterView(APIView):
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Récupère tous les fichiers parents pour un établissement donné",
|
||||||
|
manual_parameters=[
|
||||||
|
openapi.Parameter(
|
||||||
|
'establishment_id',
|
||||||
|
openapi.IN_QUERY,
|
||||||
|
description="ID de l'établissement",
|
||||||
|
type=openapi.TYPE_INTEGER,
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
],
|
||||||
|
responses={200: RegistrationParentFileMasterSerializer(many=True)}
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
establishment_id = request.GET.get('establishment_id')
|
||||||
|
if not establishment_id:
|
||||||
|
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Filtrer les fichiers parents liés à l'établissement
|
||||||
|
templates = RegistrationParentFileMaster.objects.filter(
|
||||||
|
groups__establishment__id=establishment_id
|
||||||
|
).distinct()
|
||||||
|
serializer = RegistrationParentFileMasterSerializer(templates, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Crée un nouveau fichier parent",
|
||||||
|
request_body=RegistrationParentFileMasterSerializer,
|
||||||
|
responses={
|
||||||
|
201: RegistrationParentFileMasterSerializer,
|
||||||
|
400: "Données invalides"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
logger.info(f"raw request.data: {request.data}")
|
||||||
|
|
||||||
|
payload, resp = util.build_payload_from_request(request)
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
|
|
||||||
|
logger.info(f"payload for serializer: {payload}")
|
||||||
|
serializer = RegistrationParentFileMasterSerializer(data=payload, partial=True)
|
||||||
|
if serializer.is_valid():
|
||||||
|
obj = serializer.save()
|
||||||
|
|
||||||
|
# Propager la création des templates côté serveur pour les RegistrationForm
|
||||||
|
try:
|
||||||
|
groups_qs = obj.groups.all()
|
||||||
|
if groups_qs.exists():
|
||||||
|
rfs = RegistrationForm.objects.filter(fileGroup__in=groups_qs).distinct()
|
||||||
|
for rf in rfs:
|
||||||
|
try:
|
||||||
|
util.create_templates_for_registration_form(rf)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error creating templates for RF %s from parent master %s: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error while propagating templates after parent master creation %s", getattr(obj, 'pk', None))
|
||||||
|
|
||||||
|
return Response(RegistrationParentFileMasterSerializer(obj).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
logger.error(f"serializer errors: {serializer.errors}")
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
class RegistrationParentFileMasterSimpleView(APIView):
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Récupère un fichier parent spécifique",
|
||||||
|
responses={
|
||||||
|
200: RegistrationParentFileMasterSerializer,
|
||||||
|
404: "Fichier parent non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def get(self, request, id):
|
||||||
|
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
|
||||||
|
if template is None:
|
||||||
|
return JsonResponse({"errorMessage":'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
serializer = RegistrationParentFileMasterSerializer(template)
|
||||||
|
return JsonResponse(serializer.data, safe=False)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Met à jour un fichier parent existant",
|
||||||
|
request_body=RegistrationParentFileMasterSerializer,
|
||||||
|
responses={
|
||||||
|
200: RegistrationParentFileMasterSerializer,
|
||||||
|
400: "Données invalides",
|
||||||
|
404: "Fichier parent non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def put(self, request, id):
|
||||||
|
master = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
|
||||||
|
if master is None:
|
||||||
|
return JsonResponse({'erreur': "Le master de fichier parent n'a pas été trouvé"}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# snapshot des groups avant update
|
||||||
|
old_group_ids = set(master.groups.values_list('id', flat=True))
|
||||||
|
|
||||||
|
# Normaliser payload (supporte form-data avec champ 'data' JSON ou fichier JSON)
|
||||||
|
payload, resp = util.build_payload_from_request(request)
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
|
|
||||||
|
logger.info(f"payload for update serializer: {payload}")
|
||||||
|
serializer = RegistrationParentFileMasterSerializer(master, data=payload, partial=True)
|
||||||
|
if serializer.is_valid():
|
||||||
|
obj = serializer.save()
|
||||||
|
|
||||||
|
# groups après update
|
||||||
|
new_group_ids = set(obj.groups.values_list('id', flat=True))
|
||||||
|
|
||||||
|
removed_group_ids = old_group_ids - new_group_ids
|
||||||
|
added_group_ids = new_group_ids - old_group_ids
|
||||||
|
|
||||||
|
# Pour chaque RF appartenant aux groupes retirés -> nettoyer les templates (idempotent)
|
||||||
|
if removed_group_ids:
|
||||||
|
try:
|
||||||
|
rfs_removed = RegistrationForm.objects.filter(fileGroup__in=list(removed_group_ids)).distinct()
|
||||||
|
for rf in rfs_removed:
|
||||||
|
try:
|
||||||
|
util.create_templates_for_registration_form(rf)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error cleaning templates for RF %s after parent master %s group removal: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error while processing RFs for removed groups after parent master update %s", getattr(obj, 'pk', None))
|
||||||
|
|
||||||
|
# Pour chaque RF appartenant aux groupes ajoutés -> créer les templates manquants
|
||||||
|
if added_group_ids:
|
||||||
|
try:
|
||||||
|
rfs_added = RegistrationForm.objects.filter(fileGroup__in=list(added_group_ids)).distinct()
|
||||||
|
for rf in rfs_added:
|
||||||
|
try:
|
||||||
|
util.create_templates_for_registration_form(rf)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error creating templates for RF %s after parent master %s group addition: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error while processing RFs for added groups after parent master update %s", getattr(obj, 'pk', None))
|
||||||
|
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
logger.error(f"serializer errors on put: {serializer.errors}")
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Supprime un fichier parent",
|
||||||
|
responses={
|
||||||
|
204: "Suppression réussie",
|
||||||
|
404: "Fichier parent non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def delete(self, request, id):
|
||||||
|
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
|
||||||
|
if template is not None:
|
||||||
|
template.delete()
|
||||||
|
return JsonResponse({'message': 'La suppression du fichier parent a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||||
|
else:
|
||||||
|
return JsonResponse({'erreur': 'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
from django.http.response import JsonResponse
|
||||||
|
from drf_yasg.utils import swagger_auto_schema
|
||||||
|
from drf_yasg import openapi
|
||||||
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
from Subscriptions.serializers import RegistrationParentFileTemplateSerializer
|
||||||
|
from Subscriptions.models import (
|
||||||
|
RegistrationParentFileTemplate
|
||||||
|
)
|
||||||
|
from N3wtSchool import bdd
|
||||||
|
import logging
|
||||||
|
import Subscriptions.util as util
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class RegistrationParentFileTemplateView(APIView):
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Récupère tous les templates parents pour un établissement donné",
|
||||||
|
manual_parameters=[
|
||||||
|
openapi.Parameter(
|
||||||
|
'establishment_id',
|
||||||
|
openapi.IN_QUERY,
|
||||||
|
description="ID de l'établissement",
|
||||||
|
type=openapi.TYPE_INTEGER,
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
],
|
||||||
|
responses={200: RegistrationParentFileTemplateSerializer(many=True)}
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
establishment_id = request.GET.get('establishment_id')
|
||||||
|
if not establishment_id:
|
||||||
|
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Filtrer les templates parents liés à l'établissement via master.groups.establishment
|
||||||
|
templates = RegistrationParentFileTemplate.objects.filter(
|
||||||
|
master__groups__establishment__id=establishment_id
|
||||||
|
).distinct()
|
||||||
|
serializer = RegistrationParentFileTemplateSerializer(templates, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Crée un nouveau template d'inscription",
|
||||||
|
request_body=RegistrationParentFileTemplateSerializer,
|
||||||
|
responses={
|
||||||
|
201: RegistrationParentFileTemplateSerializer,
|
||||||
|
400: "Données invalides"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
serializer = RegistrationParentFileTemplateSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
class RegistrationParentFileTemplateSimpleView(APIView):
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Récupère un template d'inscription spécifique",
|
||||||
|
responses={
|
||||||
|
200: RegistrationParentFileTemplateSerializer,
|
||||||
|
404: "Template non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def get(self, request, id):
|
||||||
|
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||||
|
if template is None:
|
||||||
|
return JsonResponse({"errorMessage":'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
serializer = RegistrationParentFileTemplateSerializer(template)
|
||||||
|
return JsonResponse(serializer.data, safe=False)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Met à jour un template d'inscription existant",
|
||||||
|
request_body=RegistrationParentFileTemplateSerializer,
|
||||||
|
responses={
|
||||||
|
200: RegistrationParentFileTemplateSerializer,
|
||||||
|
400: "Données invalides",
|
||||||
|
404: "Template non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def put(self, request, id):
|
||||||
|
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||||
|
if template is None:
|
||||||
|
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
serializer = RegistrationParentFileTemplateSerializer(template, data=request.data, partial=True)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Supprime un template d'inscription",
|
||||||
|
responses={
|
||||||
|
204: "Suppression réussie",
|
||||||
|
404: "Template non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def delete(self, request, id):
|
||||||
|
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||||
|
if template is not None:
|
||||||
|
# Suppression du fichier PDF associé
|
||||||
|
if template.file and template.file.name:
|
||||||
|
template.file.delete(save=False)
|
||||||
|
template.delete()
|
||||||
|
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||||
|
else:
|
||||||
|
return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
@ -0,0 +1,190 @@
|
|||||||
|
from django.http.response import JsonResponse
|
||||||
|
from drf_yasg.utils import swagger_auto_schema
|
||||||
|
from drf_yasg import openapi
|
||||||
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer
|
||||||
|
from Subscriptions.models import (
|
||||||
|
RegistrationForm,
|
||||||
|
RegistrationSchoolFileMaster,
|
||||||
|
RegistrationSchoolFileTemplate
|
||||||
|
)
|
||||||
|
from N3wtSchool import bdd
|
||||||
|
import logging
|
||||||
|
import Subscriptions.util as util
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class RegistrationSchoolFileMasterView(APIView):
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Récupère tous les masters de templates d'inscription pour un établissement donné",
|
||||||
|
manual_parameters=[
|
||||||
|
openapi.Parameter(
|
||||||
|
'establishment_id',
|
||||||
|
openapi.IN_QUERY,
|
||||||
|
description="ID de l'établissement",
|
||||||
|
type=openapi.TYPE_INTEGER,
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
],
|
||||||
|
responses={200: RegistrationSchoolFileMasterSerializer(many=True)}
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
establishment_id = request.GET.get('establishment_id')
|
||||||
|
if not establishment_id:
|
||||||
|
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Filtrer les masters liés à l'établissement via groups.establishment
|
||||||
|
masters = RegistrationSchoolFileMaster.objects.filter(
|
||||||
|
groups__establishment__id=establishment_id
|
||||||
|
).distinct()
|
||||||
|
serializer = RegistrationSchoolFileMasterSerializer(masters, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Crée un nouveau master de template d'inscription",
|
||||||
|
request_body=RegistrationSchoolFileMasterSerializer,
|
||||||
|
responses={
|
||||||
|
201: RegistrationSchoolFileMasterSerializer,
|
||||||
|
400: "Données invalides"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
logger.info(f"raw request.data: {request.data}")
|
||||||
|
|
||||||
|
payload, resp = util.build_payload_from_request(request)
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
logger.info(f"payload for serializer: {payload}")
|
||||||
|
serializer = RegistrationSchoolFileMasterSerializer(data=payload, partial=True)
|
||||||
|
if serializer.is_valid():
|
||||||
|
obj = serializer.save()
|
||||||
|
|
||||||
|
# Propager la création des templates côté serveur pour les RegistrationForm
|
||||||
|
try:
|
||||||
|
groups_qs = obj.groups.all()
|
||||||
|
if groups_qs.exists():
|
||||||
|
# Tous les RegistrationForm dont fileGroup est dans les groups du master
|
||||||
|
rfs = RegistrationForm.objects.filter(fileGroup__in=groups_qs).distinct()
|
||||||
|
for rf in rfs:
|
||||||
|
try:
|
||||||
|
util.create_templates_for_registration_form(rf)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error creating templates for RF %s from master %s: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error while propagating templates after master creation %s", getattr(obj, 'pk', None))
|
||||||
|
|
||||||
|
|
||||||
|
return Response(RegistrationSchoolFileMasterSerializer(obj).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
logger.error(f"serializer errors: {serializer.errors}")
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
class RegistrationSchoolFileMasterSimpleView(APIView):
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Récupère un master de template d'inscription spécifique",
|
||||||
|
responses={
|
||||||
|
200: RegistrationSchoolFileMasterSerializer,
|
||||||
|
404: "Master non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def get(self, request, id):
|
||||||
|
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
|
||||||
|
if master is None:
|
||||||
|
return JsonResponse({"errorMessage":'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
serializer = RegistrationSchoolFileMasterSerializer(master)
|
||||||
|
return JsonResponse(serializer.data, safe=False)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Met à jour un master de template d'inscription existant",
|
||||||
|
request_body=RegistrationSchoolFileMasterSerializer,
|
||||||
|
responses={
|
||||||
|
200: RegistrationSchoolFileMasterSerializer,
|
||||||
|
400: "Données invalides",
|
||||||
|
404: "Master non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def put(self, request, id):
|
||||||
|
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
|
||||||
|
if master is None:
|
||||||
|
return JsonResponse({'erreur': "Le master de template n'a pas été trouvé"}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# snapshot des groups avant update
|
||||||
|
old_group_ids = set(master.groups.values_list('id', flat=True))
|
||||||
|
|
||||||
|
# Normaliser payload (supporte form-data avec champ 'data' JSON ou fichier JSON)
|
||||||
|
payload, resp = util.build_payload_from_request(request)
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
logger.info(f"payload for update serializer: {payload}")
|
||||||
|
serializer = RegistrationSchoolFileMasterSerializer(master, data=payload, partial=True)
|
||||||
|
if serializer.is_valid():
|
||||||
|
obj = serializer.save()
|
||||||
|
|
||||||
|
# groups après update
|
||||||
|
new_group_ids = set(obj.groups.values_list('id', flat=True))
|
||||||
|
|
||||||
|
removed_group_ids = old_group_ids - new_group_ids
|
||||||
|
added_group_ids = new_group_ids - old_group_ids
|
||||||
|
|
||||||
|
# Pour chaque RF appartenant aux groupes retirés -> nettoyer les templates (idempotent)
|
||||||
|
if removed_group_ids:
|
||||||
|
logger.info("REMOVE IDs")
|
||||||
|
try:
|
||||||
|
rfs_removed = RegistrationForm.objects.filter(fileGroup__in=list(removed_group_ids)).distinct()
|
||||||
|
for rf in rfs_removed:
|
||||||
|
try:
|
||||||
|
util.create_templates_for_registration_form(rf) # supprimera les templates obsolètes
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error cleaning templates for RF %s after master %s group removal: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error while processing RFs for removed groups after master update %s", getattr(obj, 'pk', None))
|
||||||
|
|
||||||
|
# Pour chaque RF appartenant aux groupes ajoutés -> créer les templates manquants
|
||||||
|
if added_group_ids:
|
||||||
|
logger.info("ADD IDs")
|
||||||
|
try:
|
||||||
|
rfs_added = RegistrationForm.objects.filter(fileGroup__in=list(added_group_ids)).distinct()
|
||||||
|
for rf in rfs_added:
|
||||||
|
try:
|
||||||
|
util.create_templates_for_registration_form(rf) # créera les templates manquants
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Error creating templates for RF %s after master %s group addition: %s", getattr(rf, 'pk', None), getattr(obj, 'pk', None), e)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error while processing RFs for added groups after master update %s", getattr(obj, 'pk', None))
|
||||||
|
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
logger.error(f"serializer errors on put: {serializer.errors}")
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Supprime un master de template d'inscription",
|
||||||
|
responses={
|
||||||
|
204: "Suppression réussie",
|
||||||
|
404: "Master non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def delete(self, request, id):
|
||||||
|
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
|
||||||
|
if master is not None:
|
||||||
|
# Supprimer tous les templates liés et leurs fichiers PDF
|
||||||
|
templates = RegistrationSchoolFileTemplate.objects.filter(master=master)
|
||||||
|
for template in templates:
|
||||||
|
if template.file and template.file.name:
|
||||||
|
template.file.delete(save=False)
|
||||||
|
template.delete()
|
||||||
|
master.delete()
|
||||||
|
return JsonResponse({'message': 'La suppression du master de template et des fichiers associés a été effectuée avec succès'}, safe=False, status=status.HTTP_200_OK)
|
||||||
|
else:
|
||||||
|
return JsonResponse({'erreur': 'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
@ -0,0 +1,191 @@
|
|||||||
|
from django.http.response import JsonResponse
|
||||||
|
from drf_yasg.utils import swagger_auto_schema
|
||||||
|
from drf_yasg import openapi
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework import status
|
||||||
|
import os
|
||||||
|
import glob
|
||||||
|
|
||||||
|
from Subscriptions.serializers import RegistrationSchoolFileTemplateSerializer
|
||||||
|
from Subscriptions.models import RegistrationSchoolFileTemplate
|
||||||
|
from N3wtSchool import bdd
|
||||||
|
import logging
|
||||||
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||||
|
import Subscriptions.util as util
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class RegistrationSchoolFileTemplateView(APIView):
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Récupère tous les templates d'inscription pour un établissement donné",
|
||||||
|
manual_parameters=[
|
||||||
|
openapi.Parameter(
|
||||||
|
'establishment_id',
|
||||||
|
openapi.IN_QUERY,
|
||||||
|
description="ID de l'établissement",
|
||||||
|
type=openapi.TYPE_INTEGER,
|
||||||
|
required=True
|
||||||
|
)
|
||||||
|
],
|
||||||
|
responses={200: RegistrationSchoolFileTemplateSerializer(many=True)}
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
establishment_id = request.GET.get('establishment_id')
|
||||||
|
if not establishment_id:
|
||||||
|
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Filtrer les templates liés à l'établissement via master.groups.establishment
|
||||||
|
templates = RegistrationSchoolFileTemplate.objects.filter(
|
||||||
|
master__groups__establishment__id=establishment_id
|
||||||
|
).distinct()
|
||||||
|
serializer = RegistrationSchoolFileTemplateSerializer(templates, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Crée un nouveau template d'inscription",
|
||||||
|
request_body=RegistrationSchoolFileTemplateSerializer,
|
||||||
|
responses={
|
||||||
|
201: RegistrationSchoolFileTemplateSerializer,
|
||||||
|
400: "Données invalides"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
serializer = RegistrationSchoolFileTemplateSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
class RegistrationSchoolFileTemplateSimpleView(APIView):
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Récupère un template d'inscription spécifique",
|
||||||
|
responses={
|
||||||
|
200: RegistrationSchoolFileTemplateSerializer,
|
||||||
|
404: "Template non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def get(self, request, id):
|
||||||
|
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
|
||||||
|
if template is None:
|
||||||
|
return JsonResponse({"errorMessage":'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
serializer = RegistrationSchoolFileTemplateSerializer(template)
|
||||||
|
return JsonResponse(serializer.data, safe=False)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Met à jour un template d'inscription existant",
|
||||||
|
request_body=RegistrationSchoolFileTemplateSerializer,
|
||||||
|
responses={
|
||||||
|
200: RegistrationSchoolFileTemplateSerializer,
|
||||||
|
400: "Données invalides",
|
||||||
|
404: "Template non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def put(self, request, id):
|
||||||
|
# Normaliser la payload (support form-data avec champ 'data' JSON ou fichier JSON)
|
||||||
|
payload, resp = util.build_payload_from_request(request)
|
||||||
|
if resp is not None:
|
||||||
|
return resp
|
||||||
|
|
||||||
|
# Synchroniser fields[].value dans le payload AVANT le serializer (pour les formulaires dynamiques)
|
||||||
|
formTemplateData = payload.get('formTemplateData')
|
||||||
|
if formTemplateData and isinstance(formTemplateData, dict):
|
||||||
|
responses = None
|
||||||
|
if "responses" in formTemplateData:
|
||||||
|
resp = formTemplateData["responses"]
|
||||||
|
if isinstance(resp, dict) and "responses" in resp:
|
||||||
|
responses = resp["responses"]
|
||||||
|
elif isinstance(resp, dict):
|
||||||
|
responses = resp
|
||||||
|
if responses and "fields" in formTemplateData:
|
||||||
|
for field in formTemplateData["fields"]:
|
||||||
|
field_id = field.get("id")
|
||||||
|
if field_id and field_id in responses:
|
||||||
|
field["value"] = responses[field_id]
|
||||||
|
payload['formTemplateData'] = formTemplateData
|
||||||
|
|
||||||
|
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
|
||||||
|
if template is None:
|
||||||
|
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
# Cas 1 : Upload d'un fichier existant (PDF/image)
|
||||||
|
if 'file' in request.FILES:
|
||||||
|
upload = request.FILES['file']
|
||||||
|
file_field = template.file
|
||||||
|
upload_name = upload.name
|
||||||
|
upload_dir = os.path.dirname(file_field.path) if file_field and file_field.name else None
|
||||||
|
if upload_dir:
|
||||||
|
base_name, _ = os.path.splitext(upload_name)
|
||||||
|
pattern = os.path.join(upload_dir, f"{base_name}.*")
|
||||||
|
for f in glob.glob(pattern):
|
||||||
|
try:
|
||||||
|
if os.path.exists(f):
|
||||||
|
os.remove(f)
|
||||||
|
logger.info(f"Suppression du fichier existant (pattern): {f}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur suppression fichier existant (pattern): {e}")
|
||||||
|
target_path = os.path.join(upload_dir, upload_name)
|
||||||
|
if os.path.exists(target_path):
|
||||||
|
try:
|
||||||
|
os.remove(target_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur suppression fichier cible: {e}")
|
||||||
|
# On écrase le fichier existant sans passer par le serializer
|
||||||
|
template.file.save(upload_name, upload, save=True)
|
||||||
|
return Response({'message': 'Template mis à jour avec succès', 'data': RegistrationSchoolFileTemplateSerializer(template).data}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
# Cas 2 : Formulaire dynamique (JSON)
|
||||||
|
serializer = RegistrationSchoolFileTemplateSerializer(template, data=payload)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
# Régénérer le PDF si besoin
|
||||||
|
formTemplateData = serializer.validated_data.get('formTemplateData')
|
||||||
|
if (
|
||||||
|
formTemplateData
|
||||||
|
and isinstance(formTemplateData, dict)
|
||||||
|
and formTemplateData.get("fields")
|
||||||
|
and hasattr(template, "file")
|
||||||
|
):
|
||||||
|
old_pdf_name = None
|
||||||
|
if template.file and template.file.name:
|
||||||
|
old_pdf_name = os.path.basename(template.file.name)
|
||||||
|
try:
|
||||||
|
template.file.delete(save=False)
|
||||||
|
if os.path.exists(template.file.path):
|
||||||
|
os.remove(template.file.path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la suppression du fichier existant: {e}")
|
||||||
|
from Subscriptions.util import generate_form_json_pdf
|
||||||
|
pdf_file = generate_form_json_pdf(template.registration_form, formTemplateData)
|
||||||
|
pdf_filename = old_pdf_name or f"{template.name}_{template.id}.pdf"
|
||||||
|
template.file.save(pdf_filename, pdf_file, save=True)
|
||||||
|
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@swagger_auto_schema(
|
||||||
|
operation_description="Supprime un template d'inscription",
|
||||||
|
responses={
|
||||||
|
204: "Suppression réussie",
|
||||||
|
404: "Template non trouvé"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def delete(self, request, id):
|
||||||
|
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
|
||||||
|
if template is not None:
|
||||||
|
# Suppression du fichier PDF associé
|
||||||
|
if template.file and template.file.name:
|
||||||
|
file_path = template.file.path
|
||||||
|
template.file.delete(save=False)
|
||||||
|
# Vérification post-suppression
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
try:
|
||||||
|
os.remove(file_path)
|
||||||
|
logger.info(f"Fichier supprimé manuellement: {file_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la suppression manuelle du fichier: {e}")
|
||||||
|
template.delete()
|
||||||
|
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||||
|
else:
|
||||||
|
return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||||
Binary file not shown.
2
Back-End/runTests.sh
Executable file
2
Back-End/runTests.sh
Executable file
@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
docker exec -it n3wt-school-backend-1 python3 manage.py test --settings=N3wtSchool.test_settings Auth.tests
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
|
from watchfiles import run_process
|
||||||
|
|
||||||
def run_command(command):
|
def run_command(command):
|
||||||
process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
@ -11,20 +12,32 @@ def run_command(command):
|
|||||||
return process.returncode
|
return process.returncode
|
||||||
|
|
||||||
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'
|
||||||
|
migrate_data = os.getenv('migrate_data', 'false').lower() == 'true'
|
||||||
|
watch_mode = os.getenv('DJANGO_WATCH', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
collect_static_cmd = [
|
||||||
|
["python", "manage.py", "collectstatic", "--noinput"]
|
||||||
|
]
|
||||||
|
|
||||||
|
flush_data_cmd = [
|
||||||
|
["python", "manage.py", "flush", "--noinput"]
|
||||||
|
]
|
||||||
|
|
||||||
|
migrate_commands = [
|
||||||
|
["python", "manage.py", "makemigrations", "Common", "--noinput"],
|
||||||
|
["python", "manage.py", "makemigrations", "Establishment", "--noinput"],
|
||||||
|
["python", "manage.py", "makemigrations", "Settings", "--noinput"],
|
||||||
|
["python", "manage.py", "makemigrations", "Subscriptions", "--noinput"],
|
||||||
|
["python", "manage.py", "makemigrations", "Planning", "--noinput"],
|
||||||
|
["python", "manage.py", "makemigrations", "GestionNotification", "--noinput"],
|
||||||
|
["python", "manage.py", "makemigrations", "GestionEmail", "--noinput"],
|
||||||
|
["python", "manage.py", "makemigrations", "GestionMessagerie", "--noinput"],
|
||||||
|
["python", "manage.py", "makemigrations", "Auth", "--noinput"],
|
||||||
|
["python", "manage.py", "makemigrations", "School", "--noinput"]
|
||||||
|
]
|
||||||
|
|
||||||
commands = [
|
commands = [
|
||||||
["python", "manage.py", "collectstatic", "--noinput"],
|
|
||||||
#["python", "manage.py", "flush", "--noinput"],
|
|
||||||
# ["python", "manage.py", "makemigrations", "Common", "--noinput"],
|
|
||||||
# ["python", "manage.py", "makemigrations", "Establishment", "--noinput"],
|
|
||||||
# ["python", "manage.py", "makemigrations", "Settings", "--noinput"],
|
|
||||||
# ["python", "manage.py", "makemigrations", "Subscriptions", "--noinput"],
|
|
||||||
# ["python", "manage.py", "makemigrations", "Planning", "--noinput"],
|
|
||||||
# ["python", "manage.py", "makemigrations", "GestionNotification", "--noinput"],
|
|
||||||
# ["python", "manage.py", "makemigrations", "GestionEmail", "--noinput"],
|
|
||||||
# ["python", "manage.py", "makemigrations", "GestionMessagerie", "--noinput"],
|
|
||||||
# ["python", "manage.py", "makemigrations", "Auth", "--noinput"],
|
|
||||||
# ["python", "manage.py", "makemigrations", "School", "--noinput"],
|
|
||||||
["python", "manage.py", "migrate", "--noinput"]
|
["python", "manage.py", "migrate", "--noinput"]
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -32,23 +45,70 @@ test_commands = [
|
|||||||
["python", "manage.py", "init_mock_datas"]
|
["python", "manage.py", "init_mock_datas"]
|
||||||
]
|
]
|
||||||
|
|
||||||
for command in commands:
|
def run_daphne():
|
||||||
if run_command(command) != 0:
|
try:
|
||||||
exit(1)
|
result = subprocess.run([
|
||||||
|
"daphne", "-b", "0.0.0.0", "-p", "8080", "N3wtSchool.asgi:application"
|
||||||
|
])
|
||||||
|
return result.returncode
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("Arrêt de Daphne (KeyboardInterrupt)")
|
||||||
|
return 0
|
||||||
|
|
||||||
#if test_mode:
|
if __name__ == "__main__":
|
||||||
# for test_command in test_commands:
|
|
||||||
# if run_command(test_command) != 0:
|
|
||||||
# exit(1)
|
|
||||||
|
|
||||||
# Lancer les processus en parallèle
|
for command in collect_static_cmd:
|
||||||
|
if run_command(command) != 0:
|
||||||
|
exit(1)
|
||||||
|
|
||||||
processes = [
|
if flush_data:
|
||||||
subprocess.Popen(["daphne", "-b", "0.0.0.0", "-p", "8080", "N3wtSchool.asgi:application"]),
|
for command in flush_data_cmd:
|
||||||
subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"]),
|
if run_command(command) != 0:
|
||||||
subprocess.Popen(["celery", "-A", "N3wtSchool", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"])
|
exit(1)
|
||||||
]
|
|
||||||
|
|
||||||
# Attendre la fin des processus
|
|
||||||
for process in processes:
|
for command in migrate_commands:
|
||||||
process.wait()
|
if run_command(command) != 0:
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
for command in commands:
|
||||||
|
if run_command(command) != 0:
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
if test_mode:
|
||||||
|
for test_command in test_commands:
|
||||||
|
if run_command(test_command) != 0:
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
if watch_mode:
|
||||||
|
celery_worker = subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"])
|
||||||
|
celery_beat = subprocess.Popen(["celery", "-A", "N3wtSchool", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"])
|
||||||
|
try:
|
||||||
|
run_process(
|
||||||
|
'.',
|
||||||
|
target=run_daphne
|
||||||
|
)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("Arrêt demandé (KeyboardInterrupt)")
|
||||||
|
finally:
|
||||||
|
celery_worker.terminate()
|
||||||
|
celery_beat.terminate()
|
||||||
|
celery_worker.wait()
|
||||||
|
celery_beat.wait()
|
||||||
|
else:
|
||||||
|
processes = [
|
||||||
|
subprocess.Popen([
|
||||||
|
"daphne", "-b", "0.0.0.0", "-p", "8080", "N3wtSchool.asgi:application"
|
||||||
|
]),
|
||||||
|
subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"]),
|
||||||
|
subprocess.Popen(["celery", "-A", "N3wtSchool", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"])
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
for process in processes:
|
||||||
|
process.wait()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("Arrêt demandé (KeyboardInterrupt)")
|
||||||
|
for process in processes:
|
||||||
|
process.terminate()
|
||||||
|
for process in processes:
|
||||||
|
process.wait()
|
||||||
@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"presets": ["next/babel"],
|
|
||||||
"plugins": []
|
|
||||||
}
|
|
||||||
@ -42,14 +42,9 @@ const nextConfig = {
|
|||||||
NEXT_PUBLIC_USE_FAKE_DATA: process.env.NEXT_PUBLIC_USE_FAKE_DATA || 'false',
|
NEXT_PUBLIC_USE_FAKE_DATA: process.env.NEXT_PUBLIC_USE_FAKE_DATA || 'false',
|
||||||
AUTH_SECRET: process.env.AUTH_SECRET || 'false',
|
AUTH_SECRET: process.env.AUTH_SECRET || 'false',
|
||||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL || 'http://localhost:3000',
|
NEXTAUTH_URL: process.env.NEXTAUTH_URL || 'http://localhost:3000',
|
||||||
DOCUSEAL_API_KEY: process.env.DOCUSEAL_API_KEY,
|
|
||||||
},
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
|
||||||
source: '/api/documents/:path*',
|
|
||||||
destination: 'https://api.docuseal.com/v1/documents/:path*',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
source: '/api/auth/:path*',
|
source: '/api/auth/:path*',
|
||||||
destination: '/api/auth/:path*', // Exclure les routes NextAuth des réécritures de proxy
|
destination: '/api/auth/:path*', // Exclure les routes NextAuth des réécritures de proxy
|
||||||
|
|||||||
37
Front-End/package-lock.json
generated
37
Front-End/package-lock.json
generated
@ -1,14 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "n3wt-school-front-end",
|
"name": "n3wt-school-front-end",
|
||||||
"version": "0.0.1",
|
"version": "0.0.3",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "n3wt-school-front-end",
|
"name": "n3wt-school-front-end",
|
||||||
"version": "0.0.1",
|
"version": "0.0.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docuseal/react": "^1.0.56",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@ -29,6 +28,7 @@
|
|||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
|
"react-hook-form": "^7.62.0",
|
||||||
"react-international-phone": "^4.5.0",
|
"react-international-phone": "^4.5.0",
|
||||||
"react-quill": "^2.0.0",
|
"react-quill": "^2.0.0",
|
||||||
"react-tooltip": "^5.28.0"
|
"react-tooltip": "^5.28.0"
|
||||||
@ -536,11 +536,6 @@
|
|||||||
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
|
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@docuseal/react": {
|
|
||||||
"version": "1.0.66",
|
|
||||||
"resolved": "https://registry.npmjs.org/@docuseal/react/-/react-1.0.66.tgz",
|
|
||||||
"integrity": "sha512-rYG58gv8Uw1cTtjbHdgWgWBWpLMbIwDVsS3kN27w4sz/eDJilZieePUDS4eLKJ8keBN05BSjxD/iWQpaTBKZLg=="
|
|
||||||
},
|
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
|
||||||
@ -8834,6 +8829,21 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-hook-form": {
|
||||||
|
"version": "7.62.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz",
|
||||||
|
"integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/react-hook-form"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-international-phone": {
|
"node_modules/react-international-phone": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-international-phone/-/react-international-phone-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-international-phone/-/react-international-phone-4.5.0.tgz",
|
||||||
@ -11253,11 +11263,6 @@
|
|||||||
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
|
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"@docuseal/react": {
|
|
||||||
"version": "1.0.66",
|
|
||||||
"resolved": "https://registry.npmjs.org/@docuseal/react/-/react-1.0.66.tgz",
|
|
||||||
"integrity": "sha512-rYG58gv8Uw1cTtjbHdgWgWBWpLMbIwDVsS3kN27w4sz/eDJilZieePUDS4eLKJ8keBN05BSjxD/iWQpaTBKZLg=="
|
|
||||||
},
|
|
||||||
"@eslint-community/eslint-utils": {
|
"@eslint-community/eslint-utils": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
|
||||||
@ -17160,6 +17165,12 @@
|
|||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"react-hook-form": {
|
||||||
|
"version": "7.62.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz",
|
||||||
|
"integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==",
|
||||||
|
"requires": {}
|
||||||
|
},
|
||||||
"react-international-phone": {
|
"react-international-phone": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-international-phone/-/react-international-phone-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-international-phone/-/react-international-phone-4.5.0.tgz",
|
||||||
|
|||||||
@ -14,7 +14,6 @@
|
|||||||
"test:coverage": "jest --coverage"
|
"test:coverage": "jest --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docuseal/react": "^1.0.56",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@ -35,20 +34,21 @@
|
|||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
|
"react-hook-form": "^7.62.0",
|
||||||
"react-international-phone": "^4.5.0",
|
"react-international-phone": "^4.5.0",
|
||||||
"react-quill": "^2.0.0",
|
"react-quill": "^2.0.0",
|
||||||
"react-tooltip": "^5.28.0"
|
"react-tooltip": "^5.28.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/react": "^13.4.0",
|
|
||||||
"@testing-library/jest-dom": "^5.16.5",
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
|
"@testing-library/react": "^13.4.0",
|
||||||
"@testing-library/user-event": "^14.4.3",
|
"@testing-library/user-event": "^14.4.3",
|
||||||
"jest": "^29.7.0",
|
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.2.11",
|
"eslint-config-next": "14.2.11",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.14"
|
"tailwindcss": "^3.4.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
Front-End/public/icons/icon.svg
Normal file
4
Front-End/public/icons/icon.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="80" fill="#10b981"/>
|
||||||
|
<text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Arial, sans-serif" font-weight="bold" font-size="220" fill="white">N3</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 289 B |
48
Front-End/public/sw.js
Normal file
48
Front-End/public/sw.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
const CACHE_NAME = 'n3wt-school-v1';
|
||||||
|
|
||||||
|
const STATIC_ASSETS = [
|
||||||
|
'/',
|
||||||
|
'/favicon.svg',
|
||||||
|
'/favicon.ico',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
// Ne pas intercepter les requêtes API ou d'authentification
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (
|
||||||
|
url.pathname.startsWith('/api/') ||
|
||||||
|
url.pathname.startsWith('/_next/') ||
|
||||||
|
event.request.method !== 'GET'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
// Mettre en cache les réponses réussies des ressources statiques
|
||||||
|
if (response.ok && url.origin === self.location.origin) {
|
||||||
|
const cloned = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, cloned));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(event.request))
|
||||||
|
);
|
||||||
|
});
|
||||||
286
Front-End/src/app/[locale]/admin/grades/[studentId]/page.js
Normal file
286
Front-End/src/app/[locale]/admin/grades/[studentId]/page.js
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
'use client';
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import SelectChoice from '@/components/Form/SelectChoice';
|
||||||
|
import Attendance from '@/components/Grades/Attendance';
|
||||||
|
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
|
||||||
|
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
|
||||||
|
import Button from '@/components/Form/Button';
|
||||||
|
import logger from '@/utils/logger';
|
||||||
|
import { FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL, BASE_URL } from '@/utils/Url';
|
||||||
|
import {
|
||||||
|
fetchStudents,
|
||||||
|
fetchStudentCompetencies,
|
||||||
|
fetchAbsences,
|
||||||
|
editAbsences,
|
||||||
|
deleteAbsences,
|
||||||
|
} from '@/app/actions/subscriptionAction';
|
||||||
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
|
import { useClasses } from '@/context/ClassesContext';
|
||||||
|
import { Award, ArrowLeft } from 'lucide-react';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
|
|
||||||
|
function getPeriodString(selectedPeriod, frequency) {
|
||||||
|
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
|
||||||
|
const nextYear = (year + 1).toString();
|
||||||
|
const schoolYear = `${year}-${nextYear}`;
|
||||||
|
if (frequency === 1) return `T${selectedPeriod}_${schoolYear}`;
|
||||||
|
if (frequency === 2) return `S${selectedPeriod}_${schoolYear}`;
|
||||||
|
if (frequency === 3) return `A_${schoolYear}`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StudentGradesPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const studentId = Number(params.studentId);
|
||||||
|
const csrfToken = useCsrfToken();
|
||||||
|
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
|
||||||
|
useEstablishment();
|
||||||
|
const { getNiveauLabel } = useClasses();
|
||||||
|
|
||||||
|
const [student, setStudent] = useState(null);
|
||||||
|
const [studentCompetencies, setStudentCompetencies] = useState(null);
|
||||||
|
const [grades, setGrades] = useState({});
|
||||||
|
const [selectedPeriod, setSelectedPeriod] = useState(null);
|
||||||
|
const [allAbsences, setAllAbsences] = useState([]);
|
||||||
|
|
||||||
|
const getPeriods = () => {
|
||||||
|
if (selectedEstablishmentEvaluationFrequency === 1) {
|
||||||
|
return [
|
||||||
|
{ label: 'Trimestre 1', value: 1, start: '09-01', end: '12-31' },
|
||||||
|
{ label: 'Trimestre 2', value: 2, start: '01-01', end: '03-31' },
|
||||||
|
{ label: 'Trimestre 3', value: 3, start: '04-01', end: '07-15' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (selectedEstablishmentEvaluationFrequency === 2) {
|
||||||
|
return [
|
||||||
|
{ label: 'Semestre 1', value: 1, start: '09-01', end: '01-31' },
|
||||||
|
{ label: 'Semestre 2', value: 2, start: '02-01', end: '07-15' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (selectedEstablishmentEvaluationFrequency === 3) {
|
||||||
|
return [{ label: 'Année', value: 1, start: '09-01', end: '07-15' }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load student info
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedEstablishmentId) {
|
||||||
|
fetchStudents(selectedEstablishmentId, null, 5)
|
||||||
|
.then((students) => {
|
||||||
|
const found = students.find((s) => s.id === studentId);
|
||||||
|
setStudent(found || null);
|
||||||
|
})
|
||||||
|
.catch((error) => logger.error('Error fetching students:', error));
|
||||||
|
}
|
||||||
|
}, [selectedEstablishmentId, studentId]);
|
||||||
|
|
||||||
|
// Auto-select current period
|
||||||
|
useEffect(() => {
|
||||||
|
const periods = getPeriods();
|
||||||
|
const today = dayjs();
|
||||||
|
const current = periods.find((p) => {
|
||||||
|
const start = dayjs(`${today.year()}-${p.start}`);
|
||||||
|
const end = dayjs(`${today.year()}-${p.end}`);
|
||||||
|
return (
|
||||||
|
today.isAfter(start.subtract(1, 'day')) &&
|
||||||
|
today.isBefore(end.add(1, 'day'))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
setSelectedPeriod(current ? current.value : null);
|
||||||
|
}, [selectedEstablishmentEvaluationFrequency]);
|
||||||
|
|
||||||
|
// Load competencies
|
||||||
|
useEffect(() => {
|
||||||
|
if (studentId && selectedPeriod) {
|
||||||
|
const periodString = getPeriodString(
|
||||||
|
selectedPeriod,
|
||||||
|
selectedEstablishmentEvaluationFrequency
|
||||||
|
);
|
||||||
|
fetchStudentCompetencies(studentId, periodString)
|
||||||
|
.then((data) => {
|
||||||
|
setStudentCompetencies(data);
|
||||||
|
if (data && data.data) {
|
||||||
|
const initialGrades = {};
|
||||||
|
data.data.forEach((domaine) => {
|
||||||
|
domaine.categories.forEach((cat) => {
|
||||||
|
cat.competences.forEach((comp) => {
|
||||||
|
initialGrades[comp.competence_id] = comp.score ?? 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setGrades(initialGrades);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) =>
|
||||||
|
logger.error('Error fetching studentCompetencies:', error)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setGrades({});
|
||||||
|
setStudentCompetencies(null);
|
||||||
|
}
|
||||||
|
}, [studentId, selectedPeriod]);
|
||||||
|
|
||||||
|
// Load absences
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedEstablishmentId) {
|
||||||
|
fetchAbsences(selectedEstablishmentId)
|
||||||
|
.then((data) => setAllAbsences(data))
|
||||||
|
.catch((error) =>
|
||||||
|
logger.error('Erreur lors du fetch des absences:', error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [selectedEstablishmentId]);
|
||||||
|
|
||||||
|
const absences = React.useMemo(() => {
|
||||||
|
return allAbsences
|
||||||
|
.filter((a) => a.student === studentId)
|
||||||
|
.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
date: a.day,
|
||||||
|
type: [2, 1].includes(a.reason) ? 'Absence' : 'Retard',
|
||||||
|
reason: a.reason,
|
||||||
|
justified: [1, 3].includes(a.reason),
|
||||||
|
moment: a.moment,
|
||||||
|
commentaire: a.commentaire,
|
||||||
|
}));
|
||||||
|
}, [allAbsences, studentId]);
|
||||||
|
|
||||||
|
const handleToggleJustify = (absence) => {
|
||||||
|
const newReason =
|
||||||
|
absence.type === 'Absence'
|
||||||
|
? absence.justified ? 2 : 1
|
||||||
|
: absence.justified ? 4 : 3;
|
||||||
|
|
||||||
|
editAbsences(absence.id, { ...absence, reason: newReason }, csrfToken)
|
||||||
|
.then(() => {
|
||||||
|
setAllAbsences((prev) =>
|
||||||
|
prev.map((a) =>
|
||||||
|
a.id === absence.id ? { ...a, reason: newReason } : a
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((e) => logger.error('Erreur lors du changement de justification', e));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAbsence = (absence) => {
|
||||||
|
return deleteAbsences(absence.id, csrfToken)
|
||||||
|
.then(() => {
|
||||||
|
setAllAbsences((prev) => prev.filter((a) => a.id !== absence.id));
|
||||||
|
})
|
||||||
|
.catch((e) =>
|
||||||
|
logger.error("Erreur lors de la suppression de l'absence", e)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-8 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/admin/grades')}
|
||||||
|
className="p-2 rounded-md hover:bg-gray-100 border border-gray-200"
|
||||||
|
aria-label="Retour à la liste"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-xl font-bold text-gray-800">Suivi pédagogique</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Student profile */}
|
||||||
|
{student && (
|
||||||
|
<div className="bg-stone-50 rounded-lg shadow-sm border border-gray-100 p-4 md:p-6 flex flex-col sm:flex-row items-center sm:items-start gap-4">
|
||||||
|
{student.photo ? (
|
||||||
|
<img
|
||||||
|
src={`${BASE_URL}${student.photo}`}
|
||||||
|
alt={`${student.first_name} ${student.last_name}`}
|
||||||
|
className="w-24 h-24 object-cover rounded-full border-4 border-emerald-200 shadow"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-24 h-24 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-3xl border-4 border-emerald-100">
|
||||||
|
{student.first_name?.[0]}
|
||||||
|
{student.last_name?.[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 text-center sm:text-left">
|
||||||
|
<div className="text-xl font-bold text-emerald-800">
|
||||||
|
{student.last_name} {student.first_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 mt-1">
|
||||||
|
Niveau :{' '}
|
||||||
|
<span className="font-medium">
|
||||||
|
{getNiveauLabel(student.level)}
|
||||||
|
</span>
|
||||||
|
{' | '}
|
||||||
|
Classe :{' '}
|
||||||
|
<span className="font-medium">
|
||||||
|
{student.associated_class_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Period selector + Evaluate button */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-end gap-3 w-full sm:w-auto">
|
||||||
|
<div className="w-full sm:w-44">
|
||||||
|
<SelectChoice
|
||||||
|
name="period"
|
||||||
|
label="Période"
|
||||||
|
placeHolder="Choisir la période"
|
||||||
|
choices={getPeriods().map((period) => {
|
||||||
|
const today = dayjs();
|
||||||
|
const end = dayjs(`${today.year()}-${period.end}`);
|
||||||
|
return {
|
||||||
|
value: period.value,
|
||||||
|
label: period.label,
|
||||||
|
disabled: today.isAfter(end),
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
selected={selectedPeriod || ''}
|
||||||
|
callback={(e) => setSelectedPeriod(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
primary
|
||||||
|
onClick={() => {
|
||||||
|
const periodString = getPeriodString(
|
||||||
|
selectedPeriod,
|
||||||
|
selectedEstablishmentEvaluationFrequency
|
||||||
|
);
|
||||||
|
router.push(
|
||||||
|
`${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${studentId}&period=${periodString}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600 w-full sm:w-auto"
|
||||||
|
icon={<Award className="w-5 h-5" />}
|
||||||
|
text="Évaluer"
|
||||||
|
title="Évaluer l'élève"
|
||||||
|
disabled={!selectedPeriod}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats + Absences */}
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Attendance
|
||||||
|
absences={absences}
|
||||||
|
onToggleJustify={handleToggleJustify}
|
||||||
|
onDelete={handleDeleteAbsence}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<GradesStatsCircle grades={grades} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<GradesDomainBarChart studentCompetencies={studentCompetencies} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,479 +1,351 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import SelectChoice from '@/components/SelectChoice';
|
import { useRouter } from 'next/navigation';
|
||||||
import AcademicResults from '@/components/Grades/AcademicResults';
|
import { Award, Eye, Search } from 'lucide-react';
|
||||||
import Attendance from '@/components/Grades/Attendance';
|
import SectionHeader from '@/components/SectionHeader';
|
||||||
import Remarks from '@/components/Grades/Remarks';
|
import Table from '@/components/Table';
|
||||||
import WorkPlan from '@/components/Grades/WorkPlan';
|
|
||||||
import Homeworks from '@/components/Grades/Homeworks';
|
|
||||||
import SpecificEvaluations from '@/components/Grades/SpecificEvaluations';
|
|
||||||
import Orientation from '@/components/Grades/Orientation';
|
|
||||||
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import {
|
import {
|
||||||
FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL,
|
|
||||||
BASE_URL,
|
BASE_URL,
|
||||||
|
FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL,
|
||||||
|
FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL,
|
||||||
} from '@/utils/Url';
|
} from '@/utils/Url';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import {
|
import {
|
||||||
fetchStudents,
|
fetchStudents,
|
||||||
fetchStudentCompetencies,
|
fetchStudentCompetencies,
|
||||||
searchStudents,
|
|
||||||
fetchAbsences,
|
fetchAbsences,
|
||||||
editAbsences,
|
|
||||||
deleteAbsences,
|
|
||||||
} from '@/app/actions/subscriptionAction';
|
} from '@/app/actions/subscriptionAction';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
import { useClasses } from '@/context/ClassesContext';
|
import { useClasses } from '@/context/ClassesContext';
|
||||||
import { Award, FileText } from 'lucide-react';
|
|
||||||
import SectionHeader from '@/components/SectionHeader';
|
|
||||||
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
|
|
||||||
import InputText from '@/components/InputText';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useCsrfToken } from '@/context/CsrfContext';
|
|
||||||
|
function getPeriodString(periodValue, frequency) {
|
||||||
|
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
|
||||||
|
const schoolYear = `${year}-${year + 1}`;
|
||||||
|
if (frequency === 1) return `T${periodValue}_${schoolYear}`;
|
||||||
|
if (frequency === 2) return `S${periodValue}_${schoolYear}`;
|
||||||
|
if (frequency === 3) return `A_${schoolYear}`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcPercent(data) {
|
||||||
|
if (!data?.data) return null;
|
||||||
|
const scores = [];
|
||||||
|
data.data.forEach((d) =>
|
||||||
|
d.categories.forEach((c) =>
|
||||||
|
c.competences.forEach((comp) => scores.push(comp.score ?? 0))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (!scores.length) return null;
|
||||||
|
return Math.round(
|
||||||
|
(scores.filter((s) => s === 3).length / scores.length) * 100
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPeriodColumns(frequency) {
|
||||||
|
if (frequency === 1)
|
||||||
|
return [
|
||||||
|
{ label: 'Trimestre 1', value: 1 },
|
||||||
|
{ label: 'Trimestre 2', value: 2 },
|
||||||
|
{ label: 'Trimestre 3', value: 3 },
|
||||||
|
];
|
||||||
|
if (frequency === 2)
|
||||||
|
return [
|
||||||
|
{ label: 'Semestre 1', value: 1 },
|
||||||
|
{ label: 'Semestre 2', value: 2 },
|
||||||
|
];
|
||||||
|
if (frequency === 3) return [{ label: 'Année', value: 1 }];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentPeriodValue(frequency) {
|
||||||
|
const periods =
|
||||||
|
{
|
||||||
|
1: [
|
||||||
|
{ value: 1, start: '09-01', end: '12-31' },
|
||||||
|
{ value: 2, start: '01-01', end: '03-31' },
|
||||||
|
{ value: 3, start: '04-01', end: '07-15' },
|
||||||
|
],
|
||||||
|
2: [
|
||||||
|
{ value: 1, start: '09-01', end: '01-31' },
|
||||||
|
{ value: 2, start: '02-01', end: '07-15' },
|
||||||
|
],
|
||||||
|
3: [{ value: 1, start: '09-01', end: '07-15' }],
|
||||||
|
}[frequency] || [];
|
||||||
|
const today = dayjs();
|
||||||
|
const current = periods.find(
|
||||||
|
(p) =>
|
||||||
|
today.isAfter(dayjs(`${today.year()}-${p.start}`).subtract(1, 'day')) &&
|
||||||
|
today.isBefore(dayjs(`${today.year()}-${p.end}`).add(1, 'day'))
|
||||||
|
);
|
||||||
|
return current?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PercentBadge({ value, loading }) {
|
||||||
|
if (loading) return <span className="text-gray-300 text-xs">…</span>;
|
||||||
|
if (value === null) return <span className="text-gray-400 text-xs">—</span>;
|
||||||
|
const color =
|
||||||
|
value >= 75
|
||||||
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
|
: value >= 50
|
||||||
|
? 'bg-yellow-100 text-yellow-700'
|
||||||
|
: 'bg-red-100 text-red-600';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${color}`}
|
||||||
|
>
|
||||||
|
{value}%
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const csrfToken = useCsrfToken();
|
|
||||||
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
|
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
|
||||||
useEstablishment();
|
useEstablishment();
|
||||||
const { getNiveauLabel } = useClasses();
|
const { getNiveauLabel } = useClasses();
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
selectedStudent: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [students, setStudents] = useState([]);
|
const [students, setStudents] = useState([]);
|
||||||
const [studentCompetencies, setStudentCompetencies] = useState(null);
|
|
||||||
const [grades, setGrades] = useState({});
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState(null);
|
const ITEMS_PER_PAGE = 15;
|
||||||
const [allAbsences, setAllAbsences] = useState([]);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [statsMap, setStatsMap] = useState({});
|
||||||
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
|
const [absencesMap, setAbsencesMap] = useState({});
|
||||||
|
|
||||||
// Définir les périodes selon la fréquence
|
const periodColumns = getPeriodColumns(
|
||||||
const getPeriods = () => {
|
selectedEstablishmentEvaluationFrequency
|
||||||
if (selectedEstablishmentEvaluationFrequency === 1) {
|
);
|
||||||
return [
|
const currentPeriodValue = getCurrentPeriodValue(
|
||||||
{ label: 'Trimestre 1', value: 1, start: '09-01', end: '12-31' },
|
selectedEstablishmentEvaluationFrequency
|
||||||
{ label: 'Trimestre 2', value: 2, start: '01-01', end: '03-31' },
|
);
|
||||||
{ label: 'Trimestre 3', value: 3, start: '04-01', end: '07-15' },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (selectedEstablishmentEvaluationFrequency === 2) {
|
|
||||||
return [
|
|
||||||
{ label: 'Semestre 1', value: 1, start: '09-01', end: '01-31' },
|
|
||||||
{ label: 'Semestre 2', value: 2, start: '02-01', end: '07-15' },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (selectedEstablishmentEvaluationFrequency === 3) {
|
|
||||||
return [{ label: 'Année', value: 1, start: '09-01', end: '07-15' }];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sélection automatique de la période courante
|
|
||||||
useEffect(() => {
|
|
||||||
if (!formData.selectedStudent) {
|
|
||||||
setSelectedPeriod(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const periods = getPeriods();
|
|
||||||
const today = dayjs();
|
|
||||||
const current = periods.find((p) => {
|
|
||||||
const start = dayjs(`${today.year()}-${p.start}`);
|
|
||||||
const end = dayjs(`${today.year()}-${p.end}`);
|
|
||||||
return (
|
|
||||||
today.isAfter(start.subtract(1, 'day')) &&
|
|
||||||
today.isBefore(end.add(1, 'day'))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
setSelectedPeriod(current ? current.value : null);
|
|
||||||
}, [formData.selectedStudent, selectedEstablishmentEvaluationFrequency]);
|
|
||||||
|
|
||||||
const academicResults = [
|
|
||||||
{
|
|
||||||
subject: 'Mathématiques',
|
|
||||||
grade: 16,
|
|
||||||
average: 14,
|
|
||||||
appreciation: 'Très bon travail',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
subject: 'Français',
|
|
||||||
grade: 15,
|
|
||||||
average: 13,
|
|
||||||
appreciation: 'Bonne participation',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const remarks = [
|
|
||||||
{
|
|
||||||
date: '2023-09-10',
|
|
||||||
teacher: 'Mme Dupont',
|
|
||||||
comment: 'Participation active en classe.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
date: '2023-09-20',
|
|
||||||
teacher: 'M. Martin',
|
|
||||||
comment: 'Doit améliorer la concentration.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const workPlan = [
|
|
||||||
{
|
|
||||||
objective: 'Renforcer la lecture',
|
|
||||||
support: 'Exercices hebdomadaires',
|
|
||||||
followUp: 'En cours',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
objective: 'Maîtriser les tables de multiplication',
|
|
||||||
support: 'Jeux éducatifs',
|
|
||||||
followUp: 'À démarrer',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const homeworks = [
|
|
||||||
{ title: 'Rédaction', dueDate: '2023-10-10', status: 'Rendu' },
|
|
||||||
{ title: 'Exercices de maths', dueDate: '2023-10-12', status: 'À faire' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const specificEvaluations = [
|
|
||||||
{
|
|
||||||
test: 'Bilan de compétences',
|
|
||||||
date: '2023-09-25',
|
|
||||||
result: 'Bon niveau général',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const orientation = [
|
|
||||||
{
|
|
||||||
date: '2023-10-01',
|
|
||||||
counselor: 'Mme Leroy',
|
|
||||||
advice: 'Poursuivre en filière générale',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleChange = (field, value) =>
|
|
||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedEstablishmentId) {
|
if (!selectedEstablishmentId) return;
|
||||||
fetchStudents(selectedEstablishmentId, null, 5)
|
fetchStudents(selectedEstablishmentId, null, 5)
|
||||||
.then((studentsData) => {
|
.then((data) => setStudents(data))
|
||||||
setStudents(studentsData);
|
.catch((error) => logger.error('Error fetching students:', error));
|
||||||
})
|
|
||||||
.catch((error) => logger.error('Error fetching students:', error));
|
|
||||||
}
|
|
||||||
}, [selectedEstablishmentId]);
|
|
||||||
|
|
||||||
// Charger les compétences et générer les grades à chaque changement d'élève sélectionné
|
fetchAbsences(selectedEstablishmentId)
|
||||||
useEffect(() => {
|
.then((data) => {
|
||||||
if (formData.selectedStudent && selectedPeriod) {
|
const map = {};
|
||||||
const periodString = getPeriodString(
|
(data || []).forEach((a) => {
|
||||||
selectedPeriod,
|
if ([1, 2].includes(a.reason)) {
|
||||||
selectedEstablishmentEvaluationFrequency
|
map[a.student] = (map[a.student] || 0) + 1;
|
||||||
);
|
|
||||||
fetchStudentCompetencies(formData.selectedStudent, periodString)
|
|
||||||
.then((data) => {
|
|
||||||
setStudentCompetencies(data);
|
|
||||||
// Générer les grades à partir du retour API
|
|
||||||
if (data && data.data) {
|
|
||||||
const initialGrades = {};
|
|
||||||
data.data.forEach((domaine) => {
|
|
||||||
domaine.categories.forEach((cat) => {
|
|
||||||
cat.competences.forEach((comp) => {
|
|
||||||
initialGrades[comp.competence_id] = comp.score ?? 0;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
setGrades(initialGrades);
|
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
.catch((error) =>
|
setAbsencesMap(map);
|
||||||
logger.error('Error fetching studentCompetencies:', error)
|
})
|
||||||
);
|
.catch((error) => logger.error('Error fetching absences:', error));
|
||||||
} else {
|
|
||||||
setGrades({});
|
|
||||||
setStudentCompetencies(null);
|
|
||||||
}
|
|
||||||
}, [formData.selectedStudent, selectedPeriod]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedEstablishmentId) {
|
|
||||||
fetchAbsences(selectedEstablishmentId)
|
|
||||||
.then((data) => setAllAbsences(data))
|
|
||||||
.catch((error) =>
|
|
||||||
logger.error('Erreur lors du fetch des absences:', error)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [selectedEstablishmentId]);
|
}, [selectedEstablishmentId]);
|
||||||
|
|
||||||
// Transforme les absences backend pour l'élève sélectionné
|
// Fetch stats for all students × all periods
|
||||||
const absences = React.useMemo(() => {
|
useEffect(() => {
|
||||||
if (!formData.selectedStudent) return [];
|
if (!students.length || !selectedEstablishmentEvaluationFrequency) return;
|
||||||
return allAbsences
|
|
||||||
.filter((a) => a.student === formData.selectedStudent)
|
|
||||||
.map((a) => ({
|
|
||||||
id: a.id,
|
|
||||||
date: a.day,
|
|
||||||
type: [2, 1].includes(a.reason) ? 'Absence' : 'Retard',
|
|
||||||
reason: a.reason, // tu peux mapper le code vers un label si besoin
|
|
||||||
justified: [1, 3].includes(a.reason), // 1 et 3 = justifié
|
|
||||||
moment: a.moment,
|
|
||||||
commentaire: a.commentaire,
|
|
||||||
}));
|
|
||||||
}, [allAbsences, formData.selectedStudent]);
|
|
||||||
|
|
||||||
// Fonction utilitaire pour convertir la période sélectionnée en string backend
|
setStatsLoading(true);
|
||||||
function getPeriodString(selectedPeriod, frequency) {
|
const frequency = selectedEstablishmentEvaluationFrequency;
|
||||||
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1; // année scolaire commence en septembre
|
|
||||||
const nextYear = (year + 1).toString();
|
|
||||||
const schoolYear = `${year}-${nextYear}`;
|
|
||||||
if (frequency === 1) return `T${selectedPeriod}_${schoolYear}`;
|
|
||||||
if (frequency === 2) return `S${selectedPeriod}_${schoolYear}`;
|
|
||||||
if (frequency === 3) return `A_${schoolYear}`;
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Callback pour justifier/non justifier une absence
|
const tasks = students.flatMap((student) =>
|
||||||
const handleToggleJustify = (absence) => {
|
periodColumns.map(({ value: periodValue }) => {
|
||||||
// Inverser l'état justifié (1/3 = justifié, 2/4 = non justifié)
|
const periodStr = getPeriodString(periodValue, frequency);
|
||||||
const newReason =
|
return fetchStudentCompetencies(student.id, periodStr)
|
||||||
absence.type === 'Absence'
|
.then((data) => ({ studentId: student.id, periodValue, data }))
|
||||||
? absence.justified
|
.catch(() => ({ studentId: student.id, periodValue, data: null }));
|
||||||
? 2 // Absence non justifiée
|
|
||||||
: 1 // Absence justifiée
|
|
||||||
: absence.justified
|
|
||||||
? 4 // Retard non justifié
|
|
||||||
: 3; // Retard justifié
|
|
||||||
|
|
||||||
editAbsences(absence.id, { ...absence, reason: newReason }, csrfToken)
|
|
||||||
.then(() => {
|
|
||||||
setAllAbsences((prev) =>
|
|
||||||
prev.map((a) =>
|
|
||||||
a.id === absence.id ? { ...a, reason: newReason } : a
|
|
||||||
)
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
);
|
||||||
logger.error('Erreur lors du changement de justification', e);
|
|
||||||
|
Promise.all(tasks).then((results) => {
|
||||||
|
const map = {};
|
||||||
|
results.forEach(({ studentId, periodValue, data }) => {
|
||||||
|
if (!map[studentId]) map[studentId] = {};
|
||||||
|
map[studentId][periodValue] = calcPercent(data);
|
||||||
});
|
});
|
||||||
|
Object.keys(map).forEach((id) => {
|
||||||
|
const vals = Object.values(map[id]).filter((v) => v !== null);
|
||||||
|
map[id].global = vals.length
|
||||||
|
? Math.round(vals.reduce((a, b) => a + b, 0) / vals.length)
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
setStatsMap(map);
|
||||||
|
setStatsLoading(false);
|
||||||
|
});
|
||||||
|
}, [students, selectedEstablishmentEvaluationFrequency]);
|
||||||
|
|
||||||
|
const filteredStudents = students.filter(
|
||||||
|
(student) =>
|
||||||
|
!searchTerm ||
|
||||||
|
`${student.last_name} ${student.first_name}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [searchTerm, students]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredStudents.length / ITEMS_PER_PAGE);
|
||||||
|
const pagedStudents = filteredStudents.slice(
|
||||||
|
(currentPage - 1) * ITEMS_PER_PAGE,
|
||||||
|
currentPage * ITEMS_PER_PAGE
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEvaluer = (e, studentId) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const periodStr = getPeriodString(
|
||||||
|
currentPeriodValue,
|
||||||
|
selectedEstablishmentEvaluationFrequency
|
||||||
|
);
|
||||||
|
router.push(
|
||||||
|
`${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${studentId}&period=${periodStr}`
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Callback pour supprimer une absence
|
const columns = [
|
||||||
const handleDeleteAbsence = (absence) => {
|
{ name: 'Photo', transform: () => null },
|
||||||
return deleteAbsences(absence.id, csrfToken)
|
{ name: 'Élève', transform: () => null },
|
||||||
.then(() => {
|
{ name: 'Niveau', transform: () => null },
|
||||||
setAllAbsences((prev) => prev.filter((a) => a.id !== absence.id));
|
{ name: 'Classe', transform: () => null },
|
||||||
})
|
...periodColumns.map(({ label }) => ({ name: label, transform: () => null })),
|
||||||
.catch((e) => {
|
{ name: 'Stat globale', transform: () => null },
|
||||||
logger.error("Erreur lors de la suppression de l'absence", e);
|
{ name: 'Absences', transform: () => null },
|
||||||
});
|
{ name: 'Actions', transform: () => null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const renderCell = (student, column) => {
|
||||||
|
const stats = statsMap[student.id] || {};
|
||||||
|
switch (column) {
|
||||||
|
case 'Photo':
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center">
|
||||||
|
{student.photo ? (
|
||||||
|
<a
|
||||||
|
href={`${BASE_URL}${student.photo}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`${BASE_URL}${student.photo}`}
|
||||||
|
alt={`${student.first_name} ${student.last_name}`}
|
||||||
|
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 flex items-center justify-center bg-gray-200 rounded-full">
|
||||||
|
<span className="text-gray-500 text-sm font-semibold">
|
||||||
|
{student.first_name?.[0]}{student.last_name?.[0]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'Élève':
|
||||||
|
return (
|
||||||
|
<span className="font-semibold text-gray-700">
|
||||||
|
{student.last_name} {student.first_name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'Niveau':
|
||||||
|
return getNiveauLabel(student.level);
|
||||||
|
case 'Classe':
|
||||||
|
return student.associated_class_id ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
router.push(`${FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL}?schoolClassId=${student.associated_class_id}`);
|
||||||
|
}}
|
||||||
|
className="text-emerald-700 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{student.associated_class_name}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
student.associated_class_name
|
||||||
|
);
|
||||||
|
case 'Stat globale':
|
||||||
|
return (
|
||||||
|
<PercentBadge
|
||||||
|
value={stats.global ?? null}
|
||||||
|
loading={statsLoading && !('global' in stats)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'Absences':
|
||||||
|
return absencesMap[student.id] ? (
|
||||||
|
<span className="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-600">
|
||||||
|
{absencesMap[student.id]}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-xs">0</span>
|
||||||
|
);
|
||||||
|
case 'Actions':
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); router.push(`/admin/grades/${student.id}`); }}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-700 hover:bg-gray-200 transition whitespace-nowrap"
|
||||||
|
title="Voir la fiche"
|
||||||
|
>
|
||||||
|
<Eye size={14} />
|
||||||
|
Fiche
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleEvaluer(e, student.id)}
|
||||||
|
disabled={!currentPeriodValue}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-emerald-100 text-emerald-700 hover:bg-emerald-200 transition whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
title="Évaluer"
|
||||||
|
>
|
||||||
|
<Award size={14} />
|
||||||
|
Évaluer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default: {
|
||||||
|
const col = periodColumns.find((c) => c.label === column);
|
||||||
|
if (col) {
|
||||||
|
return (
|
||||||
|
<PercentBadge
|
||||||
|
value={stats[col.value] ?? null}
|
||||||
|
loading={statsLoading && !(col.value in stats)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8">
|
<div className="p-4 md:p-8 space-y-6">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
icon={Award}
|
icon={Award}
|
||||||
title="Suivi pédagogique"
|
title="Suivi pédagogique"
|
||||||
description="Suivez le parcours d'un élève"
|
description="Suivez le parcours d'un élève"
|
||||||
/>
|
/>
|
||||||
|
<div className="relative flex-grow max-w-md">
|
||||||
{/* Section haute : filtre + bouton + photo élève */}
|
<Search
|
||||||
<div className="flex flex-row gap-8 items-start">
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||||
{/* Colonne gauche : InputText + bouton */}
|
size={20}
|
||||||
<div className="w-4/5 flex items-end gap-4">
|
/>
|
||||||
<div className="flex-[3_3_0%]">
|
<input
|
||||||
<InputText
|
type="text"
|
||||||
name="studentSearch"
|
placeholder="Rechercher un élève"
|
||||||
type="text"
|
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-md"
|
||||||
label="Recherche élève"
|
value={searchTerm}
|
||||||
value={searchTerm}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
/>
|
||||||
placeholder="Rechercher un élève"
|
|
||||||
required={false}
|
|
||||||
enable={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-[1_1_0%]">
|
|
||||||
<SelectChoice
|
|
||||||
name="period"
|
|
||||||
label="Période"
|
|
||||||
placeHolder="Choisir la période"
|
|
||||||
choices={getPeriods().map((period) => {
|
|
||||||
const today = dayjs();
|
|
||||||
const start = dayjs(`${today.year()}-${period.start}`);
|
|
||||||
const end = dayjs(`${today.year()}-${period.end}`);
|
|
||||||
const isPast = today.isAfter(end);
|
|
||||||
return {
|
|
||||||
value: period.value,
|
|
||||||
label: period.label,
|
|
||||||
disabled: isPast,
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
selected={selectedPeriod || ''}
|
|
||||||
callback={(e) => setSelectedPeriod(Number(e.target.value))}
|
|
||||||
disabled={!formData.selectedStudent}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-[1_1_0%] flex items-end">
|
|
||||||
<Button
|
|
||||||
primary
|
|
||||||
onClick={() => {
|
|
||||||
const periodString = getPeriodString(
|
|
||||||
selectedPeriod,
|
|
||||||
selectedEstablishmentEvaluationFrequency
|
|
||||||
);
|
|
||||||
const url = `${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${formData.selectedStudent}&period=${periodString}`;
|
|
||||||
router.push(url);
|
|
||||||
}}
|
|
||||||
className="mb-1 px-4 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600 w-full"
|
|
||||||
icon={<Award className="w-6 h-6" />}
|
|
||||||
text="Evaluer"
|
|
||||||
title="Evaluez l'élève"
|
|
||||||
disabled={!formData.selectedStudent || !selectedPeriod}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Colonne droite : Photo élève */}
|
|
||||||
<div className="w-1/5 flex flex-col items-center justify-center">
|
|
||||||
{formData.selectedStudent &&
|
|
||||||
(() => {
|
|
||||||
const student = students.find(
|
|
||||||
(s) => s.id === formData.selectedStudent
|
|
||||||
);
|
|
||||||
if (!student) return null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{student.photo ? (
|
|
||||||
<img
|
|
||||||
src={`${BASE_URL}${student.photo}`}
|
|
||||||
alt={`${student.first_name} ${student.last_name}`}
|
|
||||||
className="w-32 h-32 object-cover rounded-full border-4 border-emerald-200 mb-4 shadow"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-24 h-24 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-3xl mb-4 border-4 border-emerald-100">
|
|
||||||
{student.first_name?.[0]}
|
|
||||||
{student.last_name?.[0]}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section basse : liste élèves + infos */}
|
<Table
|
||||||
<div className="flex flex-row gap-8 items-start mt-8">
|
data={pagedStudents}
|
||||||
{/* Colonne 1 : Liste des élèves */}
|
columns={columns}
|
||||||
<div className="w-full max-w-xs">
|
renderCell={renderCell}
|
||||||
<h3 className="text-lg font-semibold text-emerald-700 mb-4">
|
itemsPerPage={ITEMS_PER_PAGE}
|
||||||
Liste des élèves
|
currentPage={currentPage}
|
||||||
</h3>
|
totalPages={totalPages}
|
||||||
<ul className="rounded-lg bg-stone-50 shadow border border-gray-100">
|
onPageChange={setCurrentPage}
|
||||||
{students
|
emptyMessage={
|
||||||
.filter(
|
<span className="text-gray-400 text-sm">Aucun élève trouvé</span>
|
||||||
(student) =>
|
}
|
||||||
!searchTerm ||
|
/>
|
||||||
`${student.last_name} ${student.first_name}`
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchTerm.toLowerCase())
|
|
||||||
)
|
|
||||||
.map((student) => (
|
|
||||||
<li
|
|
||||||
key={student.id}
|
|
||||||
className={`flex items-center gap-4 px-4 py-3 hover:bg-emerald-100 cursor-pointer transition ${
|
|
||||||
formData.selectedStudent === student.id
|
|
||||||
? 'bg-emerald-100 border-l-4 border-emerald-400'
|
|
||||||
: 'border-l-2 border-gray-200'
|
|
||||||
}`}
|
|
||||||
onClick={() => handleChange('selectedStudent', student.id)}
|
|
||||||
>
|
|
||||||
{student.photo ? (
|
|
||||||
<img
|
|
||||||
src={`${BASE_URL}${student.photo}`}
|
|
||||||
alt={`${student.first_name} ${student.last_name}`}
|
|
||||||
className="w-10 h-10 object-cover rounded-full border-2 border-emerald-200"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-10 h-10 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-lg border-2 border-emerald-100">
|
|
||||||
{student.first_name?.[0]}
|
|
||||||
{student.last_name?.[0]}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-semibold text-emerald-800">
|
|
||||||
{student.last_name} {student.first_name}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-600">
|
|
||||||
Niveau :{' '}
|
|
||||||
<span className="font-medium">
|
|
||||||
{getNiveauLabel(student.level)}
|
|
||||||
</span>
|
|
||||||
{' | '}
|
|
||||||
Classe :{' '}
|
|
||||||
<span className="font-medium">
|
|
||||||
{student.associated_class_name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Icône PDF si bilan dispo pour la période sélectionnée */}
|
|
||||||
{selectedPeriod &&
|
|
||||||
student.bilans &&
|
|
||||||
Array.isArray(student.bilans) &&
|
|
||||||
(() => {
|
|
||||||
// Génère la string de période attendue
|
|
||||||
const periodString = getPeriodString(
|
|
||||||
selectedPeriod,
|
|
||||||
selectedEstablishmentEvaluationFrequency
|
|
||||||
);
|
|
||||||
const bilan = student.bilans.find(
|
|
||||||
(b) => b.period === periodString && b.file
|
|
||||||
);
|
|
||||||
if (bilan) {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={`${BASE_URL}${bilan.file}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="ml-2 text-emerald-600 hover:text-emerald-800"
|
|
||||||
title="Télécharger le bilan de compétences"
|
|
||||||
onClick={(e) => e.stopPropagation()} // Pour ne pas sélectionner à nouveau l'élève
|
|
||||||
>
|
|
||||||
<FileText className="w-5 h-5" />
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{/* Colonne 2 : Reste des infos */}
|
|
||||||
<div className="flex-1">
|
|
||||||
{formData.selectedStudent && (
|
|
||||||
<div className="flex flex-col gap-8 w-full justify-center items-stretch">
|
|
||||||
<div className="w-full flex flex-row items-stretch gap-4">
|
|
||||||
<div className="flex-1 flex items-stretch justify-center h-full">
|
|
||||||
<Attendance
|
|
||||||
absences={absences}
|
|
||||||
onToggleJustify={handleToggleJustify}
|
|
||||||
onDelete={handleDeleteAbsence}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 flex items-stretch justify-center h-full">
|
|
||||||
<GradesStatsCircle grades={grades} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<GradesDomainBarChart
|
|
||||||
studentCompetencies={studentCompetencies}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Form/Button';
|
||||||
import GradeView from '@/components/Grades/GradeView';
|
import GradeView from '@/components/Grades/GradeView';
|
||||||
import {
|
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>
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import {
|
|||||||
Award,
|
Award,
|
||||||
Calendar,
|
Calendar,
|
||||||
Settings,
|
Settings,
|
||||||
LogOut,
|
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@ -29,16 +28,15 @@ import {
|
|||||||
|
|
||||||
import { disconnect } from '@/app/actions/authAction';
|
import { disconnect } from '@/app/actions/authAction';
|
||||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||||
import { getGravatarUrl } from '@/utils/gravatar';
|
|
||||||
import Footer from '@/components/Footer';
|
import Footer from '@/components/Footer';
|
||||||
import { getRightStr, RIGHTS } from '@/utils/rights';
|
import MobileTopbar from '@/components/MobileTopbar';
|
||||||
|
import { RIGHTS } from '@/utils/rights';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
import ProfileSelector from '@/components/ProfileSelector';
|
|
||||||
|
|
||||||
export default function Layout({ children }) {
|
export default function Layout({ children }) {
|
||||||
const t = useTranslations('sidebar');
|
const t = useTranslations('sidebar');
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
const { profileRole, establishments, user, clearContext } =
|
const { profileRole, establishments, clearContext } =
|
||||||
useEstablishment();
|
useEstablishment();
|
||||||
|
|
||||||
const sidebarItems = {
|
const sidebarItems = {
|
||||||
@ -97,45 +95,15 @@ export default function Layout({ children }) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const currentPage = pathname.split('/').pop();
|
const currentPage = pathname.split('/').pop();
|
||||||
|
|
||||||
const headerTitle = sidebarItems[currentPage]?.name || t('dashboard');
|
|
||||||
|
|
||||||
const softwareName = 'N3WT School';
|
const softwareName = 'N3WT School';
|
||||||
const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`;
|
const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`;
|
||||||
|
|
||||||
const handleDisconnect = () => {
|
|
||||||
setIsPopupVisible(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmDisconnect = () => {
|
const confirmDisconnect = () => {
|
||||||
setIsPopupVisible(false);
|
setIsPopupVisible(false);
|
||||||
disconnect();
|
disconnect();
|
||||||
clearContext();
|
clearContext();
|
||||||
};
|
};
|
||||||
|
|
||||||
const dropdownItems = [
|
|
||||||
{
|
|
||||||
type: 'info',
|
|
||||||
content: (
|
|
||||||
<div className="px-4 py-2">
|
|
||||||
<div className="font-medium">{user?.email || 'Utilisateur'}</div>
|
|
||||||
<div className="text-xs text-gray-400">
|
|
||||||
{getRightStr(profileRole) || ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'separator',
|
|
||||||
content: <hr className="my-2 border-gray-200" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'item',
|
|
||||||
label: 'Déconnexion',
|
|
||||||
onClick: handleDisconnect,
|
|
||||||
icon: LogOut,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
setIsSidebarOpen(!isSidebarOpen);
|
setIsSidebarOpen(!isSidebarOpen);
|
||||||
};
|
};
|
||||||
@ -145,18 +113,30 @@ export default function Layout({ children }) {
|
|||||||
setIsSidebarOpen(false);
|
setIsSidebarOpen(false);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
|
// Filtrage dynamique des items de la sidebar selon le rôle
|
||||||
|
let sidebarItemsToDisplay = Object.values(sidebarItems);
|
||||||
|
if (profileRole === 0) {
|
||||||
|
// Si pas admin, on retire "directory" et "settings"
|
||||||
|
sidebarItemsToDisplay = sidebarItemsToDisplay.filter(
|
||||||
|
(item) => item.id !== 'directory' && item.id !== 'settings'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProtectedRoute requiredRight={[RIGHTS.ADMIN, RIGHTS.TEACHER]}>
|
<ProtectedRoute requiredRight={[RIGHTS.ADMIN, RIGHTS.TEACHER]}>
|
||||||
|
{/* 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'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
establishments={establishments}
|
establishments={establishments}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
items={Object.values(sidebarItems)}
|
items={sidebarItemsToDisplay}
|
||||||
onCloseMobile={toggleSidebar}
|
onCloseMobile={toggleSidebar}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -170,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>
|
||||||
|
|
||||||
|
|||||||
@ -36,7 +36,6 @@ export default function DashboardPage() {
|
|||||||
const {
|
const {
|
||||||
selectedEstablishmentId,
|
selectedEstablishmentId,
|
||||||
selectedEstablishmentTotalCapacity,
|
selectedEstablishmentTotalCapacity,
|
||||||
apiDocuseal,
|
|
||||||
} = useEstablishment();
|
} = useEstablishment();
|
||||||
|
|
||||||
const [statusDistribution, setStatusDistribution] = useState([
|
const [statusDistribution, setStatusDistribution] = useState([
|
||||||
@ -164,26 +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">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold ${
|
|
||||||
apiDocuseal
|
|
||||||
? 'bg-green-100 text-green-700 border border-green-300'
|
|
||||||
: 'bg-red-100 text-red-700 border border-red-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{apiDocuseal ? (
|
|
||||||
<CheckCircle2 className="w-4 h-4 mr-2 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<AlertTriangle className="w-4 h-4 mr-2 text-red-500" />
|
|
||||||
)}
|
|
||||||
{apiDocuseal
|
|
||||||
? 'Clé API Docuseal renseignée'
|
|
||||||
: 'Clé API Docuseal manquante'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 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
|
||||||
@ -220,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">
|
||||||
@ -234,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} />
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import Tab from '@/components/Tab';
|
import Tab from '@/components/Tab';
|
||||||
import TabContent from '@/components/TabContent';
|
import TabContent from '@/components/TabContent';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Form/Button';
|
||||||
import InputText from '@/components/InputText';
|
import InputText from '@/components/Form/InputText';
|
||||||
import CheckBox from '@/components/CheckBox'; // Import du composant CheckBox
|
import CheckBox from '@/components/Form/CheckBox'; // Import du composant CheckBox
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import {
|
import {
|
||||||
fetchSmtpSettings,
|
fetchSmtpSettings,
|
||||||
|
|||||||
@ -8,9 +8,9 @@ import { fetchClasse } 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';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Form/Button';
|
||||||
import SelectChoice from '@/components/SelectChoice';
|
import SelectChoice from '@/components/Form/SelectChoice';
|
||||||
import CheckBox from '@/components/CheckBox';
|
import CheckBox from '@/components/Form/CheckBox';
|
||||||
import {
|
import {
|
||||||
fetchAbsences,
|
fetchAbsences,
|
||||||
createAbsences,
|
createAbsences,
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import { fetchRegistrationSchoolFileMasters } from '@/app/actions/registerFileGr
|
|||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
import { PlanningProvider, PlanningModes } from '@/context/PlanningContext';
|
import { PlanningProvider, PlanningModes } from '@/context/PlanningContext';
|
||||||
|
import { updatePlanning } from '@/app/actions/planningAction';
|
||||||
import CompetenciesList from '@/components/Structure/Competencies/CompetenciesList';
|
import CompetenciesList from '@/components/Structure/Competencies/CompetenciesList';
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
@ -52,7 +53,7 @@ export default function Page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const csrfToken = useCsrfToken();
|
const csrfToken = useCsrfToken();
|
||||||
const { selectedEstablishmentId, apiDocuseal } = useEstablishment();
|
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedEstablishmentId) {
|
if (selectedEstablishmentId) {
|
||||||
@ -259,20 +260,10 @@ export default function Page() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdatePlanning = (url, planningId, updatedData) => {
|
const handleUpdatePlanning = (planningId, updatedData) => {
|
||||||
fetch(`${url}/${planningId}`, {
|
updatePlanning(planningId, updatedData, csrfToken)
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRFToken': csrfToken,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(updatedData),
|
|
||||||
credentials: 'include',
|
|
||||||
})
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
logger.debug('Planning mis à jour avec succès :', data);
|
logger.debug('Planning mis à jour avec succès :', data);
|
||||||
//setDatas(data);
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('Erreur :', error);
|
logger.error('Erreur :', error);
|
||||||
@ -316,35 +307,39 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
...(profileRole !== 0
|
||||||
id: 'Fees',
|
? [
|
||||||
label: 'Tarifs',
|
{
|
||||||
content: (
|
id: 'Fees',
|
||||||
<div className="h-full overflow-y-auto p-4">
|
label: 'Tarifs',
|
||||||
<FeesManagement
|
content: (
|
||||||
registrationDiscounts={registrationDiscounts}
|
<div className="h-full overflow-y-auto p-4">
|
||||||
setRegistrationDiscounts={setRegistrationDiscounts}
|
<FeesManagement
|
||||||
tuitionDiscounts={tuitionDiscounts}
|
registrationDiscounts={registrationDiscounts}
|
||||||
setTuitionDiscounts={setTuitionDiscounts}
|
setRegistrationDiscounts={setRegistrationDiscounts}
|
||||||
registrationFees={registrationFees}
|
tuitionDiscounts={tuitionDiscounts}
|
||||||
setRegistrationFees={setRegistrationFees}
|
setTuitionDiscounts={setTuitionDiscounts}
|
||||||
tuitionFees={tuitionFees}
|
registrationFees={registrationFees}
|
||||||
setTuitionFees={setTuitionFees}
|
setRegistrationFees={setRegistrationFees}
|
||||||
registrationPaymentPlans={registrationPaymentPlans}
|
tuitionFees={tuitionFees}
|
||||||
setRegistrationPaymentPlans={setRegistrationPaymentPlans}
|
setTuitionFees={setTuitionFees}
|
||||||
tuitionPaymentPlans={tuitionPaymentPlans}
|
registrationPaymentPlans={registrationPaymentPlans}
|
||||||
setTuitionPaymentPlans={setTuitionPaymentPlans}
|
setRegistrationPaymentPlans={setRegistrationPaymentPlans}
|
||||||
registrationPaymentModes={registrationPaymentModes}
|
tuitionPaymentPlans={tuitionPaymentPlans}
|
||||||
setRegistrationPaymentModes={setRegistrationPaymentModes}
|
setTuitionPaymentPlans={setTuitionPaymentPlans}
|
||||||
tuitionPaymentModes={tuitionPaymentModes}
|
registrationPaymentModes={registrationPaymentModes}
|
||||||
setTuitionPaymentModes={setTuitionPaymentModes}
|
setRegistrationPaymentModes={setRegistrationPaymentModes}
|
||||||
handleCreate={handleCreate}
|
tuitionPaymentModes={tuitionPaymentModes}
|
||||||
handleEdit={handleEdit}
|
setTuitionPaymentModes={setTuitionPaymentModes}
|
||||||
handleDelete={handleDelete}
|
handleCreate={handleCreate}
|
||||||
/>
|
handleEdit={handleEdit}
|
||||||
</div>
|
handleDelete={handleDelete}
|
||||||
),
|
/>
|
||||||
},
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
id: 'Files',
|
id: 'Files',
|
||||||
label: 'Documents',
|
label: 'Documents',
|
||||||
@ -353,7 +348,7 @@ export default function Page() {
|
|||||||
<FilesGroupsManagement
|
<FilesGroupsManagement
|
||||||
csrfToken={csrfToken}
|
csrfToken={csrfToken}
|
||||||
selectedEstablishmentId={selectedEstablishmentId}
|
selectedEstablishmentId={selectedEstablishmentId}
|
||||||
apiDocuseal={apiDocuseal}
|
profileRole={profileRole}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -2,17 +2,17 @@
|
|||||||
|
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { User, Mail } from 'lucide-react';
|
import { User, Mail } from 'lucide-react';
|
||||||
import InputTextIcon from '@/components/InputTextIcon';
|
import InputTextIcon from '@/components/Form/InputTextIcon';
|
||||||
import ToggleSwitch from '@/components/ToggleSwitch';
|
import ToggleSwitch from '@/components/Form/ToggleSwitch';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Form/Button';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import FeesSection from '@/components/Structure/Tarification/FeesSection';
|
import FeesSection from '@/components/Structure/Tarification/FeesSection';
|
||||||
import DiscountsSection from '@/components/Structure/Tarification/DiscountsSection';
|
import DiscountsSection from '@/components/Structure/Tarification/DiscountsSection';
|
||||||
import SectionTitle from '@/components/SectionTitle';
|
import SectionTitle from '@/components/SectionTitle';
|
||||||
import InputPhone from '@/components/InputPhone';
|
import InputPhone from '@/components/Form/InputPhone';
|
||||||
import CheckBox from '@/components/CheckBox';
|
import CheckBox from '@/components/Form/CheckBox';
|
||||||
import RadioList from '@/components/RadioList';
|
import RadioList from '@/components/Form/RadioList';
|
||||||
import SelectChoice from '@/components/SelectChoice';
|
import SelectChoice from '@/components/Form/SelectChoice';
|
||||||
import Loader from '@/components/Loader';
|
import Loader from '@/components/Loader';
|
||||||
import { getCurrentSchoolYear, getNextSchoolYear } from '@/utils/Date';
|
import { getCurrentSchoolYear, getNextSchoolYear } from '@/utils/Date';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
@ -34,10 +34,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
fetchRegistrationFileGroups,
|
fetchRegistrationFileGroups,
|
||||||
fetchRegistrationSchoolFileMasters,
|
fetchRegistrationSchoolFileMasters,
|
||||||
fetchRegistrationParentFileMasters,
|
fetchRegistrationParentFileMasters
|
||||||
cloneTemplate,
|
|
||||||
createRegistrationSchoolFileTemplate,
|
|
||||||
createRegistrationParentFileTemplate,
|
|
||||||
} from '@/app/actions/registerFileGroupAction';
|
} from '@/app/actions/registerFileGroupAction';
|
||||||
import { fetchProfiles } from '@/app/actions/authAction';
|
import { fetchProfiles } from '@/app/actions/authAction';
|
||||||
import { useClasses } from '@/context/ClassesContext';
|
import { useClasses } from '@/context/ClassesContext';
|
||||||
@ -74,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([]);
|
||||||
@ -96,7 +95,7 @@ export default function CreateSubscriptionPage() {
|
|||||||
const { getNiveauLabel } = useClasses();
|
const { getNiveauLabel } = useClasses();
|
||||||
|
|
||||||
const formDataRef = useRef(formData);
|
const formDataRef = useRef(formData);
|
||||||
const { selectedEstablishmentId, apiDocuseal } = useEstablishment();
|
const { selectedEstablishmentId } = useEstablishment();
|
||||||
|
|
||||||
const csrfToken = useCsrfToken();
|
const csrfToken = useCsrfToken();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -182,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
|
||||||
@ -522,128 +523,23 @@ export default function CreateSubscriptionPage() {
|
|||||||
} else {
|
} else {
|
||||||
// Création du dossier d'inscription
|
// Création du dossier d'inscription
|
||||||
createRegisterForm(data, csrfToken)
|
createRegisterForm(data, csrfToken)
|
||||||
.then((data) => {
|
.then((response) => {
|
||||||
// Clonage des schoolFileTemplates
|
showNotification(
|
||||||
const masters = schoolFileMasters.filter((file) =>
|
"Dossier d'inscription créé avec succès",
|
||||||
file.groups.includes(selectedFileGroup)
|
'success',
|
||||||
|
'Succès'
|
||||||
);
|
);
|
||||||
const parentMasters = parentFileMasters.filter((file) =>
|
router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
|
||||||
file.groups.includes(selectedFileGroup)
|
})
|
||||||
);
|
|
||||||
|
|
||||||
const clonePromises = masters.map((templateMaster) =>
|
|
||||||
cloneTemplate(
|
|
||||||
templateMaster.id,
|
|
||||||
formDataRef.current.guardianEmail,
|
|
||||||
templateMaster.is_required,
|
|
||||||
selectedEstablishmentId,
|
|
||||||
apiDocuseal
|
|
||||||
)
|
|
||||||
.then((clonedDocument) => {
|
|
||||||
const cloneData = {
|
|
||||||
name: `${templateMaster.name}_${formDataRef.current.studentFirstName}_${formDataRef.current.studentLastName}`,
|
|
||||||
slug: clonedDocument.slug,
|
|
||||||
id: clonedDocument.id,
|
|
||||||
master: templateMaster.id,
|
|
||||||
registration_form: data.student.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
return createRegistrationSchoolFileTemplate(
|
|
||||||
cloneData,
|
|
||||||
csrfToken
|
|
||||||
)
|
|
||||||
.then((response) =>
|
|
||||||
logger.debug('Template enregistré avec succès:', response)
|
|
||||||
)
|
|
||||||
.catch((error) => {
|
|
||||||
setIsLoading(false);
|
|
||||||
logger.error(
|
|
||||||
"Erreur lors de l'enregistrement du template:",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
showNotification(
|
|
||||||
"Erreur lors de la création du dossier d'inscription",
|
|
||||||
'error',
|
|
||||||
'Erreur',
|
|
||||||
'ERR_ADM_SUB_03'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
setIsLoading(false);
|
|
||||||
logger.error('Error during cloning or sending:', error);
|
|
||||||
showNotification(
|
|
||||||
"Erreur lors de la création du dossier d'inscription",
|
|
||||||
'error',
|
|
||||||
'Erreur',
|
|
||||||
'ERR_ADM_SUB_05'
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clonage des parentFileTemplates
|
|
||||||
const parentClonePromises = parentMasters.map((parentMaster) => {
|
|
||||||
const parentTemplateData = {
|
|
||||||
master: parentMaster.id,
|
|
||||||
registration_form: data.student.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
return createRegistrationParentFileTemplate(
|
|
||||||
parentTemplateData,
|
|
||||||
csrfToken
|
|
||||||
)
|
|
||||||
.then((response) =>
|
|
||||||
logger.debug(
|
|
||||||
'Parent template enregistré avec succès:',
|
|
||||||
response
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.catch((error) => {
|
|
||||||
setIsLoading(false);
|
|
||||||
logger.error(
|
|
||||||
"Erreur lors de l'enregistrement du parent template:",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
showNotification(
|
|
||||||
"Erreur lors de la création du dossier d'inscription",
|
|
||||||
'error',
|
|
||||||
'Erreur',
|
|
||||||
'ERR_ADM_SUB_02'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Attendre que tous les clones soient créés
|
|
||||||
Promise.all([...clonePromises, ...parentClonePromises])
|
|
||||||
.then(() => {
|
|
||||||
// Redirection après succès
|
|
||||||
showNotification(
|
|
||||||
"Dossier d'inscription créé avec succès",
|
|
||||||
'success',
|
|
||||||
'Succès'
|
|
||||||
);
|
|
||||||
router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
setIsLoading(false);
|
|
||||||
showNotification(
|
|
||||||
"Erreur lors de la création du dossier d'inscription",
|
|
||||||
'error',
|
|
||||||
'Erreur',
|
|
||||||
'ERR_ADM_SUB_04'
|
|
||||||
);
|
|
||||||
logger.error('Error during cloning or sending:', error);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
logger.error('Erreur lors de la mise à jour du dossier:', error);
|
||||||
showNotification(
|
showNotification(
|
||||||
"Erreur lors de la création du dossier d'inscription",
|
"Erreur lors de la création du dossier d'inscription",
|
||||||
'error',
|
'error',
|
||||||
'Erreur',
|
'Erreur',
|
||||||
'ERR_ADM_SUB_01'
|
'ERR_ADM_SUB_01'
|
||||||
);
|
);
|
||||||
logger.error('Error during register form creation:', error);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -817,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
|
||||||
}
|
}
|
||||||
@ -977,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',
|
||||||
@ -1035,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 && (
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export default function Page() {
|
|||||||
|
|
||||||
const [formErrors, setFormErrors] = useState({});
|
const [formErrors, setFormErrors] = useState({});
|
||||||
const csrfToken = useCsrfToken();
|
const csrfToken = useCsrfToken();
|
||||||
const { selectedEstablishmentId, apiDocuseal } = useEstablishment();
|
const { selectedEstablishmentId } = useEstablishment();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = (data) => {
|
const handleSubmit = (data) => {
|
||||||
@ -59,7 +59,6 @@ export default function Page() {
|
|||||||
studentId={studentId}
|
studentId={studentId}
|
||||||
csrfToken={csrfToken}
|
csrfToken={csrfToken}
|
||||||
selectedEstablishmentId={selectedEstablishmentId}
|
selectedEstablishmentId={selectedEstablishmentId}
|
||||||
apiDocuseal = {apiDocuseal}
|
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
cancelUrl={FE_ADMIN_SUBSCRIPTIONS_URL}
|
cancelUrl={FE_ADMIN_SUBSCRIPTIONS_URL}
|
||||||
errors={formErrors}
|
errors={formErrors}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import Tab from '@/components/Tab';
|
import Tab from '@/components/Tab';
|
||||||
|
import Textarea from '@/components/Textarea';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import StatusLabel from '@/components/StatusLabel';
|
import StatusLabel from '@/components/StatusLabel';
|
||||||
import Popup from '@/components/Popup';
|
import Popup from '@/components/Popup';
|
||||||
@ -17,6 +18,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Upload,
|
Upload,
|
||||||
Eye,
|
Eye,
|
||||||
|
XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Modal from '@/components/Modal';
|
import Modal from '@/components/Modal';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
@ -40,8 +42,8 @@ import {
|
|||||||
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
|
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
|
||||||
import { useCsrfToken } from '@/context/CsrfContext';
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import { PhoneLabel } from '@/components/PhoneLabel';
|
import { PhoneLabel } from '@/components/Form/PhoneLabel';
|
||||||
import FileUpload from '@/components/FileUpload';
|
import FileUpload from '@/components/Form/FileUpload';
|
||||||
import FilesModal from '@/components/Inscription/FilesModal';
|
import FilesModal from '@/components/Inscription/FilesModal';
|
||||||
import { getCurrentSchoolYear, getNextSchoolYear } from '@/utils/Date';
|
import { getCurrentSchoolYear, getNextSchoolYear } from '@/utils/Date';
|
||||||
|
|
||||||
@ -83,12 +85,9 @@ export default function Page({ params: { locale } }) {
|
|||||||
const [totalHistorical, setTotalHistorical] = useState(0);
|
const [totalHistorical, setTotalHistorical] = useState(0);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(10); // Définir le nombre d'éléments par page
|
const [itemsPerPage, setItemsPerPage] = useState(10); // Définir le nombre d'éléments par page
|
||||||
|
|
||||||
const [student, setStudent] = useState('');
|
|
||||||
const [classes, setClasses] = useState([]);
|
const [classes, setClasses] = useState([]);
|
||||||
const [reloadFetch, setReloadFetch] = useState(false);
|
const [reloadFetch, setReloadFetch] = useState(false);
|
||||||
|
|
||||||
const [isOpenAddGuardian, setIsOpenAddGuardian] = useState(false);
|
|
||||||
|
|
||||||
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
||||||
const [selectedRegisterForm, setSelectedRegisterForm] = useState([]);
|
const [selectedRegisterForm, setSelectedRegisterForm] = useState([]);
|
||||||
|
|
||||||
@ -99,9 +98,40 @@ export default function Page({ params: { locale } }) {
|
|||||||
const [isSepaUploadModalOpen, setIsSepaUploadModalOpen] = useState(false);
|
const [isSepaUploadModalOpen, setIsSepaUploadModalOpen] = useState(false);
|
||||||
const [selectedRowForUpload, setSelectedRowForUpload] = useState(null);
|
const [selectedRowForUpload, setSelectedRowForUpload] = useState(null);
|
||||||
|
|
||||||
|
// Refus popup state
|
||||||
|
const [isRefusePopupOpen, setIsRefusePopupOpen] = useState(false);
|
||||||
|
const [refuseReason, setRefuseReason] = useState('');
|
||||||
|
const [rowToRefuse, setRowToRefuse] = useState(null);
|
||||||
|
// Ouvre la popup de refus
|
||||||
|
const openRefusePopup = (row) => {
|
||||||
|
setRowToRefuse(row);
|
||||||
|
setRefuseReason('');
|
||||||
|
setIsRefusePopupOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Valide le refus
|
||||||
|
const handleRefuse = () => {
|
||||||
|
if (!refuseReason.trim()) {
|
||||||
|
showNotification('Merci de préciser la raison du refus.', 'error', 'Erreur');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('data', JSON.stringify({ status: RegistrationFormStatus.STATUS_ARCHIVED, notes: refuseReason }));
|
||||||
|
|
||||||
|
editRegisterForm(rowToRefuse.student.id, formData, csrfToken)
|
||||||
|
.then(() => {
|
||||||
|
showNotification('Le dossier a été refusé et archivé.', 'success', 'Succès');
|
||||||
|
setReloadFetch(true);
|
||||||
|
setIsRefusePopupOpen(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
showNotification('Erreur lors du refus du dossier.', 'error', 'Erreur');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const csrfToken = useCsrfToken();
|
const csrfToken = useCsrfToken();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { selectedEstablishmentId } = useEstablishment();
|
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||||
const { showNotification } = useNotification();
|
const { showNotification } = useNotification();
|
||||||
|
|
||||||
const openSepaUploadModal = (row) => {
|
const openSepaUploadModal = (row) => {
|
||||||
@ -250,7 +280,12 @@ export default function Page({ params: { locale } }) {
|
|||||||
}, 500); // Debounce la recherche
|
}, 500); // Debounce la recherche
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
}, [searchTerm, selectedEstablishmentId, currentSchoolYearPage, itemsPerPage]);
|
}, [
|
||||||
|
searchTerm,
|
||||||
|
selectedEstablishmentId,
|
||||||
|
currentSchoolYearPage,
|
||||||
|
itemsPerPage,
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UseEffect to update page count of tab
|
* UseEffect to update page count of tab
|
||||||
@ -485,10 +520,18 @@ export default function Page({ params: { locale } }) {
|
|||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
const url = `${FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL}?studentId=${row.student.id}&firstName=${row.student.first_name}&lastName=${row.student.last_name}&level=${row.student.level}&sepa_file=${row.sepa_file}&student_file=${row.registration_file}`;
|
const url = `${FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL}?studentId=${row.student.id}&firstName=${row.student.first_name}&lastName=${row.student.last_name}&email=${row.student.guardians[0].associated_profile_email}&level=${row.student.level}&sepa_file=${row.sepa_file}&student_file=${row.registration_file}`;
|
||||||
router.push(`${url}`);
|
router.push(`${url}`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: (
|
||||||
|
<span title="Refuser le dossier">
|
||||||
|
<XCircle className="w-5 h-5 text-red-500 hover:text-red-700" />
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
onClick: () => openRefusePopup(row),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// Etat "A relancer" - NON TESTE
|
// Etat "A relancer" - NON TESTE
|
||||||
[RegistrationFormStatus.STATUS_TO_FOLLOW_UP]: [
|
[RegistrationFormStatus.STATUS_TO_FOLLOW_UP]: [
|
||||||
@ -796,15 +839,17 @@ export default function Page({ params: { locale } }) {
|
|||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{profileRole !== 0 && (
|
||||||
onClick={() => {
|
<button
|
||||||
const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`;
|
onClick={() => {
|
||||||
router.push(url);
|
const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`;
|
||||||
}}
|
router.push(url);
|
||||||
className="flex items-center bg-emerald-600 text-white p-2 rounded-full shadow hover:bg-emerald-900 transition duration-200 ml-4"
|
}}
|
||||||
>
|
className="flex items-center bg-emerald-600 text-white p-2 rounded-full shadow hover:bg-emerald-900 transition duration-200 ml-4"
|
||||||
<Plus className="w-5 h-5" />
|
>
|
||||||
</button>
|
<Plus className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@ -848,6 +893,25 @@ export default function Page({ params: { locale } }) {
|
|||||||
onCancel={() => setConfirmPopupVisible(false)}
|
onCancel={() => setConfirmPopupVisible(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Popup de refus de dossier */}
|
||||||
|
<Popup
|
||||||
|
isOpen={isRefusePopupOpen}
|
||||||
|
message={
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 font-semibold">Veuillez indiquer la raison du refus :</div>
|
||||||
|
<Textarea
|
||||||
|
value={refuseReason}
|
||||||
|
onChange={(e) => setRefuseReason(e.target.value)}
|
||||||
|
placeholder="Ex : Réception de dossier trop tardive"
|
||||||
|
rows={3}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
onConfirm={handleRefuse}
|
||||||
|
onCancel={() => setIsRefusePopupOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
{isSepaUploadModalOpen && (
|
{isSepaUploadModalOpen && (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isSepaUploadModalOpen}
|
isOpen={isSepaUploadModalOpen}
|
||||||
|
|||||||
@ -10,8 +10,10 @@ import Loader from '@/components/Loader';
|
|||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url';
|
import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url';
|
||||||
import { useNotification } from '@/context/NotificationContext';
|
import { useNotification } from '@/context/NotificationContext';
|
||||||
|
import { editRegistrationSchoolFileTemplates, editRegistrationParentFileTemplates } from '@/app/actions/registerFileGroupAction';
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
|
const [isLoadingRefuse, setIsLoadingRefuse] = useState(false);
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@ -20,6 +22,7 @@ export default function Page() {
|
|||||||
const studentId = searchParams.get('studentId');
|
const studentId = searchParams.get('studentId');
|
||||||
const firstName = searchParams.get('firstName');
|
const firstName = searchParams.get('firstName');
|
||||||
const lastName = searchParams.get('lastName');
|
const lastName = searchParams.get('lastName');
|
||||||
|
const email = searchParams.get('email');
|
||||||
const level = searchParams.get('level');
|
const level = searchParams.get('level');
|
||||||
const sepa_file =
|
const sepa_file =
|
||||||
searchParams.get('sepa_file') === 'null'
|
searchParams.get('sepa_file') === 'null'
|
||||||
@ -84,6 +87,45 @@ export default function Page() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleRefuseRF = (data) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('data', JSON.stringify(data));
|
||||||
|
editRegisterForm(studentId, formData, csrfToken)
|
||||||
|
.then((response) => {
|
||||||
|
logger.debug('RF refusé et archivé:', response);
|
||||||
|
showNotification('Le dossier a été refusé et archivé.', 'success', 'Succès');
|
||||||
|
router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
|
||||||
|
setIsLoadingRefuse(false);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
showNotification('Erreur lors du refus du dossier.', 'error', 'Erreur');
|
||||||
|
setIsLoadingRefuse(false);
|
||||||
|
logger.error('Erreur lors du refus du RF:', error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validation/refus d'un document individuel (hors fiche élève)
|
||||||
|
const handleValidateOrRefuseDoc = ({ templateId, type, validated, csrfToken }) => {
|
||||||
|
if (!templateId) return;
|
||||||
|
let editFn = null;
|
||||||
|
if (type === 'school') {
|
||||||
|
editFn = editRegistrationSchoolFileTemplates;
|
||||||
|
} else if (type === 'parent') {
|
||||||
|
editFn = editRegistrationParentFileTemplates;
|
||||||
|
}
|
||||||
|
if (!editFn) return;
|
||||||
|
const updateData = new FormData();
|
||||||
|
updateData.append('data', JSON.stringify({ isValidated: validated }));
|
||||||
|
editFn(templateId, updateData, csrfToken)
|
||||||
|
.then((response) => {
|
||||||
|
logger.debug(`Document ${validated ? 'validé' : 'refusé'} (type: ${type}, id: ${templateId})`, response);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error('Erreur lors de la validation/refus du document:', error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <Loader />;
|
return <Loader />;
|
||||||
}
|
}
|
||||||
@ -93,10 +135,15 @@ export default function Page() {
|
|||||||
studentId={studentId}
|
studentId={studentId}
|
||||||
firstName={firstName}
|
firstName={firstName}
|
||||||
lastName={lastName}
|
lastName={lastName}
|
||||||
|
email={email}
|
||||||
sepa_file={sepa_file}
|
sepa_file={sepa_file}
|
||||||
student_file={student_file}
|
student_file={student_file}
|
||||||
onAccept={handleAcceptRF}
|
onAccept={handleAcceptRF}
|
||||||
classes={classes}
|
classes={classes}
|
||||||
|
onRefuse={handleRefuseRF}
|
||||||
|
isLoadingRefuse={isLoadingRefuse}
|
||||||
|
handleValidateOrRefuseDoc={handleValidateOrRefuseDoc}
|
||||||
|
csrfToken={csrfToken}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Form/Button';
|
||||||
import Logo from '@/components/Logo'; // Import du composant Logo
|
import Logo from '@/components/Logo'; // Import du composant Logo
|
||||||
|
import FormRenderer from '@/components/Form/FormRenderer';
|
||||||
|
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const t = useTranslations('homePage');
|
const t = useTranslations('homePage');
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export default function Page() {
|
|||||||
const enable = searchParams.get('enabled') === 'true';
|
const enable = searchParams.get('enabled') === 'true';
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const csrfToken = useCsrfToken();
|
const csrfToken = useCsrfToken();
|
||||||
const { selectedEstablishmentId, apiDocuseal } = useEstablishment();
|
const { selectedEstablishmentId } = useEstablishment();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = (data) => {
|
const handleSubmit = (data) => {
|
||||||
@ -53,7 +53,6 @@ export default function Page() {
|
|||||||
studentId={studentId}
|
studentId={studentId}
|
||||||
csrfToken={csrfToken}
|
csrfToken={csrfToken}
|
||||||
selectedEstablishmentId={selectedEstablishmentId}
|
selectedEstablishmentId={selectedEstablishmentId}
|
||||||
apiDocuseal = {apiDocuseal}
|
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
cancelUrl={FE_PARENTS_HOME_URL}
|
cancelUrl={FE_PARENTS_HOME_URL}
|
||||||
enable={enable}
|
enable={enable}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import {
|
|||||||
CalendarDays,
|
CalendarDays,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import StatusLabel from '@/components/StatusLabel';
|
import StatusLabel from '@/components/StatusLabel';
|
||||||
import FileUpload from '@/components/FileUpload';
|
import FileUpload from '@/components/Form/FileUpload';
|
||||||
import { FE_PARENTS_EDIT_SUBSCRIPTION_URL } from '@/utils/Url';
|
import { FE_PARENTS_EDIT_SUBSCRIPTION_URL } from '@/utils/Url';
|
||||||
import {
|
import {
|
||||||
fetchChildren,
|
fetchChildren,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Form/Button';
|
||||||
import InputText from '@/components/InputText';
|
import InputText from '@/components/Form/InputText';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import { useNotification } from '@/context/NotificationContext';
|
import { useNotification } from '@/context/NotificationContext';
|
||||||
|
|
||||||
|
|||||||
@ -3,9 +3,9 @@ import React, { useState } from 'react';
|
|||||||
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
|
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
|
||||||
import Logo from '@/components/Logo';
|
import Logo from '@/components/Logo';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import InputTextIcon from '@/components/InputTextIcon';
|
import InputTextIcon from '@/components/Form/InputTextIcon';
|
||||||
import Loader from '@/components/Loader'; // Importez le composant Loader
|
import Loader from '@/components/Loader'; // Importez le composant Loader
|
||||||
import Button from '@/components/Button'; // Importez le composant Button
|
import Button from '@/components/Form/Button'; // Importez le composant Button
|
||||||
import { User, KeySquare } from 'lucide-react'; // Importez directement les icônes nécessaires
|
import { User, KeySquare } from 'lucide-react'; // Importez directement les icônes nécessaires
|
||||||
import { FE_USERS_NEW_PASSWORD_URL, getRedirectUrlFromRole } from '@/utils/Url';
|
import { FE_USERS_NEW_PASSWORD_URL, getRedirectUrlFromRole } from '@/utils/Url';
|
||||||
import { login } from '@/app/actions/authAction';
|
import { login } from '@/app/actions/authAction';
|
||||||
@ -35,11 +35,7 @@ export default function Page() {
|
|||||||
logger.debug('Sign In Result', result);
|
logger.debug('Sign In Result', result);
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
showNotification(
|
showNotification(result.error, 'error', 'Erreur');
|
||||||
result.error,
|
|
||||||
'error',
|
|
||||||
'Erreur'
|
|
||||||
);
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
} else {
|
} else {
|
||||||
// On initialise le contexte establishement avec la session
|
// On initialise le contexte establishement avec la session
|
||||||
@ -50,11 +46,7 @@ export default function Page() {
|
|||||||
if (url) {
|
if (url) {
|
||||||
router.push(url);
|
router.push(url);
|
||||||
} else {
|
} else {
|
||||||
showNotification(
|
showNotification('Type de rôle non géré', 'error', 'Erreur');
|
||||||
'Type de rôle non géré',
|
|
||||||
'error',
|
|
||||||
'Erreur'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user