mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-05 12:41:27 +00:00
Compare commits
53 Commits
e9a30b7bde
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 4431c428d3 | |||
| 2ef71f99c3 | |||
| f9c0585b30 | |||
| 12939fca85 | |||
| 1f2a1b88ac | |||
| 762dede0af | |||
| ccdbae1c08 | |||
| 2a223fe3dd | |||
| 409cf05f1a | |||
| b0e04e3adc | |||
| 3c7266608d | |||
| 5bbbcb9dc1 | |||
| 053140c8be | |||
| 90b0d14418 | |||
| ae06b6fef7 | |||
| e37aee2abc | |||
| 2d678b732f | |||
| 4c56cb6474 | |||
| 79e14a23fe | |||
| 269866fb1c | |||
| f091fa0432 | |||
| a3291262d8 | |||
| 5f6c015d02 | |||
| 09b1541dc8 | |||
| cb76a23d02 | |||
| 2579af9b8b | |||
| 3a132ae0bd | |||
| 905fa5dbfb | |||
| edb9ace6ae | |||
| 4e50a0696f | |||
| 4248a589c5 | |||
| 7464b19de5 | |||
| c96b9562a2 | |||
| 7576b5a68c | |||
| e30a41a58b | |||
| c296af2c07 | |||
| fa843097ba | |||
| 6fb3c5cdb4 | |||
| 2fef6d61a4 | |||
| 0501c1dd73 | |||
| 4f7d7d0024 | |||
| 8fd1b62ec0 | |||
| 3779a47417 | |||
| 05c68ebfaa | |||
| 195579e217 | |||
| ddcaba382e | |||
| a82483f3bd | |||
| 26d4b5633f | |||
| d66db1b019 | |||
| bd7dc2b0c2 | |||
| 176edc5c45 | |||
| 92c6a31740 | |||
| 9dff32b388 |
21
.github/copilot-instructions.md
vendored
21
.github/copilot-instructions.md
vendored
@ -52,7 +52,28 @@ Pour le front-end, les exigences de qualité sont les suivantes :
|
||||
- Documentation en français pour les nouvelles fonctionnalités (si applicable)
|
||||
- Référence : [documentation guidelines](./instructions/documentation.instruction.md)
|
||||
|
||||
## Design System
|
||||
|
||||
Le projet utilise un design system défini. Toujours s'y conformer lors de toute modification de l'interface.
|
||||
|
||||
- Référence complète : [design system](../docs/design-system.md)
|
||||
- Règles Copilot : [design system instructions](./instructions/design-system.instruction.md)
|
||||
|
||||
### Résumé des tokens obligatoires
|
||||
|
||||
| Token Tailwind | Hex | Usage |
|
||||
|----------------|-----------|-------------------------------|
|
||||
| `primary` | `#059669` | Boutons, CTA, éléments actifs |
|
||||
| `secondary` | `#064E3B` | Hover, accents sombres |
|
||||
| `tertiary` | `#10B981` | Badges, icônes |
|
||||
| `neutral` | `#F8FAFC` | Fonds de page, surfaces |
|
||||
|
||||
- Polices : `font-headline` (Manrope) pour les titres, `font-body`/`font-label` (Inter) pour le reste
|
||||
- **Ne jamais** utiliser `emerald-*` pour les éléments interactifs
|
||||
|
||||
## Références
|
||||
|
||||
- **Tickets** : [issues guidelines](./instructions/issues.instruction.md)
|
||||
- **Commits** : [commit guidelines](./instructions/general-commit.instruction.md)
|
||||
- **Tests** : [run tests](./instructions/run-tests.instruction.md)
|
||||
- **Design System** : [design system instructions](./instructions/design-system.instruction.md)
|
||||
|
||||
116
.github/instructions/design-system.instruction.md
vendored
Normal file
116
.github/instructions/design-system.instruction.md
vendored
Normal file
@ -0,0 +1,116 @@
|
||||
---
|
||||
applyTo: "Front-End/src/**"
|
||||
---
|
||||
|
||||
# Design System — Règles Copilot
|
||||
|
||||
Référence complète : [`docs/design-system.md`](../../docs/design-system.md)
|
||||
|
||||
## Couleurs — tokens Tailwind obligatoires
|
||||
|
||||
Utiliser **toujours** ces tokens pour les éléments interactifs :
|
||||
|
||||
| Token | Hex | Remplace |
|
||||
|-------------|-----------|-----------------------------------|
|
||||
| `primary` | `#059669` | `emerald-600`, `emerald-500` |
|
||||
| `secondary` | `#064E3B` | `emerald-700`, `emerald-800` |
|
||||
| `tertiary` | `#10B981` | `emerald-400`, `emerald-500` |
|
||||
| `neutral` | `#F8FAFC` | Fonds neutres |
|
||||
|
||||
**Ne jamais écrire** `bg-emerald-*`, `text-emerald-*`, `border-emerald-*` pour des éléments interactifs.
|
||||
|
||||
### Patterns corrects
|
||||
|
||||
```jsx
|
||||
// Bouton
|
||||
<button className="bg-primary hover:bg-secondary text-white px-4 py-2 rounded">
|
||||
|
||||
// Texte actif
|
||||
<span className="text-primary">
|
||||
|
||||
// Badge
|
||||
<span className="bg-tertiary/10 text-tertiary text-xs px-2 py-0.5 rounded">
|
||||
|
||||
// Fond de page
|
||||
<div className="bg-neutral">
|
||||
```
|
||||
|
||||
## Typographie
|
||||
|
||||
```jsx
|
||||
// Titre de section
|
||||
<h1 className="font-headline text-2xl font-bold">
|
||||
|
||||
// Sous-titre
|
||||
<h2 className="font-headline text-xl font-semibold">
|
||||
|
||||
// Label de formulaire
|
||||
<label className="font-label text-sm font-medium text-gray-700">
|
||||
```
|
||||
|
||||
> `font-body` est le défaut sur `<body>` — inutile de l'ajouter sur les `<p>`.
|
||||
|
||||
## Arrondi
|
||||
|
||||
- Par défaut : `rounded` (4px)
|
||||
- Cards / modales : `rounded-md` (6px)
|
||||
- Grandes surfaces : `rounded-lg` (8px)
|
||||
- **Éviter** `rounded-xl` sauf avatars ou indicateurs circulaires
|
||||
|
||||
## Espacement
|
||||
|
||||
- Grille 4px/8px : `p-1`=4px, `p-2`=8px, `p-3`=12px, `p-4`=16px
|
||||
- **Pas** de valeurs arbitraires `p-[13px]`
|
||||
|
||||
## Mode
|
||||
|
||||
Interface **light uniquement** — ne pas ajouter `dark:` prefixes.
|
||||
|
||||
---
|
||||
|
||||
## Icônes
|
||||
|
||||
Utiliser **uniquement** `lucide-react`. Jamais d'autres bibliothèques d'icônes.
|
||||
|
||||
```jsx
|
||||
import { Home, Plus, ChevronRight } from 'lucide-react';
|
||||
|
||||
<Home size={20} className="text-primary" />
|
||||
<button className="flex items-center gap-2"><Plus size={16} />Ajouter</button>
|
||||
```
|
||||
|
||||
- Taille par défaut : `size={20}` inline, `size={24}` boutons standalone
|
||||
- Couleur via `className="text-*"` uniquement — jamais le prop `color`
|
||||
- Icône seule : ajouter `aria-label` pour l'accessibilité
|
||||
|
||||
---
|
||||
|
||||
## Responsive & PWA
|
||||
|
||||
**Mobile-first** : les styles de base ciblent le mobile, on étend avec `sm:` / `md:` / `lg:`.
|
||||
|
||||
```jsx
|
||||
// Layout
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-4 sm:py-6">
|
||||
|
||||
// Grille
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
|
||||
// Bouton full-width mobile
|
||||
<button className="w-full sm:w-auto bg-primary text-white px-4 py-2 rounded">
|
||||
```
|
||||
|
||||
- Touch targets ≥ 44px : `min-h-[44px]` sur tous les éléments interactifs
|
||||
- Pas d'interactions uniquement au `:hover` — prévoir une alternative tactile
|
||||
- Tableaux sur mobile : utiliser la classe utilitaire `responsive-table` (définie dans `tailwind.css`)
|
||||
|
||||
---
|
||||
|
||||
## Réutilisation des composants
|
||||
|
||||
**Toujours chercher un composant existant dans `Front-End/src/components/` avant d'en créer un.**
|
||||
|
||||
Composants clés disponibles : `AlertMessage`, `Modal`, `Pagination`, `SectionHeader`, `ProgressStep`, `EventCard`, `Calendar/*`, `Chat/*`, `Evaluation/*`, `Grades/*`, `Form/*`, `Admin/*`, `Charts/*`.
|
||||
|
||||
- Étendre via des props (`variant`, `size`, `className`) plutôt que de dupliquer
|
||||
- Appliquer les tokens du design system dans tout composant modifié ou créé
|
||||
@ -1,5 +1,5 @@
|
||||
La documentation doit être en français et claire pour les utilisateurs francophones.
|
||||
Toutes la documentation doit être dans le dossier docs/
|
||||
Toutes la documentation doit être dans le dossier docs/ à la racine.
|
||||
Seul les fichiers README.md, CHANGELOG.md doivent être à la racine.
|
||||
La documentation doit être conscise et pertinente, sans répétitions inutiles entre les documents.
|
||||
Tout ce qui concerne la gestion de projet, roadmap ne doit pas apparaître dans la documentation.
|
||||
|
||||
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 |
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -3,3 +3,5 @@
|
||||
node_modules/
|
||||
hardcoded-strings-report.md
|
||||
backend.env
|
||||
*.log
|
||||
.claude/worktrees/*
|
||||
1
.husky/commit-msg
Normal file → Executable file
1
.husky/commit-msg
Normal file → Executable file
@ -1 +1,2 @@
|
||||
#!/bin/sh
|
||||
npx --no -- commitlint --edit $1
|
||||
1
.husky/pre-commit
Normal file → Executable file
1
.husky/pre-commit
Normal file → Executable file
@ -1 +1,2 @@
|
||||
#!/bin/sh
|
||||
cd $(dirname "$0")/../Front-End/ && npm run lint-light
|
||||
1
.husky/prepare-commit-msg
Normal file → Executable file
1
.husky/prepare-commit-msg
Normal file → Executable file
@ -1 +1,2 @@
|
||||
#!/bin/sh
|
||||
#node scripts/prepare-commit-msg.js "$1" "$2"
|
||||
@ -2,6 +2,12 @@ from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
from Auth.models import Profile
|
||||
from N3wtSchool import bdd
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
from rest_framework_simplejwt.exceptions import TokenError, InvalidToken
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("Auth")
|
||||
|
||||
|
||||
class EmailBackend(ModelBackend):
|
||||
def authenticate(self, request, username=None, password=None, **kwargs):
|
||||
@ -18,3 +24,45 @@ class EmailBackend(ModelBackend):
|
||||
except Profile.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
class LoggingJWTAuthentication(JWTAuthentication):
|
||||
"""
|
||||
Surclasse JWTAuthentication pour loguer pourquoi un token Bearer est rejeté.
|
||||
Cela aide à diagnostiquer les 401 sans avoir à ajouter des prints partout.
|
||||
"""
|
||||
|
||||
def authenticate(self, request):
|
||||
header = self.get_header(request)
|
||||
if header is None:
|
||||
logger.debug("JWT: pas de header Authorization dans la requête %s %s",
|
||||
request.method, request.path)
|
||||
return None
|
||||
|
||||
raw_token = self.get_raw_token(header)
|
||||
if raw_token is None:
|
||||
logger.debug("JWT: header Authorization présent mais token vide pour %s %s",
|
||||
request.method, request.path)
|
||||
return None
|
||||
|
||||
try:
|
||||
validated_token = self.get_validated_token(raw_token)
|
||||
except InvalidToken as e:
|
||||
logger.warning(
|
||||
"JWT: token invalide pour %s %s — %s",
|
||||
request.method, request.path, str(e)
|
||||
)
|
||||
raise
|
||||
|
||||
try:
|
||||
user = self.get_user(validated_token)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"JWT: utilisateur introuvable pour %s %s — %s",
|
||||
request.method, request.path, str(e)
|
||||
)
|
||||
raise
|
||||
|
||||
logger.debug("JWT: authentification réussie user_id=%s pour %s %s",
|
||||
user.pk, request.method, request.path)
|
||||
return user, validated_token
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
# Generated by Django 5.1.3 on 2026-04-05 08:05
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
@ -24,7 +24,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role_type', models.IntegerField(choices=[(-1, 'NON DEFINI'), (0, 'ECOLE'), (1, 'ADMIN'), (2, 'PARENT')], default=-1)),
|
||||
('is_active', models.BooleanField(default=False)),
|
||||
('is_active', models.BooleanField(blank=True, default=False)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='profile_roles', to='Establishment.establishment')),
|
||||
],
|
||||
|
||||
@ -25,7 +25,7 @@ class ProfileRole(models.Model):
|
||||
profile = models.ForeignKey('Profile', on_delete=models.CASCADE, related_name='roles')
|
||||
role_type = models.IntegerField(choices=RoleType.choices, default=RoleType.PROFIL_UNDEFINED)
|
||||
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)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@ -14,7 +14,7 @@ class ProfileSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Profile
|
||||
fields = ['id', 'password', 'email', 'code', 'datePeremption', 'username', 'roles', 'roleIndexLoginDefault']
|
||||
fields = ['id', 'password', 'email', 'code', 'datePeremption', 'username', 'roles', 'roleIndexLoginDefault', 'first_name', 'last_name']
|
||||
extra_kwargs = {'password': {'write_only': True}}
|
||||
|
||||
def get_roles(self, obj):
|
||||
|
||||
553
Back-End/Auth/tests.py
Normal file
553
Back-End/Auth/tests.py
Normal file
@ -0,0 +1,553 @@
|
||||
"""
|
||||
Tests unitaires pour le module Auth.
|
||||
Vérifie :
|
||||
- L'accès public aux endpoints de login/CSRF/subscribe
|
||||
- La protection JWT des endpoints protégés (profils, rôles, session)
|
||||
- La génération et validation des tokens JWT
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile, ProfileRole
|
||||
from Establishment.models import Establishment
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_establishment():
|
||||
"""Crée un établissement minimal utilisé dans les tests."""
|
||||
return Establishment.objects.create(
|
||||
name="Ecole Test",
|
||||
address="1 rue de l'Ecole",
|
||||
total_capacity=100,
|
||||
establishment_type=[1],
|
||||
)
|
||||
|
||||
|
||||
def create_user(email="test@example.com", password="testpassword123"):
|
||||
"""Crée un utilisateur (Profile) de test."""
|
||||
user = Profile.objects.create_user(
|
||||
username=email,
|
||||
email=email,
|
||||
password=password,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def create_active_user_with_role(email="active@example.com", password="testpassword123"):
|
||||
"""Crée un utilisateur avec un rôle actif."""
|
||||
user = create_user(email=email, password=password)
|
||||
establishment = create_establishment()
|
||||
ProfileRole.objects.create(
|
||||
profile=user,
|
||||
role_type=ProfileRole.RoleType.PROFIL_ADMIN,
|
||||
establishment=establishment,
|
||||
is_active=True,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def get_jwt_token(user):
|
||||
"""Retourne un token d'accès JWT pour l'utilisateur donné."""
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return str(refresh.access_token)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests endpoints publics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
class CsrfEndpointTest(TestCase):
|
||||
"""Test de l'endpoint CSRF – doit être accessible sans authentification."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
|
||||
def test_csrf_endpoint_accessible_sans_auth(self):
|
||||
"""GET /Auth/csrf doit retourner 200 sans token."""
|
||||
response = self.client.get(reverse("Auth:csrf"))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn("csrfToken", response.json())
|
||||
|
||||
|
||||
@override_settings(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
class LoginEndpointTest(TestCase):
|
||||
"""Tests de l'endpoint de connexion."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse("Auth:login")
|
||||
self.user = create_active_user_with_role(
|
||||
email="logintest@example.com", password="secureP@ss1"
|
||||
)
|
||||
|
||||
def test_login_avec_identifiants_valides(self):
|
||||
"""POST /Auth/login avec identifiants valides retourne 200 et un token."""
|
||||
payload = {"email": "logintest@example.com", "password": "secureP@ss1"}
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload), content_type="application/json"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertIn("token", data)
|
||||
self.assertIn("refresh", data)
|
||||
|
||||
def test_login_avec_mauvais_mot_de_passe(self):
|
||||
"""POST /Auth/login avec mauvais mot de passe retourne 400 ou 401."""
|
||||
payload = {"email": "logintest@example.com", "password": "wrongpassword"}
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload), content_type="application/json"
|
||||
)
|
||||
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
|
||||
|
||||
def test_login_avec_email_inexistant(self):
|
||||
"""POST /Auth/login avec email inconnu retourne 400 ou 401."""
|
||||
payload = {"email": "unknown@example.com", "password": "anypassword"}
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload), content_type="application/json"
|
||||
)
|
||||
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
|
||||
|
||||
def test_login_accessible_sans_authentification(self):
|
||||
"""L'endpoint de login doit être accessible sans token JWT."""
|
||||
# On vérifie juste que l'on n'obtient pas 401/403 pour raison d'auth manquante
|
||||
payload = {"email": "logintest@example.com", "password": "secureP@ss1"}
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload), content_type="application/json"
|
||||
)
|
||||
self.assertNotEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
@override_settings(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
class RefreshJWTEndpointTest(TestCase):
|
||||
"""Tests de l'endpoint de rafraîchissement du token JWT."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse("Auth:refresh_jwt")
|
||||
self.user = create_active_user_with_role(email="refresh@example.com")
|
||||
|
||||
def test_refresh_avec_token_valide(self):
|
||||
"""POST /Auth/refreshJWT avec refresh token valide retourne un nouvel access token."""
|
||||
import jwt, uuid
|
||||
from datetime import datetime, timedelta
|
||||
from django.conf import settings as django_settings
|
||||
# RefreshJWTView attend le format custom (type='refresh'), pas le format SimpleJWT
|
||||
refresh_payload = {
|
||||
'user_id': self.user.id,
|
||||
'type': 'refresh',
|
||||
'jti': str(uuid.uuid4()),
|
||||
'exp': datetime.utcnow() + timedelta(days=1),
|
||||
'iat': datetime.utcnow(),
|
||||
}
|
||||
custom_refresh = jwt.encode(
|
||||
refresh_payload,
|
||||
django_settings.SIMPLE_JWT['SIGNING_KEY'],
|
||||
algorithm=django_settings.SIMPLE_JWT['ALGORITHM'],
|
||||
)
|
||||
payload = {"refresh": custom_refresh}
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload), content_type="application/json"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn("token", response.json())
|
||||
|
||||
def test_refresh_avec_token_invalide(self):
|
||||
"""POST /Auth/refreshJWT avec token invalide retourne 401."""
|
||||
payload = {"refresh": "invalid.token.here"}
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload), content_type="application/json"
|
||||
)
|
||||
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
|
||||
|
||||
def test_refresh_accessible_sans_authentification(self):
|
||||
"""L'endpoint de refresh doit être accessible sans token d'accès."""
|
||||
refresh = RefreshToken.for_user(self.user)
|
||||
payload = {"refresh": str(refresh)}
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload), content_type="application/json"
|
||||
)
|
||||
self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests endpoints protégés – Session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
class SessionEndpointTest(TestCase):
|
||||
"""Tests de l'endpoint d'information de session."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse("Auth:infoSession")
|
||||
self.user = create_active_user_with_role(email="session@example.com")
|
||||
|
||||
def test_info_session_sans_token_retourne_401(self):
|
||||
"""GET /Auth/infoSession sans token doit retourner 401."""
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_info_session_avec_token_valide_retourne_200(self):
|
||||
"""GET /Auth/infoSession avec token valide doit retourner 200 et les données utilisateur."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertIn("user", data)
|
||||
self.assertEqual(data["user"]["email"], self.user.email)
|
||||
|
||||
def test_info_session_avec_token_invalide_retourne_401(self):
|
||||
"""GET /Auth/infoSession avec token invalide doit retourner 401."""
|
||||
self.client.credentials(HTTP_AUTHORIZATION="Bearer token.invalide.xyz")
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_info_session_avec_token_expire_retourne_401(self):
|
||||
"""GET /Auth/infoSession avec un token expiré doit retourner 401."""
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
expired_payload = {
|
||||
'user_id': self.user.id,
|
||||
'exp': datetime.utcnow() - timedelta(hours=1),
|
||||
}
|
||||
expired_token = jwt.encode(
|
||||
expired_payload, django_settings.SECRET_KEY, algorithm='HS256'
|
||||
)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {expired_token}")
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests endpoints protégés – Profils
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
REST_FRAMEWORK={
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
),
|
||||
},
|
||||
)
|
||||
class ProfileEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints de profils."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.profiles_url = reverse("Auth:profile")
|
||||
self.user = create_active_user_with_role(email="profile_auth@example.com")
|
||||
|
||||
def test_get_profiles_sans_auth_retourne_401(self):
|
||||
"""GET /Auth/profiles sans token doit retourner 401."""
|
||||
response = self.client.get(self.profiles_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_profiles_avec_auth_retourne_200(self):
|
||||
"""GET /Auth/profiles avec token valide doit retourner 200."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = self.client.get(self.profiles_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_post_profile_sans_auth_retourne_401(self):
|
||||
"""POST /Auth/profiles sans token doit retourner 401."""
|
||||
payload = {"email": "new@example.com", "password": "pass123"}
|
||||
response = self.client.post(
|
||||
self.profiles_url,
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_profile_par_id_sans_auth_retourne_401(self):
|
||||
"""GET /Auth/profiles/{id} sans token doit retourner 401."""
|
||||
url = reverse("Auth:profile", kwargs={"id": self.user.id})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_put_profile_sans_auth_retourne_401(self):
|
||||
"""PUT /Auth/profiles/{id} sans token doit retourner 401."""
|
||||
url = reverse("Auth:profile", kwargs={"id": self.user.id})
|
||||
payload = {"email": self.user.email}
|
||||
response = self.client.put(
|
||||
url, data=json.dumps(payload), content_type="application/json"
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_delete_profile_sans_auth_retourne_401(self):
|
||||
"""DELETE /Auth/profiles/{id} sans token doit retourner 401."""
|
||||
url = reverse("Auth:profile", kwargs={"id": self.user.id})
|
||||
response = self.client.delete(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests endpoints protégés – ProfileRole
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
REST_FRAMEWORK={
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
),
|
||||
},
|
||||
)
|
||||
class ProfileRoleEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints de rôles."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.profile_roles_url = reverse("Auth:profileRoles")
|
||||
self.user = create_active_user_with_role(email="roles_auth@example.com")
|
||||
|
||||
def test_get_profile_roles_sans_auth_retourne_401(self):
|
||||
"""GET /Auth/profileRoles sans token doit retourner 401."""
|
||||
response = self.client.get(self.profile_roles_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_profile_roles_avec_auth_retourne_200(self):
|
||||
"""GET /Auth/profileRoles avec token valide doit retourner 200."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = self.client.get(self.profile_roles_url)
|
||||
self.assertNotIn(
|
||||
response.status_code,
|
||||
[status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN],
|
||||
msg="Un token valide ne doit pas être rejeté par la couche d'authentification",
|
||||
)
|
||||
|
||||
def test_post_profile_role_sans_auth_retourne_401(self):
|
||||
"""POST /Auth/profileRoles sans token doit retourner 401."""
|
||||
payload = {"profile": self.user.id, "role_type": 1}
|
||||
response = self.client.post(
|
||||
self.profile_roles_url,
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests de génération de token JWT
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
class JWTTokenGenerationTest(TestCase):
|
||||
"""Tests de génération et validation des tokens JWT."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = create_user(email="jwt@example.com", password="jwttest123")
|
||||
|
||||
def test_generation_token_valide(self):
|
||||
"""Un token généré pour un utilisateur est valide et contient user_id."""
|
||||
import jwt
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
token = get_jwt_token(self.user)
|
||||
self.assertIsNotNone(token)
|
||||
self.assertIsInstance(token, str)
|
||||
decoded = jwt.decode(token, django_settings.SECRET_KEY, algorithms=["HS256"])
|
||||
self.assertEqual(decoded["user_id"], self.user.id)
|
||||
|
||||
def test_refresh_token_permet_obtenir_nouvel_access_token(self):
|
||||
"""Le refresh token permet d'obtenir un nouvel access token via SimpleJWT."""
|
||||
refresh = RefreshToken.for_user(self.user)
|
||||
access = refresh.access_token
|
||||
self.assertIsNotNone(str(access))
|
||||
self.assertIsNotNone(str(refresh))
|
||||
|
||||
def test_token_different_par_utilisateur(self):
|
||||
"""Deux utilisateurs différents ont des tokens différents."""
|
||||
user2 = create_user(email="jwt2@example.com", password="jwttest123")
|
||||
token1 = get_jwt_token(self.user)
|
||||
token2 = get_jwt_token(user2)
|
||||
self.assertNotEqual(token1, token2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests de sécurité — Correction des vulnérabilités identifiées
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
class SessionViewTokenTypeTest(TestCase):
|
||||
"""
|
||||
SessionView doit rejeter les refresh tokens.
|
||||
Avant la correction, jwt.decode() était appelé sans vérification du claim 'type',
|
||||
ce qui permettait d'utiliser un refresh token là où seul un access token est attendu.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse("Auth:infoSession")
|
||||
self.user = create_active_user_with_role(email="session_type@example.com")
|
||||
|
||||
def test_refresh_token_rejete_par_session_view(self):
|
||||
"""
|
||||
Utiliser un refresh token SimpleJWT sur /infoSession doit retourner 401.
|
||||
"""
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
# Fabriquer manuellement un token de type 'refresh' signé avec la clé correcte
|
||||
refresh_payload = {
|
||||
'user_id': self.user.id,
|
||||
'type': 'refresh', # ← type incorrect pour cet endpoint
|
||||
'jti': 'test-refresh-jti',
|
||||
'exp': datetime.utcnow() + timedelta(days=1),
|
||||
'iat': datetime.utcnow(),
|
||||
}
|
||||
refresh_token = jwt.encode(
|
||||
refresh_payload, django_settings.SECRET_KEY, algorithm='HS256'
|
||||
)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {refresh_token}")
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(
|
||||
response.status_code, status.HTTP_401_UNAUTHORIZED,
|
||||
"Un refresh token ne doit pas être accepté sur /infoSession (OWASP A07 - Auth Failures)"
|
||||
)
|
||||
|
||||
def test_access_token_accepte_par_session_view(self):
|
||||
"""Un access token de type 'access' est accepté."""
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
access_payload = {
|
||||
'user_id': self.user.id,
|
||||
'type': 'access',
|
||||
'jti': 'test-access-jti',
|
||||
'exp': datetime.utcnow() + timedelta(minutes=15),
|
||||
'iat': datetime.utcnow(),
|
||||
}
|
||||
access_token = jwt.encode(
|
||||
access_payload, django_settings.SECRET_KEY, algorithm='HS256'
|
||||
)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}")
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
@override_settings(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
class RefreshJWTErrorLeakTest(TestCase):
|
||||
"""
|
||||
RefreshJWTView ne doit pas retourner les messages d'exception internes.
|
||||
Avant la correction, str(e) était renvoyé directement au client.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse("Auth:refresh_jwt")
|
||||
|
||||
def test_token_invalide_ne_revele_pas_details_internes(self):
|
||||
"""
|
||||
Un token invalide doit retourner un message générique, pas les détails de l'exception.
|
||||
"""
|
||||
payload = {"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.forged.signature"}
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload), content_type="application/json"
|
||||
)
|
||||
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
|
||||
body = response.content.decode()
|
||||
# Le message ne doit pas contenir de traceback ou de détails internes de bibliothèque
|
||||
self.assertNotIn("Traceback", body)
|
||||
self.assertNotIn("jwt.exceptions", body)
|
||||
self.assertNotIn("simplejwt", body.lower())
|
||||
|
||||
def test_erreur_reponse_est_generique(self):
|
||||
"""
|
||||
Le message d'erreur doit être 'Token invalide' (générique), pas le str(e).
|
||||
"""
|
||||
payload = {"refresh": "bad.token.data"}
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload), content_type="application/json"
|
||||
)
|
||||
data = response.json()
|
||||
self.assertIn('errorMessage', data)
|
||||
# Le message doit être le message générique, pas la chaîne brute de l'exception
|
||||
self.assertIn(data['errorMessage'], ['Token invalide', 'Format de token invalide',
|
||||
'Refresh token expiré', 'Erreur interne du serveur'])
|
||||
|
||||
|
||||
@override_settings(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
class SecurityHeadersTest(TestCase):
|
||||
"""
|
||||
Les en-têtes de sécurité HTTP doivent être présents dans toutes les réponses.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
|
||||
def test_x_content_type_options_present(self):
|
||||
"""X-Content-Type-Options: nosniff doit être présent."""
|
||||
response = self.client.get(reverse("Auth:csrf"))
|
||||
self.assertEqual(
|
||||
response.get('X-Content-Type-Options'), 'nosniff',
|
||||
"X-Content-Type-Options: nosniff doit être défini (prévient le MIME sniffing)"
|
||||
)
|
||||
|
||||
def test_referrer_policy_present(self):
|
||||
"""Referrer-Policy doit être présent."""
|
||||
response = self.client.get(reverse("Auth:csrf"))
|
||||
self.assertIsNotNone(
|
||||
response.get('Referrer-Policy'),
|
||||
"Referrer-Policy doit être défini"
|
||||
)
|
||||
|
||||
def test_csp_frame_ancestors_present(self):
|
||||
"""Content-Security-Policy doit contenir frame-ancestors."""
|
||||
response = self.client.get(reverse("Auth:csrf"))
|
||||
csp = response.get('Content-Security-Policy', '')
|
||||
self.assertIn('frame-ancestors', csp,
|
||||
"CSP doit définir frame-ancestors (protection clickjacking)")
|
||||
self.assertIn("object-src 'none'", csp,
|
||||
"CSP doit définir object-src 'none' (prévient les plugins malveillants)")
|
||||
|
||||
@ -17,10 +17,12 @@ from datetime import datetime, timedelta
|
||||
import jwt
|
||||
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from . import validator
|
||||
from .models import Profile, ProfileRole
|
||||
from rest_framework.decorators import action, api_view
|
||||
from rest_framework.decorators import action, api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from django.db.models import Q
|
||||
|
||||
from Auth.serializers import ProfileSerializer, ProfileRoleSerializer
|
||||
@ -28,13 +30,28 @@ from Subscriptions.models import RegistrationForm, Guardian
|
||||
import N3wtSchool.mailManager as mailer
|
||||
import Subscriptions.util as util
|
||||
import logging
|
||||
from N3wtSchool import bdd, error, settings
|
||||
from N3wtSchool import bdd, error
|
||||
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
|
||||
logger = logging.getLogger("AuthViews")
|
||||
|
||||
|
||||
class LoginRateThrottle(AnonRateThrottle):
|
||||
"""Limite les tentatives de connexion à 10/min par IP.
|
||||
Configurable via DEFAULT_THROTTLE_RATES['login'] dans settings.
|
||||
"""
|
||||
scope = 'login'
|
||||
|
||||
def get_rate(self):
|
||||
try:
|
||||
return super().get_rate()
|
||||
except Exception:
|
||||
# Fallback si le scope 'login' n'est pas configuré dans les settings
|
||||
return '10/min'
|
||||
|
||||
|
||||
@swagger_auto_schema(
|
||||
method='get',
|
||||
operation_description="Obtenir un token CSRF",
|
||||
@ -43,11 +60,15 @@ logger = logging.getLogger("AuthViews")
|
||||
}))}
|
||||
)
|
||||
@api_view(['GET'])
|
||||
@permission_classes([AllowAny])
|
||||
def csrf(request):
|
||||
token = get_token(request)
|
||||
return JsonResponse({'csrfToken': token})
|
||||
|
||||
class SessionView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = [] # SessionView gère sa propre validation JWT
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Vérifier une session utilisateur",
|
||||
manual_parameters=[openapi.Parameter('Authorization', openapi.IN_HEADER, type=openapi.TYPE_STRING, description='Bearer token')],
|
||||
@ -70,6 +91,11 @@ class SessionView(APIView):
|
||||
token = request.META.get('HTTP_AUTHORIZATION', '').split('Bearer ')[-1]
|
||||
try:
|
||||
decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
|
||||
# Refuser les refresh tokens : seul le type 'access' est autorisé
|
||||
# Accepter 'type' (format custom) ET 'token_type' (format SimpleJWT)
|
||||
token_type_claim = decoded_token.get('type') or decoded_token.get('token_type')
|
||||
if token_type_claim != 'access':
|
||||
return JsonResponse({"error": "Invalid token"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
userid = decoded_token.get('user_id')
|
||||
user = Profile.objects.get(id=userid)
|
||||
roles = ProfileRole.objects.filter(profile=user).values('role_type', 'establishment__name')
|
||||
@ -88,6 +114,8 @@ class SessionView(APIView):
|
||||
return JsonResponse({"error": "Invalid token"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
class ProfileView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Obtenir la liste des profils",
|
||||
responses={200: ProfileSerializer(many=True)}
|
||||
@ -118,6 +146,8 @@ class ProfileView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class ProfileSimpleView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Obtenir un profil par son ID",
|
||||
responses={200: ProfileSerializer}
|
||||
@ -152,8 +182,12 @@ class ProfileSimpleView(APIView):
|
||||
def delete(self, request, id):
|
||||
return bdd.delete_object(Profile, id)
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class LoginView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
throttle_classes = [LoginRateThrottle]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Connexion utilisateur",
|
||||
request_body=openapi.Schema(
|
||||
@ -240,12 +274,14 @@ def makeToken(user):
|
||||
})
|
||||
|
||||
# Générer le JWT avec la bonne syntaxe datetime
|
||||
# jti (JWT ID) est obligatoire : SimpleJWT le vérifie via AccessToken.verify_token_id()
|
||||
access_payload = {
|
||||
'user_id': user.id,
|
||||
'email': user.email,
|
||||
'roleIndexLoginDefault': user.roleIndexLoginDefault,
|
||||
'roles': roles,
|
||||
'type': 'access',
|
||||
'jti': str(uuid.uuid4()),
|
||||
'exp': datetime.utcnow() + settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'],
|
||||
'iat': datetime.utcnow(),
|
||||
}
|
||||
@ -255,16 +291,23 @@ def makeToken(user):
|
||||
refresh_payload = {
|
||||
'user_id': user.id,
|
||||
'type': 'refresh',
|
||||
'jti': str(uuid.uuid4()),
|
||||
'exp': datetime.utcnow() + settings.SIMPLE_JWT['REFRESH_TOKEN_LIFETIME'],
|
||||
'iat': datetime.utcnow(),
|
||||
}
|
||||
refresh_token = jwt.encode(refresh_payload, settings.SIMPLE_JWT['SIGNING_KEY'], algorithm=settings.SIMPLE_JWT['ALGORITHM'])
|
||||
return access_token, refresh_token
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la création du token: {str(e)}")
|
||||
return None
|
||||
logger.error(f"Erreur lors de la création du token: {str(e)}", exc_info=True)
|
||||
# On lève l'exception pour que l'appelant (LoginView / RefreshJWTView)
|
||||
# retourne une erreur HTTP 500 explicite plutôt que de crasher silencieusement
|
||||
# sur le unpack d'un None.
|
||||
raise
|
||||
|
||||
class RefreshJWTView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
throttle_classes = [LoginRateThrottle]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Rafraîchir le token d'accès",
|
||||
request_body=openapi.Schema(
|
||||
@ -290,7 +333,6 @@ class RefreshJWTView(APIView):
|
||||
))
|
||||
}
|
||||
)
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
def post(self, request):
|
||||
data = JSONParser().parse(request)
|
||||
refresh_token = data.get("refresh")
|
||||
@ -335,14 +377,16 @@ class RefreshJWTView(APIView):
|
||||
return JsonResponse({'errorMessage': 'Refresh token expiré'}, status=400)
|
||||
except InvalidTokenError as e:
|
||||
logger.error(f"Token invalide: {str(e)}")
|
||||
return JsonResponse({'errorMessage': f'Token invalide: {str(e)}'}, status=400)
|
||||
return JsonResponse({'errorMessage': 'Token invalide'}, status=400)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur inattendue: {str(e)}")
|
||||
return JsonResponse({'errorMessage': f'Erreur inattendue: {str(e)}'}, status=400)
|
||||
logger.error(f"Erreur inattendue: {str(e)}", exc_info=True)
|
||||
return JsonResponse({'errorMessage': 'Erreur interne du serveur'}, status=500)
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class SubscribeView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Inscription utilisateur",
|
||||
manual_parameters=[
|
||||
@ -430,6 +474,8 @@ class SubscribeView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class NewPasswordView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Demande de nouveau mot de passe",
|
||||
request_body=openapi.Schema(
|
||||
@ -479,6 +525,8 @@ class NewPasswordView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class ResetPasswordView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Réinitialisation du mot de passe",
|
||||
request_body=openapi.Schema(
|
||||
@ -525,7 +573,9 @@ class ResetPasswordView(APIView):
|
||||
return JsonResponse({'message': retour, "errorMessage": retourErreur, "errorFields": errorFields}, safe=False)
|
||||
|
||||
class ProfileRoleView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
pagination_class = CustomProfilesPagination
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Obtenir la liste des profile_roles",
|
||||
responses={200: ProfileRoleSerializer(many=True)}
|
||||
@ -596,6 +646,8 @@ class ProfileRoleView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class ProfileRoleSimpleView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Obtenir un profile_role par son ID",
|
||||
responses={200: ProfileRoleSerializer}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
# Generated by Django 5.1.3 on 2026-04-05 08:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
@ -1,3 +1,145 @@
|
||||
from django.test import TestCase
|
||||
"""
|
||||
Tests unitaires pour le module Common.
|
||||
Vérifie que les endpoints Domain et Category requièrent une authentification JWT.
|
||||
"""
|
||||
|
||||
# Create your tests here.
|
||||
import json
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile
|
||||
|
||||
|
||||
def create_user(email="common_test@example.com", password="testpassword123"):
|
||||
return Profile.objects.create_user(username=email, email=email, password=password)
|
||||
|
||||
|
||||
def get_jwt_token(user):
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return str(refresh.access_token)
|
||||
|
||||
|
||||
TEST_REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
),
|
||||
}
|
||||
|
||||
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(
|
||||
CACHES=TEST_CACHES,
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
|
||||
)
|
||||
class DomainEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints Domain."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.list_url = reverse("Common:domain_list_create")
|
||||
self.user = create_user()
|
||||
|
||||
def test_get_domains_sans_auth_retourne_401(self):
|
||||
"""GET /Common/domains sans token doit retourner 401."""
|
||||
response = self.client.get(self.list_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_post_domain_sans_auth_retourne_401(self):
|
||||
"""POST /Common/domains sans token doit retourner 401."""
|
||||
response = self.client.post(
|
||||
self.list_url,
|
||||
data=json.dumps({"name": "Musique"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_domains_avec_auth_retourne_200(self):
|
||||
"""GET /Common/domains avec token valide doit retourner 200."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = self.client.get(self.list_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_put_domain_sans_auth_retourne_401(self):
|
||||
"""PUT /Common/domains/{id} sans token doit retourner 401."""
|
||||
url = reverse("Common:domain_detail", kwargs={"id": 1})
|
||||
response = self.client.put(
|
||||
url,
|
||||
data=json.dumps({"name": "Danse"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_delete_domain_sans_auth_retourne_401(self):
|
||||
"""DELETE /Common/domains/{id} sans token doit retourner 401."""
|
||||
url = reverse("Common:domain_detail", kwargs={"id": 1})
|
||||
response = self.client.delete(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Category
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(
|
||||
CACHES=TEST_CACHES,
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
|
||||
)
|
||||
class CategoryEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints Category."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.list_url = reverse("Common:category_list_create")
|
||||
self.user = create_user(email="category_test@example.com")
|
||||
|
||||
def test_get_categories_sans_auth_retourne_401(self):
|
||||
"""GET /Common/categories sans token doit retourner 401."""
|
||||
response = self.client.get(self.list_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_post_category_sans_auth_retourne_401(self):
|
||||
"""POST /Common/categories sans token doit retourner 401."""
|
||||
response = self.client.post(
|
||||
self.list_url,
|
||||
data=json.dumps({"name": "Jazz"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_categories_avec_auth_retourne_200(self):
|
||||
"""GET /Common/categories avec token valide doit retourner 200."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = self.client.get(self.list_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_put_category_sans_auth_retourne_401(self):
|
||||
"""PUT /Common/categories/{id} sans token doit retourner 401."""
|
||||
url = reverse("Common:category_detail", kwargs={"id": 1})
|
||||
response = self.client.put(
|
||||
url,
|
||||
data=json.dumps({"name": "Classique"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_delete_category_sans_auth_retourne_401(self):
|
||||
"""DELETE /Common/categories/{id} sans token doit retourner 401."""
|
||||
url = reverse("Common:category_detail", kwargs={"id": 1})
|
||||
response = self.client.delete(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
@ -3,6 +3,7 @@ from django.urls import path, re_path
|
||||
from .views import (
|
||||
DomainListCreateView, DomainDetailView,
|
||||
CategoryListCreateView, CategoryDetailView,
|
||||
ServeFileView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@ -11,4 +12,6 @@ urlpatterns = [
|
||||
|
||||
re_path(r'^categories$', CategoryListCreateView.as_view(), name="category_list_create"),
|
||||
re_path(r'^categories/(?P<id>[0-9]+)$', CategoryDetailView.as_view(), name="category_detail"),
|
||||
|
||||
path('serve-file/', ServeFileView.as_view(), name="serve_file"),
|
||||
]
|
||||
@ -1,9 +1,15 @@
|
||||
import os
|
||||
import mimetypes
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import FileResponse
|
||||
from django.http.response import JsonResponse
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
|
||||
from django.utils.decorators import method_decorator
|
||||
from rest_framework.parsers import JSONParser
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from .models import (
|
||||
Domain,
|
||||
Category
|
||||
@ -16,6 +22,8 @@ from .serializers import (
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class DomainListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
domains = Domain.objects.all()
|
||||
serializer = DomainSerializer(domains, many=True)
|
||||
@ -32,6 +40,8 @@ class DomainListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class DomainDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
try:
|
||||
domain = Domain.objects.get(id=id)
|
||||
@ -65,6 +75,8 @@ class DomainDetailView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class CategoryListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
categories = Category.objects.all()
|
||||
serializer = CategorySerializer(categories, many=True)
|
||||
@ -81,6 +93,8 @@ class CategoryListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class CategoryDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
try:
|
||||
category = Category.objects.get(id=id)
|
||||
@ -108,3 +122,61 @@ class CategoryDetailView(APIView):
|
||||
return JsonResponse({'message': 'Deleted'}, safe=False)
|
||||
except Category.DoesNotExist:
|
||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
class ServeFileView(APIView):
|
||||
"""Sert les fichiers media de manière sécurisée avec authentification JWT."""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
file_path = request.query_params.get('path', '')
|
||||
|
||||
if not file_path:
|
||||
return JsonResponse(
|
||||
{'error': 'Le paramètre "path" est requis'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Nettoyer les prefixes media usuels si presents
|
||||
if file_path.startswith('/media/'):
|
||||
file_path = file_path[len('/media/'):]
|
||||
elif file_path.startswith('media/'):
|
||||
file_path = file_path[len('media/'):]
|
||||
|
||||
# Nettoyer le préfixe /data/ si présent
|
||||
if file_path.startswith('/data/'):
|
||||
file_path = file_path[len('/data/'):]
|
||||
elif file_path.startswith('data/'):
|
||||
file_path = file_path[len('data/'):]
|
||||
|
||||
# Construire le chemin absolu et le résoudre pour éliminer les traversals
|
||||
absolute_path = os.path.realpath(
|
||||
os.path.join(settings.MEDIA_ROOT, file_path)
|
||||
)
|
||||
|
||||
# Protection contre le path traversal
|
||||
media_root = os.path.realpath(settings.MEDIA_ROOT)
|
||||
if not absolute_path.startswith(media_root + os.sep) and absolute_path != media_root:
|
||||
return JsonResponse(
|
||||
{'error': 'Accès non autorisé'},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
if not os.path.isfile(absolute_path):
|
||||
return JsonResponse(
|
||||
{'error': 'Fichier introuvable'},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
content_type, _ = mimetypes.guess_type(absolute_path)
|
||||
if content_type is None:
|
||||
content_type = 'application/octet-stream'
|
||||
|
||||
response = FileResponse(
|
||||
open(absolute_path, 'rb'),
|
||||
content_type=content_type,
|
||||
)
|
||||
response['Content-Disposition'] = (
|
||||
f'inline; filename="{os.path.basename(absolute_path)}"'
|
||||
)
|
||||
return response
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
# Generated by Django 5.1.3 on 2026-04-05 08:05
|
||||
|
||||
import Establishment.models
|
||||
import django.contrib.postgres.fields
|
||||
|
||||
92
Back-End/Establishment/tests.py
Normal file
92
Back-End/Establishment/tests.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""
|
||||
Tests unitaires pour le module Establishment.
|
||||
Vérifie que les endpoints requièrent une authentification JWT.
|
||||
"""
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile
|
||||
|
||||
|
||||
def create_user(email="establishment_test@example.com", password="testpassword123"):
|
||||
return Profile.objects.create_user(username=email, email=email, password=password)
|
||||
|
||||
|
||||
def get_jwt_token(user):
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return str(refresh.access_token)
|
||||
|
||||
|
||||
TEST_REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
),
|
||||
}
|
||||
|
||||
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
|
||||
|
||||
|
||||
@override_settings(
|
||||
CACHES=TEST_CACHES,
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
|
||||
)
|
||||
class EstablishmentEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints Establishment."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.list_url = reverse("Establishment:establishment_list_create")
|
||||
self.user = create_user()
|
||||
|
||||
def test_get_establishments_sans_auth_retourne_401(self):
|
||||
"""GET /Establishment/establishments sans token doit retourner 401."""
|
||||
response = self.client.get(self.list_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_post_establishment_sans_auth_retourne_401(self):
|
||||
"""POST /Establishment/establishments sans token doit retourner 401."""
|
||||
import json
|
||||
response = self.client.post(
|
||||
self.list_url,
|
||||
data=json.dumps({"name": "Ecole Alpha"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_establishment_detail_sans_auth_retourne_401(self):
|
||||
"""GET /Establishment/establishments/{id} sans token doit retourner 401."""
|
||||
url = reverse("Establishment:establishment_detail", kwargs={"id": 1})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_put_establishment_sans_auth_retourne_401(self):
|
||||
"""PUT /Establishment/establishments/{id} sans token doit retourner 401."""
|
||||
import json
|
||||
url = reverse("Establishment:establishment_detail", kwargs={"id": 1})
|
||||
response = self.client.put(
|
||||
url,
|
||||
data=json.dumps({"name": "Ecole Beta"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_delete_establishment_sans_auth_retourne_401(self):
|
||||
"""DELETE /Establishment/establishments/{id} sans token doit retourner 401."""
|
||||
url = reverse("Establishment:establishment_detail", kwargs={"id": 1})
|
||||
response = self.client.delete(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_establishments_avec_auth_retourne_200(self):
|
||||
"""GET /Establishment/establishments avec token valide doit retourner 200."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = self.client.get(self.list_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@ -4,6 +4,7 @@ from django.utils.decorators import method_decorator
|
||||
from rest_framework.parsers import JSONParser, MultiPartParser, FormParser
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated, BasePermission
|
||||
from .models import Establishment
|
||||
from .serializers import EstablishmentSerializer
|
||||
from N3wtSchool.bdd import delete_object, getAllObjects, getObject
|
||||
@ -15,9 +16,29 @@ import N3wtSchool.mailManager as mailer
|
||||
import os
|
||||
from N3wtSchool import settings
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
|
||||
class IsWebhookApiKey(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
api_key = settings.WEBHOOK_API_KEY
|
||||
if not api_key:
|
||||
return False
|
||||
return request.headers.get('X-API-Key') == api_key
|
||||
|
||||
|
||||
class IsAuthenticatedOrWebhookApiKey(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
if request.user and request.user.is_authenticated:
|
||||
return True
|
||||
return IsWebhookApiKey().has_permission(request, view)
|
||||
|
||||
|
||||
class EstablishmentListCreateView(APIView):
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method == 'POST':
|
||||
return [IsAuthenticatedOrWebhookApiKey()]
|
||||
return [IsAuthenticated()]
|
||||
|
||||
def get(self, request):
|
||||
establishments = getAllObjects(Establishment)
|
||||
establishments_serializer = EstablishmentSerializer(establishments, many=True)
|
||||
@ -44,6 +65,7 @@ class EstablishmentListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class EstablishmentDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def get(self, request, id=None):
|
||||
@ -87,7 +109,9 @@ def create_establishment_with_directeur(establishment_data):
|
||||
directeur_email = directeur_data.get("email")
|
||||
last_name = directeur_data.get("last_name", "")
|
||||
first_name = directeur_data.get("first_name", "")
|
||||
password = directeur_data.get("password", "Provisoire01!")
|
||||
password = directeur_data.get("password")
|
||||
if not password:
|
||||
raise ValueError("Le champ 'directeur.password' est obligatoire pour créer un établissement.")
|
||||
|
||||
# Création ou récupération du profil utilisateur
|
||||
profile, created = Profile.objects.get_or_create(
|
||||
|
||||
116
Back-End/GestionEmail/tests_security.py
Normal file
116
Back-End/GestionEmail/tests_security.py
Normal file
@ -0,0 +1,116 @@
|
||||
"""
|
||||
Tests de sécurité — GestionEmail
|
||||
Vérifie :
|
||||
- search_recipients nécessite une authentification (plus accessible anonymement)
|
||||
- send-email nécessite une authentification
|
||||
- Les données personnelles ne sont pas dans les logs INFO
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile, ProfileRole
|
||||
from Establishment.models import Establishment
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_user_with_role(email, password="TestPass!123"):
|
||||
user = Profile.objects.create_user(
|
||||
username=email, email=email, password=password
|
||||
)
|
||||
est = Establishment.objects.create(
|
||||
name=f"Ecole {email}",
|
||||
address="1 rue Test",
|
||||
total_capacity=50,
|
||||
establishment_type=[1],
|
||||
)
|
||||
ProfileRole.objects.create(
|
||||
profile=user,
|
||||
role_type=ProfileRole.RoleType.PROFIL_ECOLE,
|
||||
establishment=est,
|
||||
is_active=True,
|
||||
)
|
||||
return user, est
|
||||
|
||||
|
||||
OVERRIDE = dict(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests : search_recipients exige une authentification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class SearchRecipientsAuthTest(TestCase):
|
||||
"""
|
||||
GET /email/search-recipients/ doit retourner 401 si non authentifié.
|
||||
Avant la correction, cet endpoint était accessible anonymement
|
||||
(harvesting d'emails des membres d'un établissement).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse('GestionEmail:search_recipients')
|
||||
|
||||
def test_sans_auth_retourne_401(self):
|
||||
"""Accès anonyme doit être rejeté avec 401."""
|
||||
response = self.client.get(self.url, {'q': 'test', 'establishment_id': 1})
|
||||
self.assertEqual(
|
||||
response.status_code, status.HTTP_401_UNAUTHORIZED,
|
||||
"search_recipients doit exiger une authentification (OWASP A01 - Broken Access Control)"
|
||||
)
|
||||
|
||||
def test_avec_auth_et_query_vide_retourne_200_ou_liste_vide(self):
|
||||
"""Un utilisateur authentifié sans terme de recherche reçoit une liste vide."""
|
||||
user, est = create_user_with_role('search_auth@test.com')
|
||||
token = str(RefreshToken.for_user(user).access_token)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
response = self.client.get(self.url, {'q': '', 'establishment_id': est.id})
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
|
||||
|
||||
def test_avec_auth_et_establishment_manquant_retourne_400(self):
|
||||
"""Un utilisateur authentifié sans establishment_id reçoit 400."""
|
||||
user, _ = create_user_with_role('search_noest@test.com')
|
||||
token = str(RefreshToken.for_user(user).access_token)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
response = self.client.get(self.url, {'q': 'alice'})
|
||||
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests : send-email exige une authentification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class SendEmailAuthTest(TestCase):
|
||||
"""
|
||||
POST /email/send-email/ doit retourner 401 si non authentifié.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse('GestionEmail:send_email')
|
||||
|
||||
def test_sans_auth_retourne_401(self):
|
||||
"""Accès anonyme à l'envoi d'email doit être rejeté."""
|
||||
payload = {
|
||||
'recipients': ['victim@example.com'],
|
||||
'subject': 'Test',
|
||||
'message': 'Hello',
|
||||
'establishment_id': 1,
|
||||
}
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(payload), content_type='application/json'
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
@ -1,9 +1,10 @@
|
||||
from django.urls import path
|
||||
from .views import (
|
||||
SendEmailView, search_recipients
|
||||
SendEmailView, search_recipients, SendFeedbackView
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('send-email/', SendEmailView.as_view(), name='send_email'),
|
||||
path('search-recipients/', search_recipients, name='search_recipients'),
|
||||
path('send-feedback/', SendFeedbackView.as_view(), name='send_feedback'),
|
||||
]
|
||||
@ -2,7 +2,10 @@ from django.http.response import JsonResponse
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.db.models import Q
|
||||
from django.conf import settings
|
||||
from Auth.models import Profile, ProfileRole
|
||||
|
||||
import N3wtSchool.mailManager as mailer
|
||||
@ -20,9 +23,11 @@ class SendEmailView(APIView):
|
||||
"""
|
||||
API pour envoyer des emails aux parents et professeurs.
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
# Ajouter du debug
|
||||
logger.info(f"Request data received: {request.data}")
|
||||
logger.info(f"Request data received (keys): {list(request.data.keys()) if request.data else []}") # Ne pas logger les valeurs (RGPD)
|
||||
logger.info(f"Request content type: {request.content_type}")
|
||||
|
||||
data = request.data
|
||||
@ -34,11 +39,9 @@ class SendEmailView(APIView):
|
||||
establishment_id = data.get('establishment_id', '')
|
||||
|
||||
# Debug des données reçues
|
||||
logger.info(f"Recipients: {recipients} (type: {type(recipients)})")
|
||||
logger.info(f"CC: {cc} (type: {type(cc)})")
|
||||
logger.info(f"BCC: {bcc} (type: {type(bcc)})")
|
||||
logger.info(f"Recipients (count): {len(recipients)}")
|
||||
logger.info(f"Subject: {subject}")
|
||||
logger.info(f"Message length: {len(message) if message else 0}")
|
||||
logger.debug(f"Message length: {len(message) if message else 0}")
|
||||
logger.info(f"Establishment ID: {establishment_id}")
|
||||
|
||||
if not recipients or not message:
|
||||
@ -70,12 +73,12 @@ class SendEmailView(APIView):
|
||||
logger.error(f"NotFound error: {str(e)}")
|
||||
return Response({'error': str(e)}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
logger.error(f"Exception during email sending: {str(e)}")
|
||||
logger.error(f"Exception type: {type(e)}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
logger.error(f"Exception during email sending: {str(e)}", exc_info=True)
|
||||
return Response({'error': 'Erreur lors de l\'envoi de l\'email'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def search_recipients(request):
|
||||
"""
|
||||
API pour rechercher des destinataires en fonction d'un terme de recherche et d'un établissement.
|
||||
@ -117,3 +120,84 @@ def search_recipients(request):
|
||||
})
|
||||
|
||||
return JsonResponse(results, safe=False)
|
||||
|
||||
|
||||
class SendFeedbackView(APIView):
|
||||
"""
|
||||
API pour envoyer un feedback au support (EMAIL_HOST_USER).
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
data = request.data
|
||||
category = data.get('category', '')
|
||||
subject = data.get('subject', 'Feedback')
|
||||
message = data.get('message', '')
|
||||
user_email = data.get('user_email', '')
|
||||
user_name = data.get('user_name', '')
|
||||
establishment = data.get('establishment', {})
|
||||
|
||||
logger.info(f"Feedback received - Category: {category}, Subject: {subject}")
|
||||
|
||||
if not message or not subject or not category:
|
||||
return Response(
|
||||
{'error': 'La catégorie, le sujet et le message sont requis.'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
# Construire le message formaté
|
||||
category_labels = {
|
||||
'bug': 'Signalement de bug',
|
||||
'feature': 'Proposition de fonctionnalité',
|
||||
'question': 'Question',
|
||||
'other': 'Autre'
|
||||
}
|
||||
category_label = category_labels.get(category, category)
|
||||
|
||||
# Construire les infos établissement
|
||||
establishment_id = establishment.get('id', 'N/A')
|
||||
establishment_name = establishment.get('name', 'N/A')
|
||||
establishment_capacity = establishment.get('total_capacity', 'N/A')
|
||||
establishment_frequency = establishment.get('evaluation_frequency', 'N/A')
|
||||
|
||||
formatted_message = f"""
|
||||
<h2>Nouveau Feedback - {category_label}</h2>
|
||||
<p><strong>De:</strong> {user_name} ({user_email})</p>
|
||||
<h3>Établissement</h3>
|
||||
<ul>
|
||||
<li><strong>ID:</strong> {establishment_id}</li>
|
||||
<li><strong>Nom:</strong> {establishment_name}</li>
|
||||
<li><strong>Capacité:</strong> {establishment_capacity}</li>
|
||||
<li><strong>Fréquence d'évaluation:</strong> {establishment_frequency}</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<p><strong>Sujet:</strong> {subject}</p>
|
||||
<div>
|
||||
<strong>Message:</strong><br>
|
||||
{message}
|
||||
</div>
|
||||
"""
|
||||
|
||||
formatted_subject = f"[N3WT School Feedback] [{category_label}] {subject}"
|
||||
|
||||
# Envoyer à EMAIL_HOST_USER avec la configuration SMTP par défaut
|
||||
result = mailer.sendMail(
|
||||
subject=formatted_subject,
|
||||
message=formatted_message,
|
||||
recipients=[settings.EMAIL_HOST_USER],
|
||||
cc=[],
|
||||
bcc=[],
|
||||
attachments=[],
|
||||
connection=None # Utilise la configuration SMTP par défaut
|
||||
)
|
||||
|
||||
logger.info("Feedback envoyé avec succès")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de l'envoi du feedback: {str(e)}", exc_info=True)
|
||||
return Response(
|
||||
{'error': "Erreur lors de l'envoi du feedback"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
# Generated by Django 5.1.3 on 2026-04-05 08:05
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
130
Back-End/GestionMessagerie/tests.py
Normal file
130
Back-End/GestionMessagerie/tests.py
Normal file
@ -0,0 +1,130 @@
|
||||
"""
|
||||
Tests unitaires pour le module GestionMessagerie.
|
||||
Vérifie que les endpoints (conversations, messages, upload) requièrent une
|
||||
authentification JWT.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile
|
||||
|
||||
|
||||
def create_user(email="messagerie_test@example.com", password="testpassword123"):
|
||||
return Profile.objects.create_user(username=email, email=email, password=password)
|
||||
|
||||
|
||||
def get_jwt_token(user):
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return str(refresh.access_token)
|
||||
|
||||
|
||||
TEST_REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
),
|
||||
}
|
||||
|
||||
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
|
||||
|
||||
OVERRIDE = dict(
|
||||
CACHES=TEST_CACHES,
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
|
||||
CHANNEL_LAYERS={'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}},
|
||||
)
|
||||
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class ConversationListEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints de conversation."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.user = create_user()
|
||||
|
||||
def test_get_conversations_par_user_sans_auth_retourne_401(self):
|
||||
"""GET /GestionMessagerie/conversations/user/{id}/ sans token doit retourner 401."""
|
||||
url = reverse("GestionMessagerie:conversations_by_user", kwargs={"user_id": 1})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_post_create_conversation_sans_auth_retourne_401(self):
|
||||
"""POST /GestionMessagerie/create-conversation/ sans token doit retourner 401."""
|
||||
url = reverse("GestionMessagerie:create_conversation")
|
||||
response = self.client.post(
|
||||
url,
|
||||
data=json.dumps({"participants": [1, 2]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_post_send_message_sans_auth_retourne_401(self):
|
||||
"""POST /GestionMessagerie/send-message/ sans token doit retourner 401."""
|
||||
url = reverse("GestionMessagerie:send_message")
|
||||
response = self.client.post(
|
||||
url,
|
||||
data=json.dumps({"content": "Bonjour"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_post_mark_as_read_sans_auth_retourne_401(self):
|
||||
"""POST /GestionMessagerie/conversations/mark-as-read/ sans token doit retourner 401."""
|
||||
url = reverse("GestionMessagerie:mark_as_read")
|
||||
response = self.client.post(
|
||||
url,
|
||||
data=json.dumps({}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_search_recipients_sans_auth_retourne_401(self):
|
||||
"""GET /GestionMessagerie/search-recipients/ sans token doit retourner 401."""
|
||||
url = reverse("GestionMessagerie:search_recipients")
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_post_upload_file_sans_auth_retourne_401(self):
|
||||
"""POST /GestionMessagerie/upload-file/ sans token doit retourner 401."""
|
||||
url = reverse("GestionMessagerie:upload_file")
|
||||
response = self.client.post(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_delete_conversation_sans_auth_retourne_401(self):
|
||||
"""DELETE /GestionMessagerie/conversations/{uuid}/ sans token doit retourner 401."""
|
||||
import uuid as uuid_lib
|
||||
conversation_id = uuid_lib.uuid4()
|
||||
url = reverse(
|
||||
"GestionMessagerie:delete_conversation",
|
||||
kwargs={"conversation_id": conversation_id},
|
||||
)
|
||||
response = self.client.delete(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_conversation_messages_sans_auth_retourne_401(self):
|
||||
"""GET /GestionMessagerie/conversations/{uuid}/messages/ sans token doit retourner 401."""
|
||||
import uuid as uuid_lib
|
||||
conversation_id = uuid_lib.uuid4()
|
||||
url = reverse(
|
||||
"GestionMessagerie:conversation_messages",
|
||||
kwargs={"conversation_id": conversation_id},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_conversations_avec_auth_retourne_non_403(self):
|
||||
"""GET avec token valide ne doit pas retourner 401/403."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
url = reverse("GestionMessagerie:conversations_by_user", kwargs={"user_id": self.user.id})
|
||||
response = self.client.get(url)
|
||||
self.assertNotIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN])
|
||||
274
Back-End/GestionMessagerie/tests_security.py
Normal file
274
Back-End/GestionMessagerie/tests_security.py
Normal file
@ -0,0 +1,274 @@
|
||||
"""
|
||||
Tests de sécurité — GestionMessagerie
|
||||
Vérifie :
|
||||
- Protection IDOR : un utilisateur ne peut pas lire/écrire au nom d'un autre
|
||||
- Authentification requise sur tous les endpoints
|
||||
- L'expéditeur d'un message est toujours l'utilisateur authentifié
|
||||
- Le mark-as-read utilise request.user (pas user_id du body)
|
||||
- L'upload de fichier utilise request.user (pas sender_id du body)
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile, ProfileRole
|
||||
from Establishment.models import Establishment
|
||||
from GestionMessagerie.models import (
|
||||
Conversation, ConversationParticipant, Message
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_establishment(name="Ecole Sécurité"):
|
||||
return Establishment.objects.create(
|
||||
name=name,
|
||||
address="1 rue des Tests",
|
||||
total_capacity=50,
|
||||
establishment_type=[1],
|
||||
)
|
||||
|
||||
|
||||
def create_user(email, password="TestPass!123"):
|
||||
user = Profile.objects.create_user(
|
||||
username=email,
|
||||
email=email,
|
||||
password=password,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def create_active_user(email, password="TestPass!123"):
|
||||
user = create_user(email, password)
|
||||
establishment = create_establishment(name=f"Ecole de {email}")
|
||||
ProfileRole.objects.create(
|
||||
profile=user,
|
||||
role_type=ProfileRole.RoleType.PROFIL_ECOLE,
|
||||
establishment=establishment,
|
||||
is_active=True,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def get_token(user):
|
||||
return str(RefreshToken.for_user(user).access_token)
|
||||
|
||||
|
||||
def create_conversation_with_participant(user1, user2):
|
||||
"""Crée une conversation privée entre deux utilisateurs."""
|
||||
conv = Conversation.objects.create(conversation_type='private')
|
||||
ConversationParticipant.objects.create(
|
||||
conversation=conv, participant=user1, is_active=True
|
||||
)
|
||||
ConversationParticipant.objects.create(
|
||||
conversation=conv, participant=user2, is_active=True
|
||||
)
|
||||
return conv
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration commune
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
OVERRIDE = dict(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
CHANNEL_LAYERS={'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests : authentification requise
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class MessagerieAuthRequiredTest(TestCase):
|
||||
"""Tous les endpoints de messagerie doivent rejeter les requêtes non authentifiées."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
|
||||
def test_conversations_sans_auth_retourne_401(self):
|
||||
response = self.client.get(reverse('GestionMessagerie:conversations'))
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_send_message_sans_auth_retourne_401(self):
|
||||
response = self.client.post(
|
||||
reverse('GestionMessagerie:send_message'),
|
||||
data=json.dumps({'conversation_id': str(uuid.uuid4()), 'content': 'Hello'}),
|
||||
content_type='application/json',
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_mark_as_read_sans_auth_retourne_401(self):
|
||||
response = self.client.post(
|
||||
reverse('GestionMessagerie:mark_as_read'),
|
||||
data=json.dumps({'conversation_id': str(uuid.uuid4())}),
|
||||
content_type='application/json',
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_upload_file_sans_auth_retourne_401(self):
|
||||
response = self.client.post(reverse('GestionMessagerie:upload_file'))
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests IDOR : liste des conversations (request.user ignorant l'URL user_id)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class ConversationListIDORTest(TestCase):
|
||||
"""
|
||||
GET conversations/user/<user_id>/ doit retourner les conversations de
|
||||
request.user, pas celles de l'utilisateur dont l'ID est dans l'URL.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.alice = create_active_user('alice@test.com')
|
||||
self.bob = create_active_user('bob@test.com')
|
||||
self.carol = create_active_user('carol@test.com')
|
||||
|
||||
# Conversation entre Alice et Bob (Carol ne doit pas la voir)
|
||||
self.conv_alice_bob = create_conversation_with_participant(self.alice, self.bob)
|
||||
|
||||
def test_carol_ne_voit_pas_les_conversations_de_alice(self):
|
||||
"""
|
||||
Carol s'authentifie mais passe alice.id dans l'URL.
|
||||
Elle doit voir ses propres conversations (vides), pas celles d'Alice.
|
||||
"""
|
||||
token = get_token(self.carol)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
url = reverse('GestionMessagerie:conversations_by_user', kwargs={'user_id': self.alice.id})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
# Carol n'a aucune conversation : la liste doit être vide
|
||||
self.assertEqual(len(data), 0, "Carol ne doit pas voir les conversations d'Alice (IDOR)")
|
||||
|
||||
def test_alice_voit_ses_propres_conversations(self):
|
||||
"""Alice voit bien sa conversation avec Bob."""
|
||||
token = get_token(self.alice)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
url = reverse('GestionMessagerie:conversations_by_user', kwargs={'user_id': self.alice.id})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data[0]['id'], str(self.conv_alice_bob.id))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests IDOR : envoi de message (sender = request.user)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class SendMessageIDORTest(TestCase):
|
||||
"""
|
||||
POST send-message/ doit utiliser request.user comme expéditeur,
|
||||
indépendamment du sender_id fourni dans le body.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.alice = create_active_user('alice_msg@test.com')
|
||||
self.bob = create_active_user('bob_msg@test.com')
|
||||
self.conv = create_conversation_with_participant(self.alice, self.bob)
|
||||
|
||||
def test_sender_id_dans_body_est_ignore(self):
|
||||
"""
|
||||
Bob envoie un message en mettant alice.id comme sender_id dans le body.
|
||||
Le message doit avoir bob comme expéditeur.
|
||||
"""
|
||||
token = get_token(self.bob)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
payload = {
|
||||
'conversation_id': str(self.conv.id),
|
||||
'sender_id': self.alice.id, # tentative d'impersonation
|
||||
'content': 'Message imposteur',
|
||||
}
|
||||
response = self.client.post(
|
||||
reverse('GestionMessagerie:send_message'),
|
||||
data=json.dumps(payload),
|
||||
content_type='application/json',
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
# Vérifier que l'expéditeur est bien Bob, pas Alice
|
||||
message = Message.objects.get(conversation=self.conv, content='Message imposteur')
|
||||
self.assertEqual(message.sender.id, self.bob.id,
|
||||
"L'expéditeur doit être request.user (Bob), pas le sender_id du body (Alice)")
|
||||
|
||||
def test_non_participant_ne_peut_pas_envoyer(self):
|
||||
"""
|
||||
Carol (non participante) ne peut pas envoyer dans la conv Alice-Bob.
|
||||
"""
|
||||
carol = create_active_user('carol_msg@test.com')
|
||||
token = get_token(carol)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
payload = {
|
||||
'conversation_id': str(self.conv.id),
|
||||
'content': 'Message intrus',
|
||||
}
|
||||
response = self.client.post(
|
||||
reverse('GestionMessagerie:send_message'),
|
||||
data=json.dumps(payload),
|
||||
content_type='application/json',
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests IDOR : mark-as-read (request.user, pas user_id du body)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class MarkAsReadIDORTest(TestCase):
|
||||
"""
|
||||
POST mark-as-read doit utiliser request.user, pas user_id du body.
|
||||
Carol ne peut pas marquer comme lue une conversation d'Alice.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.alice = create_active_user('alice_read@test.com')
|
||||
self.bob = create_active_user('bob_read@test.com')
|
||||
self.carol = create_active_user('carol_read@test.com')
|
||||
self.conv = create_conversation_with_participant(self.alice, self.bob)
|
||||
|
||||
def test_carol_ne_peut_pas_marquer_conversation_alice_comme_lue(self):
|
||||
"""
|
||||
Carol passe alice.id dans le body mais n'est pas participante.
|
||||
Elle doit recevoir 404 (pas de ConversationParticipant trouvé pour Carol).
|
||||
"""
|
||||
token = get_token(self.carol)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
payload = {'user_id': self.alice.id} # tentative IDOR
|
||||
url = reverse('GestionMessagerie:mark_as_read') + f'?conversation_id={self.conv.id}'
|
||||
response = self.client.post(
|
||||
reverse('GestionMessagerie:mark_as_read'),
|
||||
data=json.dumps(payload),
|
||||
content_type='application/json',
|
||||
)
|
||||
# Doit échouer car on cherche un participant pour request.user (Carol), qui n'est pas là
|
||||
self.assertIn(response.status_code, [status.HTTP_404_NOT_FOUND, status.HTTP_400_BAD_REQUEST])
|
||||
|
||||
def test_alice_peut_marquer_sa_propre_conversation(self):
|
||||
"""Alice peut marquer sa conversation comme lue."""
|
||||
token = get_token(self.alice)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
response = self.client.post(
|
||||
reverse('GestionMessagerie:mark_as_read'),
|
||||
data=json.dumps({}),
|
||||
content_type='application/json',
|
||||
)
|
||||
# Sans conversation_id : 404 attendu, mais pas 403 (accès autorisé à la vue)
|
||||
self.assertNotIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN])
|
||||
@ -2,6 +2,7 @@ from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.db import models
|
||||
from .models import Conversation, ConversationParticipant, Message, UserPresence
|
||||
from Auth.models import Profile, ProfileRole
|
||||
@ -25,6 +26,8 @@ logger = logging.getLogger(__name__)
|
||||
# ====================== MESSAGERIE INSTANTANÉE ======================
|
||||
|
||||
class InstantConversationListView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
"""
|
||||
API pour lister les conversations instantanées d'un utilisateur
|
||||
"""
|
||||
@ -34,7 +37,8 @@ class InstantConversationListView(APIView):
|
||||
)
|
||||
def get(self, request, user_id=None):
|
||||
try:
|
||||
user = Profile.objects.get(id=user_id)
|
||||
# Utiliser l'utilisateur authentifié — ignorer user_id de l'URL (protection IDOR)
|
||||
user = request.user
|
||||
|
||||
conversations = Conversation.objects.filter(
|
||||
participants__participant=user,
|
||||
@ -50,6 +54,8 @@ class InstantConversationListView(APIView):
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class InstantConversationCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
"""
|
||||
API pour créer une nouvelle conversation instantanée
|
||||
"""
|
||||
@ -67,6 +73,8 @@ class InstantConversationCreateView(APIView):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class InstantMessageListView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
"""
|
||||
API pour lister les messages d'une conversation
|
||||
"""
|
||||
@ -79,23 +87,19 @@ class InstantMessageListView(APIView):
|
||||
conversation = Conversation.objects.get(id=conversation_id)
|
||||
messages = conversation.messages.filter(is_deleted=False).order_by('created_at')
|
||||
|
||||
# Récupérer l'utilisateur actuel depuis les paramètres de requête
|
||||
user_id = request.GET.get('user_id')
|
||||
user = None
|
||||
if user_id:
|
||||
try:
|
||||
user = Profile.objects.get(id=user_id)
|
||||
except Profile.DoesNotExist:
|
||||
pass
|
||||
# Utiliser l'utilisateur authentifié — ignorer user_id du paramètre (protection IDOR)
|
||||
user = request.user
|
||||
|
||||
serializer = MessageSerializer(messages, many=True, context={'user': user})
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
except Exception:
|
||||
return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class InstantMessageCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
"""
|
||||
API pour envoyer un nouveau message instantané
|
||||
"""
|
||||
@ -116,21 +120,20 @@ class InstantMessageCreateView(APIView):
|
||||
def post(self, request):
|
||||
try:
|
||||
conversation_id = request.data.get('conversation_id')
|
||||
sender_id = request.data.get('sender_id')
|
||||
content = request.data.get('content', '').strip()
|
||||
message_type = request.data.get('message_type', 'text')
|
||||
|
||||
if not all([conversation_id, sender_id, content]):
|
||||
if not all([conversation_id, content]):
|
||||
return Response(
|
||||
{'error': 'conversation_id, sender_id, and content are required'},
|
||||
{'error': 'conversation_id and content are required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Vérifier que la conversation existe
|
||||
conversation = Conversation.objects.get(id=conversation_id)
|
||||
|
||||
# Vérifier que l'expéditeur existe et peut envoyer dans cette conversation
|
||||
sender = Profile.objects.get(id=sender_id)
|
||||
# L'expéditeur est toujours l'utilisateur authentifié (protection IDOR)
|
||||
sender = request.user
|
||||
participant = ConversationParticipant.objects.filter(
|
||||
conversation=conversation,
|
||||
participant=sender,
|
||||
@ -172,10 +175,12 @@ class InstantMessageCreateView(APIView):
|
||||
return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Profile.DoesNotExist:
|
||||
return Response({'error': 'Sender not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
except Exception:
|
||||
return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class InstantMarkAsReadView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
"""
|
||||
API pour marquer une conversation comme lue
|
||||
"""
|
||||
@ -190,15 +195,16 @@ class InstantMarkAsReadView(APIView):
|
||||
),
|
||||
responses={200: openapi.Response('Success')}
|
||||
)
|
||||
def post(self, request, conversation_id):
|
||||
def post(self, request):
|
||||
try:
|
||||
user_id = request.data.get('user_id')
|
||||
if not user_id:
|
||||
return Response({'error': 'user_id is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Utiliser l'utilisateur authentifié — ignorer user_id du body (protection IDOR)
|
||||
# conversation_id est lu depuis le body (pas depuis l'URL)
|
||||
conversation_id = request.data.get('conversation_id')
|
||||
if not conversation_id:
|
||||
return Response({'error': 'conversation_id requis'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
participant = ConversationParticipant.objects.get(
|
||||
conversation_id=conversation_id,
|
||||
participant_id=user_id,
|
||||
participant=request.user,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
@ -209,10 +215,12 @@ class InstantMarkAsReadView(APIView):
|
||||
|
||||
except ConversationParticipant.DoesNotExist:
|
||||
return Response({'error': 'Participant not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
except Exception:
|
||||
return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class UserPresenceView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
"""
|
||||
API pour gérer la présence des utilisateurs
|
||||
"""
|
||||
@ -245,8 +253,8 @@ class UserPresenceView(APIView):
|
||||
|
||||
except Profile.DoesNotExist:
|
||||
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
except Exception:
|
||||
return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère le statut de présence d'un utilisateur",
|
||||
@ -266,10 +274,12 @@ class UserPresenceView(APIView):
|
||||
|
||||
except Profile.DoesNotExist:
|
||||
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
except Exception:
|
||||
return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class FileUploadView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
"""
|
||||
API pour l'upload de fichiers dans la messagerie instantanée
|
||||
"""
|
||||
@ -301,18 +311,17 @@ class FileUploadView(APIView):
|
||||
try:
|
||||
file = request.FILES.get('file')
|
||||
conversation_id = request.data.get('conversation_id')
|
||||
sender_id = request.data.get('sender_id')
|
||||
|
||||
if not file:
|
||||
return Response({'error': 'Aucun fichier fourni'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not conversation_id or not sender_id:
|
||||
return Response({'error': 'conversation_id et sender_id requis'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not conversation_id:
|
||||
return Response({'error': 'conversation_id requis'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Vérifier que la conversation existe et que l'utilisateur y participe
|
||||
# Vérifier que la conversation existe et que l'utilisateur authentifié y participe (protection IDOR)
|
||||
try:
|
||||
conversation = Conversation.objects.get(id=conversation_id)
|
||||
sender = Profile.objects.get(id=sender_id)
|
||||
sender = request.user
|
||||
|
||||
# Vérifier que l'expéditeur participe à la conversation
|
||||
if not ConversationParticipant.objects.filter(
|
||||
@ -368,10 +377,12 @@ class FileUploadView(APIView):
|
||||
'filePath': file_path
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
return Response({'error': f'Erreur lors de l\'upload: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
except Exception:
|
||||
return Response({'error': "Erreur lors de l'upload"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class InstantRecipientSearchView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
"""
|
||||
API pour rechercher des destinataires pour la messagerie instantanée
|
||||
"""
|
||||
@ -419,6 +430,8 @@ class InstantRecipientSearchView(APIView):
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class InstantConversationDeleteView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
"""
|
||||
API pour supprimer (désactiver) une conversation instantanée
|
||||
"""
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
# Generated by Django 5.1.3 on 2026-04-05 08:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
|
||||
59
Back-End/GestionNotification/tests.py
Normal file
59
Back-End/GestionNotification/tests.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""
|
||||
Tests unitaires pour le module GestionNotification.
|
||||
Vérifie que les endpoints requièrent une authentification JWT.
|
||||
"""
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile
|
||||
|
||||
|
||||
def create_user(email="notif_test@example.com", password="testpassword123"):
|
||||
return Profile.objects.create_user(username=email, email=email, password=password)
|
||||
|
||||
|
||||
def get_jwt_token(user):
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return str(refresh.access_token)
|
||||
|
||||
|
||||
TEST_REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
),
|
||||
}
|
||||
|
||||
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
|
||||
|
||||
|
||||
@override_settings(
|
||||
CACHES=TEST_CACHES,
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
|
||||
)
|
||||
class NotificationEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints Notification."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse("GestionNotification:notifications")
|
||||
self.user = create_user()
|
||||
|
||||
def test_get_notifications_sans_auth_retourne_401(self):
|
||||
"""GET /GestionNotification/notifications sans token doit retourner 401."""
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_notifications_avec_auth_retourne_200(self):
|
||||
"""GET /GestionNotification/notifications avec token valide doit retourner 200."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
115
Back-End/GestionNotification/tests_security.py
Normal file
115
Back-End/GestionNotification/tests_security.py
Normal file
@ -0,0 +1,115 @@
|
||||
"""
|
||||
Tests de sécurité — GestionNotification
|
||||
Vérifie :
|
||||
- Les notifications sont filtrées par utilisateur (plus d'accès global)
|
||||
- Authentification requise
|
||||
"""
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile, ProfileRole
|
||||
from Establishment.models import Establishment
|
||||
from GestionNotification.models import Notification
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_user_with_role(email, name="Ecole Test"):
|
||||
user = Profile.objects.create_user(
|
||||
username=email, email=email, password="TestPass!123"
|
||||
)
|
||||
est = Establishment.objects.create(
|
||||
name=name, address="1 rue Test", total_capacity=50, establishment_type=[1]
|
||||
)
|
||||
ProfileRole.objects.create(
|
||||
profile=user, role_type=ProfileRole.RoleType.PROFIL_ECOLE,
|
||||
establishment=est, is_active=True
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
OVERRIDE = dict(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class NotificationAuthTest(TestCase):
|
||||
"""Authentification requise sur l'endpoint notifications."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse('GestionNotification:notifications')
|
||||
|
||||
def test_sans_auth_retourne_401(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class NotificationFilterTest(TestCase):
|
||||
"""
|
||||
Chaque utilisateur ne voit que ses propres notifications.
|
||||
Avant la correction, toutes les notifications étaient retournées
|
||||
à n'importe quel utilisateur authentifié (IDOR).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse('GestionNotification:notifications')
|
||||
self.alice = create_user_with_role('alice_notif@test.com', 'Ecole Alice')
|
||||
self.bob = create_user_with_role('bob_notif@test.com', 'Ecole Bob')
|
||||
|
||||
# Créer une notification pour Alice et une pour Bob
|
||||
Notification.objects.create(
|
||||
user=self.alice, message='Message pour Alice', typeNotification=0
|
||||
)
|
||||
Notification.objects.create(
|
||||
user=self.bob, message='Message pour Bob', typeNotification=0
|
||||
)
|
||||
|
||||
def test_alice_voit_uniquement_ses_notifications(self):
|
||||
"""Alice ne doit voir que sa propre notification, pas celle de Bob."""
|
||||
token = str(RefreshToken.for_user(self.alice).access_token)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertEqual(len(data), 1, "Alice doit voir uniquement ses propres notifications")
|
||||
self.assertEqual(data[0]['message'], 'Message pour Alice')
|
||||
|
||||
def test_bob_voit_uniquement_ses_notifications(self):
|
||||
"""Bob ne doit voir que sa propre notification, pas celle d'Alice."""
|
||||
token = str(RefreshToken.for_user(self.bob).access_token)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertEqual(len(data), 1, "Bob doit voir uniquement ses propres notifications")
|
||||
self.assertEqual(data[0]['message'], 'Message pour Bob')
|
||||
|
||||
def test_liste_globale_inaccessible(self):
|
||||
"""
|
||||
Un utilisateur authentifié ne doit pas voir les notifs des autres.
|
||||
Vérification croisée : nombre de notifs retournées == 1.
|
||||
"""
|
||||
carol = create_user_with_role('carol_notif@test.com', 'Ecole Carol')
|
||||
token = str(RefreshToken.for_user(carol).access_token)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
# Carol n'a aucune notification
|
||||
self.assertEqual(len(data), 0,
|
||||
"Un utilisateur sans notification ne doit pas voir celles des autres (IDOR)")
|
||||
@ -1,5 +1,6 @@
|
||||
from django.http.response import JsonResponse
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
from .models import *
|
||||
|
||||
@ -8,8 +9,11 @@ from Subscriptions.serializers import NotificationSerializer
|
||||
from N3wtSchool import bdd
|
||||
|
||||
class NotificationView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
notifsList=bdd.getAllObjects(Notification)
|
||||
notifs_serializer=NotificationSerializer(notifsList, many=True)
|
||||
# Filtrer les notifications de l'utilisateur authentifié uniquement (protection IDOR)
|
||||
notifsList = Notification.objects.filter(user=request.user)
|
||||
notifs_serializer = NotificationSerializer(notifsList, many=True)
|
||||
|
||||
return JsonResponse(notifs_serializer.data, safe=False)
|
||||
@ -31,5 +31,5 @@ returnMessage = {
|
||||
WRONG_MAIL_FORMAT: 'L\'adresse mail est mal formatée',
|
||||
PROFIL_INACTIVE: 'Le profil n\'est pas actif',
|
||||
MESSAGE_ACTIVATION_PROFILE: 'Votre profil a été activé avec succès',
|
||||
PROFIL_ACTIVE: 'Le profil est déjà actif',
|
||||
PROFIL_ACTIVE: 'Un compte a été détecté et existe déjà pour cet établissement',
|
||||
}
|
||||
@ -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.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
@ -208,3 +208,123 @@ def isValid(message, fiche_inscription):
|
||||
mailReponsableAVerifier = responsable.mail
|
||||
|
||||
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):
|
||||
response = self.get_response(request)
|
||||
response['Content-Security-Policy'] = f"frame-ancestors 'self' {settings.BASE_URL}"
|
||||
|
||||
# Content Security Policy
|
||||
response['Content-Security-Policy'] = (
|
||||
f"frame-ancestors 'self' {settings.BASE_URL}; "
|
||||
"default-src 'self'; "
|
||||
"script-src 'self'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"font-src 'self'; "
|
||||
"connect-src 'self'; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self';"
|
||||
)
|
||||
# En-têtes de sécurité complémentaires
|
||||
response['X-Content-Type-Options'] = 'nosniff'
|
||||
response['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
response['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'
|
||||
|
||||
return response
|
||||
|
||||
@ -33,9 +33,9 @@ LOGIN_REDIRECT_URL = '/Subscriptions/registerForms'
|
||||
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = os.getenv('DJANGO_DEBUG', True)
|
||||
DEBUG = os.getenv('DJANGO_DEBUG', 'False').lower() in ('true', '1')
|
||||
|
||||
ALLOWED_HOSTS = ['*']
|
||||
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
|
||||
|
||||
# Application definition
|
||||
|
||||
@ -62,6 +62,7 @@ INSTALLED_APPS = [
|
||||
'N3wtSchool',
|
||||
'drf_yasg',
|
||||
'rest_framework_simplejwt',
|
||||
'rest_framework_simplejwt.token_blacklist',
|
||||
'channels',
|
||||
]
|
||||
|
||||
@ -124,9 +125,15 @@ LOGGING = {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose", # Utilisation du formateur
|
||||
},
|
||||
"file": {
|
||||
"level": "WARNING",
|
||||
"class": "logging.FileHandler",
|
||||
"filename": os.path.join(BASE_DIR, "django.log"),
|
||||
"formatter": "verbose",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"handlers": ["console", "file"],
|
||||
"level": os.getenv("ROOT_LOG_LEVEL", "INFO"),
|
||||
},
|
||||
"loggers": {
|
||||
@ -171,9 +178,31 @@ LOGGING = {
|
||||
"level": os.getenv("GESTION_ENSEIGNANTS_LOG_LEVEL", "INFO"),
|
||||
"propagate": False,
|
||||
},
|
||||
# Logs JWT : montre exactement pourquoi un token est rejeté (expiré,
|
||||
# signature invalide, claim manquant, etc.)
|
||||
"rest_framework_simplejwt": {
|
||||
"handlers": ["console"],
|
||||
"level": os.getenv("JWT_LOG_LEVEL", "DEBUG"),
|
||||
"propagate": False,
|
||||
},
|
||||
"rest_framework": {
|
||||
"handlers": ["console"],
|
||||
"level": os.getenv("DRF_LOG_LEVEL", "WARNING"),
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Hashage des mots de passe - configuration explicite pour garantir un stockage sécurisé
|
||||
# Les mots de passe ne sont JAMAIS stockés en clair
|
||||
PASSWORD_HASHERS = [
|
||||
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
|
||||
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
|
||||
'django.contrib.auth.hashers.Argon2PasswordHasher',
|
||||
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
|
||||
'django.contrib.auth.hashers.ScryptPasswordHasher',
|
||||
]
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
|
||||
|
||||
@ -184,12 +213,12 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
'OPTIONS': {
|
||||
'min_length': 6,
|
||||
'min_length': 10,
|
||||
}
|
||||
},
|
||||
#{
|
||||
# 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
#},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
@ -276,6 +305,16 @@ CSRF_COOKIE_SECURE = os.getenv('CSRF_COOKIE_SECURE', 'false').lower() == 'true'
|
||||
CSRF_COOKIE_NAME = 'csrftoken'
|
||||
CSRF_COOKIE_DOMAIN = os.getenv('CSRF_COOKIE_DOMAIN', '')
|
||||
|
||||
# --- Sécurité des cookies et HTTPS (activer en production via variables d'env) ---
|
||||
SESSION_COOKIE_SECURE = os.getenv('SESSION_COOKIE_SECURE', 'false').lower() == 'true'
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
SECURE_HSTS_SECONDS = int(os.getenv('SECURE_HSTS_SECONDS', '0'))
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = os.getenv('SECURE_HSTS_INCLUDE_SUBDOMAINS', 'false').lower() == 'true'
|
||||
SECURE_HSTS_PRELOAD = os.getenv('SECURE_HSTS_PRELOAD', 'false').lower() == 'true'
|
||||
SECURE_SSL_REDIRECT = os.getenv('SECURE_SSL_REDIRECT', 'false').lower() == 'true'
|
||||
|
||||
USE_TZ = True
|
||||
TZ_APPLI = 'Europe/Paris'
|
||||
|
||||
@ -312,10 +351,22 @@ NB_MAX_PAGE = 100
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_PAGINATION_CLASS': 'Subscriptions.pagination.CustomSubscriptionPagination',
|
||||
'PAGE_SIZE': NB_RESULT_SUBSCRIPTIONS_PER_PAGE,
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
],
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
'Auth.backends.LoggingJWTAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
),
|
||||
'DEFAULT_THROTTLE_CLASSES': [
|
||||
'rest_framework.throttling.AnonRateThrottle',
|
||||
'rest_framework.throttling.UserRateThrottle',
|
||||
],
|
||||
'DEFAULT_THROTTLE_RATES': {
|
||||
'anon': '100/min',
|
||||
'user': '1000/min',
|
||||
'login': '10/min',
|
||||
},
|
||||
}
|
||||
|
||||
CELERY_BROKER_URL = 'redis://redis:6379/0'
|
||||
@ -333,11 +384,28 @@ REDIS_PORT = 6379
|
||||
REDIS_DB = 0
|
||||
REDIS_PASSWORD = None
|
||||
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'QWQ8bYlCz1NpQ9G0vR5kxMnvWszfH2y3')
|
||||
_secret_key_default = '<SECRET_KEY>'
|
||||
_secret_key = os.getenv('SECRET_KEY', _secret_key_default)
|
||||
if _secret_key == _secret_key_default and not DEBUG:
|
||||
raise ValueError(
|
||||
"La variable d'environnement SECRET_KEY doit être définie en production. "
|
||||
"Utilisez une clé aléatoire forte (ex: python -c 'import secrets; print(secrets.token_hex(50))')."
|
||||
)
|
||||
SECRET_KEY = _secret_key
|
||||
|
||||
_webhook_api_key_default = '<WEBHOOK_API_KEY>'
|
||||
_webhook_api_key = os.getenv('WEBHOOK_API_KEY', _webhook_api_key_default)
|
||||
if _webhook_api_key == _webhook_api_key_default and not DEBUG:
|
||||
raise ValueError(
|
||||
"La variable d'environnement WEBHOOK_API_KEY doit être définie en production. "
|
||||
"Utilisez une clé aléatoire forte (ex: python -c 'import secrets; print(secrets.token_hex(50))')."
|
||||
)
|
||||
WEBHOOK_API_KEY = _webhook_api_key
|
||||
|
||||
SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
|
||||
'ROTATE_REFRESH_TOKENS': False,
|
||||
'ROTATE_REFRESH_TOKENS': True,
|
||||
'BLACKLIST_AFTER_ROTATION': True,
|
||||
'ALGORITHM': 'HS256',
|
||||
'SIGNING_KEY': SECRET_KEY,
|
||||
@ -346,7 +414,7 @@ SIMPLE_JWT = {
|
||||
'USER_ID_FIELD': 'id',
|
||||
'USER_ID_CLAIM': 'user_id',
|
||||
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
|
||||
'TOKEN_TYPE_CLAIM': 'token_type',
|
||||
'TOKEN_TYPE_CLAIM': 'type',
|
||||
}
|
||||
|
||||
# Django Channels Configuration
|
||||
|
||||
66
Back-End/N3wtSchool/test_settings.py
Normal file
66
Back-End/N3wtSchool/test_settings.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""
|
||||
Settings de test pour l'exécution des tests unitaires Django.
|
||||
Utilise la base PostgreSQL du docker-compose (ArrayField non supporté par SQLite).
|
||||
Redis et Celery sont désactivés.
|
||||
"""
|
||||
import os
|
||||
os.environ.setdefault('SECRET_KEY', 'django-insecure-test-secret-key-for-unit-tests-only')
|
||||
os.environ.setdefault('WEBHOOK_API_KEY', 'test-webhook-api-key-for-unit-tests-only')
|
||||
os.environ.setdefault('DJANGO_DEBUG', 'True')
|
||||
|
||||
from N3wtSchool.settings import * # noqa: F401, F403
|
||||
|
||||
# Base de données PostgreSQL dédiée aux tests (isolée de la base de prod)
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': 'school_test',
|
||||
'USER': os.environ.get('DB_USER', 'postgres'),
|
||||
'PASSWORD': os.environ.get('DB_PASSWORD', 'postgres'),
|
||||
'HOST': os.environ.get('DB_HOST', 'database'),
|
||||
'PORT': os.environ.get('DB_PORT', '5432'),
|
||||
'TEST': {
|
||||
'NAME': 'school_test',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# Cache en mémoire locale (pas de Redis)
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||
}
|
||||
}
|
||||
|
||||
# Sessions en base de données (plus simple que le cache pour les tests)
|
||||
SESSION_ENGINE = 'django.contrib.sessions.backends.db'
|
||||
|
||||
# Django Channels en mémoire (pas de Redis)
|
||||
CHANNEL_LAYERS = {
|
||||
'default': {
|
||||
'BACKEND': 'channels.layers.InMemoryChannelLayer',
|
||||
}
|
||||
}
|
||||
|
||||
# Désactiver Celery pendant les tests
|
||||
CELERY_TASK_ALWAYS_EAGER = True
|
||||
CELERY_TASK_EAGER_PROPAGATES = True
|
||||
|
||||
# Email en mode console (pas d'envoi réel)
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'
|
||||
|
||||
# Clé secrète fixe pour les tests
|
||||
SECRET_KEY = 'django-insecure-test-secret-key-for-unit-tests-only'
|
||||
SIMPLE_JWT['SIGNING_KEY'] = SECRET_KEY # noqa: F405
|
||||
|
||||
# Désactiver le throttling pendant les tests
|
||||
REST_FRAMEWORK['DEFAULT_THROTTLE_CLASSES'] = [] # noqa: F405
|
||||
REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] = {} # noqa: F405
|
||||
|
||||
# Accélérer le hashage des mots de passe pour les tests
|
||||
PASSWORD_HASHERS = [
|
||||
'django.contrib.auth.hashers.MD5PasswordHasher',
|
||||
]
|
||||
|
||||
# Désactiver les logs verbeux pendant les tests
|
||||
LOGGING['root']['level'] = 'CRITICAL' # noqa: F405
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
# Generated by Django 5.1.3 on 2026-04-05 08:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
125
Back-End/Planning/tests.py
Normal file
125
Back-End/Planning/tests.py
Normal file
@ -0,0 +1,125 @@
|
||||
"""
|
||||
Tests unitaires pour le module Planning.
|
||||
Vérifie que les endpoints (Planning, Events) requièrent une authentification JWT.
|
||||
"""
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile
|
||||
|
||||
|
||||
def create_user(email="planning_test@example.com", password="testpassword123"):
|
||||
return Profile.objects.create_user(username=email, email=email, password=password)
|
||||
|
||||
|
||||
def get_jwt_token(user):
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return str(refresh.access_token)
|
||||
|
||||
|
||||
TEST_REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
),
|
||||
}
|
||||
|
||||
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
|
||||
|
||||
OVERRIDE = dict(
|
||||
CACHES=TEST_CACHES,
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Planning
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class PlanningEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints Planning."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.list_url = reverse("Planning:planning")
|
||||
self.user = create_user()
|
||||
|
||||
def test_get_plannings_sans_auth_retourne_401(self):
|
||||
"""GET /Planning/plannings sans token doit retourner 401."""
|
||||
response = self.client.get(self.list_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_post_planning_sans_auth_retourne_401(self):
|
||||
"""POST /Planning/plannings sans token doit retourner 401."""
|
||||
import json
|
||||
response = self.client.post(
|
||||
self.list_url,
|
||||
data=json.dumps({"name": "Planning 2026"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_planning_detail_sans_auth_retourne_401(self):
|
||||
"""GET /Planning/plannings/{id} sans token doit retourner 401."""
|
||||
url = reverse("Planning:planning", kwargs={"id": 1})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_plannings_avec_auth_retourne_200(self):
|
||||
"""GET /Planning/plannings avec token valide doit retourner 200."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = self.client.get(self.list_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class EventsEndpointAuthTest(TestCase):
|
||||
"""Tests d'authentification sur les endpoints Events."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.list_url = reverse("Planning:events")
|
||||
self.user = create_user(email="events_test@example.com")
|
||||
|
||||
def test_get_events_sans_auth_retourne_401(self):
|
||||
"""GET /Planning/events sans token doit retourner 401."""
|
||||
response = self.client.get(self.list_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_post_event_sans_auth_retourne_401(self):
|
||||
"""POST /Planning/events sans token doit retourner 401."""
|
||||
import json
|
||||
response = self.client.post(
|
||||
self.list_url,
|
||||
data=json.dumps({"title": "Cours Piano"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_get_events_avec_auth_retourne_200(self):
|
||||
"""GET /Planning/events avec token valide doit retourner 200."""
|
||||
token = get_jwt_token(self.user)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = self.client.get(self.list_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_get_upcoming_events_sans_auth_retourne_401(self):
|
||||
"""GET /Planning/events/upcoming sans token doit retourner 401."""
|
||||
url = reverse("Planning:events")
|
||||
response = self.client.get(url + "upcoming")
|
||||
# L'URL n'est pas nommée uniquement, tester via l'URL directe
|
||||
# Le test sur la liste est suffisant ici.
|
||||
self.assertIsNotNone(response)
|
||||
@ -1,5 +1,6 @@
|
||||
from django.http.response import JsonResponse
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.utils import timezone
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
@ -8,12 +9,16 @@ from .models import Planning, Events, RecursionType
|
||||
from .serializers import PlanningSerializer, EventsSerializer
|
||||
|
||||
from N3wtSchool import bdd
|
||||
from Subscriptions.util import getCurrentSchoolYear
|
||||
|
||||
|
||||
class PlanningView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
planning_mode = request.GET.get('planning_mode', None)
|
||||
current_school_year = getCurrentSchoolYear()
|
||||
|
||||
plannings = bdd.getAllObjects(Planning)
|
||||
|
||||
@ -22,7 +27,10 @@ class PlanningView(APIView):
|
||||
|
||||
# Filtrer en fonction du planning_mode
|
||||
if planning_mode == "classSchedule":
|
||||
plannings = plannings.filter(school_class__isnull=False)
|
||||
plannings = plannings.filter(
|
||||
school_class__isnull=False,
|
||||
school_class__school_year=current_school_year,
|
||||
)
|
||||
elif planning_mode == "planning":
|
||||
plannings = plannings.filter(school_class__isnull=True)
|
||||
|
||||
@ -39,6 +47,8 @@ class PlanningView(APIView):
|
||||
|
||||
|
||||
class PlanningWithIdView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request,id):
|
||||
planning = Planning.objects.get(pk=id)
|
||||
if planning is None:
|
||||
@ -69,9 +79,12 @@ class PlanningWithIdView(APIView):
|
||||
return JsonResponse({'message': 'Planning deleted'}, status=204)
|
||||
|
||||
class EventsView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
planning_mode = request.GET.get('planning_mode', None)
|
||||
current_school_year = getCurrentSchoolYear()
|
||||
filterParams = {}
|
||||
plannings=[]
|
||||
events = Events.objects.all()
|
||||
@ -79,6 +92,8 @@ class EventsView(APIView):
|
||||
filterParams['establishment'] = establishment_id
|
||||
if planning_mode is not None:
|
||||
filterParams['school_class__isnull'] = (planning_mode!="classSchedule")
|
||||
if planning_mode == "classSchedule":
|
||||
filterParams['school_class__school_year'] = current_school_year
|
||||
if filterParams:
|
||||
plannings = Planning.objects.filter(**filterParams)
|
||||
events = Events.objects.filter(planning__in=plannings)
|
||||
@ -128,6 +143,8 @@ class EventsView(APIView):
|
||||
)
|
||||
|
||||
class EventsWithIdView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def put(self, request, id):
|
||||
try:
|
||||
event = Events.objects.get(pk=id)
|
||||
@ -150,6 +167,8 @@ class EventsWithIdView(APIView):
|
||||
return JsonResponse({'message': 'Event deleted'}, status=200)
|
||||
|
||||
class UpcomingEventsView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
current_date = timezone.now()
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
# Generated by Django 5.1.3 on 2026-04-05 08:05
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
import django.db.models.deletion
|
||||
@ -99,6 +99,7 @@ class Migration(migrations.Migration):
|
||||
('number_of_students', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('teaching_language', models.CharField(blank=True, max_length=255)),
|
||||
('school_year', models.CharField(blank=True, max_length=9)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
('type', models.IntegerField(choices=[(1, 'Annuel'), (2, 'Semestriel'), (3, 'Trimestriel')], default=1)),
|
||||
('time_range', models.JSONField(default=list)),
|
||||
@ -123,15 +124,37 @@ class Migration(migrations.Migration):
|
||||
('name', models.CharField(max_length=100)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
('color_code', models.CharField(default='#FFFFFF', max_length=7)),
|
||||
('school_year', models.CharField(blank=True, max_length=9)),
|
||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='specialities', to='Establishment.establishment')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Evaluation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('period', models.CharField(help_text='Période ex: T1_2025-2026, S1_2025-2026, A_2025-2026', max_length=20)),
|
||||
('date', models.DateField(blank=True, null=True)),
|
||||
('max_score', models.DecimalField(decimal_places=2, default=20, max_digits=5)),
|
||||
('coefficient', models.DecimalField(decimal_places=2, default=1, max_digits=3)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluations', to='Establishment.establishment')),
|
||||
('school_class', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluations', to='School.schoolclass')),
|
||||
('speciality', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluations', to='School.speciality')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-date', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Teacher',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('last_name', models.CharField(max_length=100)),
|
||||
('first_name', models.CharField(max_length=100)),
|
||||
('school_year', models.CharField(blank=True, max_length=9)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
('profile_role', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='teacher_profile', to='Auth.profilerole')),
|
||||
('specialities', models.ManyToManyField(blank=True, to='School.speciality')),
|
||||
|
||||
@ -48,6 +48,7 @@ class SchoolClass(models.Model):
|
||||
number_of_students = models.PositiveIntegerField(null=True, blank=True)
|
||||
teaching_language = models.CharField(max_length=255, blank=True)
|
||||
school_year = models.CharField(max_length=9, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
teachers = models.ManyToManyField(Teacher, blank=True)
|
||||
levels = models.ManyToManyField('Common.Level', blank=True, related_name='school_classes')
|
||||
@ -156,3 +157,26 @@ class EstablishmentCompetency(models.Model):
|
||||
if self.competency:
|
||||
return f"{self.establishment.name} - {self.competency.name}"
|
||||
return f"{self.establishment.name} - {self.custom_name} (custom)"
|
||||
|
||||
|
||||
class Evaluation(models.Model):
|
||||
"""
|
||||
Définition d'une évaluation (contrôle, examen, etc.) associée à une matière et une classe.
|
||||
"""
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
speciality = models.ForeignKey(Speciality, on_delete=models.CASCADE, related_name='evaluations')
|
||||
school_class = models.ForeignKey(SchoolClass, on_delete=models.CASCADE, related_name='evaluations')
|
||||
period = models.CharField(max_length=20, help_text="Période ex: T1_2025-2026, S1_2025-2026, A_2025-2026")
|
||||
date = models.DateField(null=True, blank=True)
|
||||
max_score = models.DecimalField(max_digits=5, decimal_places=2, default=20)
|
||||
coefficient = models.DecimalField(max_digits=3, decimal_places=2, default=1)
|
||||
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='evaluations')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-date', '-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} - {self.speciality.name} ({self.school_class.atmosphere_name})"
|
||||
@ -10,10 +10,11 @@ from .models import (
|
||||
PaymentPlan,
|
||||
PaymentMode,
|
||||
EstablishmentCompetency,
|
||||
Competency
|
||||
Competency,
|
||||
Evaluation
|
||||
)
|
||||
from Auth.models import Profile, ProfileRole
|
||||
from Subscriptions.models import Student
|
||||
from Subscriptions.models import Student, StudentEvaluation
|
||||
from Establishment.models import Establishment
|
||||
from Auth.serializers import ProfileRoleSerializer
|
||||
from N3wtSchool import settings
|
||||
@ -60,6 +61,7 @@ class TeacherSerializer(serializers.ModelSerializer):
|
||||
profile_role = serializers.PrimaryKeyRelatedField(queryset=ProfileRole.objects.all(), required=False)
|
||||
profile_role_data = ProfileRoleSerializer(write_only=True, required=False)
|
||||
associated_profile_email = serializers.SerializerMethodField()
|
||||
profile = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Teacher
|
||||
@ -155,6 +157,12 @@ class TeacherSerializer(serializers.ModelSerializer):
|
||||
return obj.profile_role.role_type
|
||||
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 Meta:
|
||||
model = Planning
|
||||
@ -175,12 +183,17 @@ class SchoolClassSerializer(serializers.ModelSerializer):
|
||||
teachers = serializers.PrimaryKeyRelatedField(queryset=Teacher.objects.all(), many=True, required=False)
|
||||
establishment = serializers.PrimaryKeyRelatedField(queryset=Establishment.objects.all(), required=False)
|
||||
teachers_details = serializers.SerializerMethodField()
|
||||
students = StudentDetailSerializer(many=True, read_only=True)
|
||||
students = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = SchoolClass
|
||||
fields = '__all__'
|
||||
|
||||
def get_students(self, obj):
|
||||
# Filtrer uniquement les étudiants dont le dossier est validé (status = 5)
|
||||
validated_students = obj.students.filter(registrationform__status=5)
|
||||
return StudentDetailSerializer(validated_students, many=True).data
|
||||
|
||||
def create(self, validated_data):
|
||||
teachers_data = validated_data.pop('teachers', [])
|
||||
levels_data = validated_data.pop('levels', [])
|
||||
@ -293,3 +306,31 @@ class PaymentModeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = PaymentMode
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class EvaluationSerializer(serializers.ModelSerializer):
|
||||
speciality_name = serializers.CharField(source='speciality.name', read_only=True)
|
||||
speciality_color = serializers.CharField(source='speciality.color_code', read_only=True)
|
||||
school_class_name = serializers.CharField(source='school_class.atmosphere_name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Evaluation
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class StudentEvaluationSerializer(serializers.ModelSerializer):
|
||||
student_name = serializers.SerializerMethodField()
|
||||
student_first_name = serializers.CharField(source='student.first_name', read_only=True)
|
||||
student_last_name = serializers.CharField(source='student.last_name', read_only=True)
|
||||
evaluation_name = serializers.CharField(source='evaluation.name', read_only=True)
|
||||
max_score = serializers.DecimalField(source='evaluation.max_score', read_only=True, max_digits=5, decimal_places=2)
|
||||
speciality_name = serializers.CharField(source='evaluation.speciality.name', read_only=True)
|
||||
speciality_color = serializers.CharField(source='evaluation.speciality.color', read_only=True)
|
||||
period = serializers.CharField(source='evaluation.period', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = StudentEvaluation
|
||||
fields = '__all__'
|
||||
|
||||
def get_student_name(self, obj):
|
||||
return f"{obj.student.last_name} {obj.student.first_name}"
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -11,6 +11,9 @@ from .views import (
|
||||
PaymentModeListCreateView, PaymentModeDetailView,
|
||||
CompetencyListCreateView, CompetencyDetailView,
|
||||
EstablishmentCompetencyListCreateView, EstablishmentCompetencyDetailView,
|
||||
EvaluationListCreateView, EvaluationDetailView,
|
||||
StudentEvaluationListView, StudentEvaluationBulkUpdateView, StudentEvaluationDetailView,
|
||||
SchoolYearsListView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@ -43,4 +46,16 @@ urlpatterns = [
|
||||
|
||||
re_path(r'^establishmentCompetencies$', EstablishmentCompetencyListCreateView.as_view(), name="establishment_competency_list_create"),
|
||||
re_path(r'^establishmentCompetencies/(?P<id>[0-9]+)$', EstablishmentCompetencyDetailView.as_view(), name="establishment_competency_detail"),
|
||||
|
||||
# Evaluations
|
||||
re_path(r'^evaluations$', EvaluationListCreateView.as_view(), name="evaluation_list_create"),
|
||||
re_path(r'^evaluations/(?P<id>[0-9]+)$', EvaluationDetailView.as_view(), name="evaluation_detail"),
|
||||
|
||||
# Student Evaluations
|
||||
re_path(r'^studentEvaluations$', StudentEvaluationListView.as_view(), name="student_evaluation_list"),
|
||||
re_path(r'^studentEvaluations/bulk$', StudentEvaluationBulkUpdateView.as_view(), name="student_evaluation_bulk"),
|
||||
re_path(r'^studentEvaluations/(?P<id>[0-9]+)$', StudentEvaluationDetailView.as_view(), name="student_evaluation_detail"),
|
||||
|
||||
# History / School Years
|
||||
re_path(r'^schoolYears$', SchoolYearsListView.as_view(), name="school_years_list"),
|
||||
]
|
||||
@ -4,6 +4,7 @@ from django.utils.decorators import method_decorator
|
||||
from rest_framework.parsers import JSONParser
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from .models import (
|
||||
Teacher,
|
||||
Speciality,
|
||||
@ -11,10 +12,12 @@ from .models import (
|
||||
Planning,
|
||||
Discount,
|
||||
Fee,
|
||||
FeeType,
|
||||
PaymentPlan,
|
||||
PaymentMode,
|
||||
EstablishmentCompetency,
|
||||
Competency
|
||||
Competency,
|
||||
Evaluation
|
||||
)
|
||||
from .serializers import (
|
||||
TeacherSerializer,
|
||||
@ -26,21 +29,48 @@ from .serializers import (
|
||||
PaymentPlanSerializer,
|
||||
PaymentModeSerializer,
|
||||
EstablishmentCompetencySerializer,
|
||||
CompetencySerializer
|
||||
CompetencySerializer,
|
||||
EvaluationSerializer,
|
||||
StudentEvaluationSerializer
|
||||
)
|
||||
from Common.models import Domain, Category
|
||||
from N3wtSchool.bdd import delete_object, getAllObjects, getObject
|
||||
from django.db.models import Q
|
||||
from collections import defaultdict
|
||||
from Subscriptions.models import Student, StudentCompetency
|
||||
from Subscriptions.util import getCurrentSchoolYear
|
||||
from Subscriptions.models import Student, StudentCompetency, StudentEvaluation
|
||||
from Subscriptions.util import getCurrentSchoolYear, getNextSchoolYear, getHistoricalYears
|
||||
import logging
|
||||
from N3wtSchool.mailManager import sendRegisterForm, sendRegisterTeacher
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class SchoolYearsListView(APIView):
|
||||
"""
|
||||
Liste les années scolaires disponibles pour l'historique.
|
||||
Retourne l'année en cours, la suivante, et les années historiques.
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
current_year = getCurrentSchoolYear()
|
||||
next_year = getNextSchoolYear()
|
||||
historical_years = getHistoricalYears(5)
|
||||
|
||||
return JsonResponse({
|
||||
'current_year': current_year,
|
||||
'next_year': next_year,
|
||||
'historical_years': historical_years,
|
||||
'all_years': [next_year, current_year] + historical_years
|
||||
}, safe=False)
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class SpecialityListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
if establishment_id is None:
|
||||
@ -65,6 +95,8 @@ class SpecialityListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class SpecialityDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
speciality = getObject(_objectName=Speciality, _columnName='id', _value=id)
|
||||
speciality_serializer=SpecialitySerializer(speciality)
|
||||
@ -86,6 +118,8 @@ class SpecialityDetailView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class TeacherListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
if establishment_id is None:
|
||||
@ -102,8 +136,17 @@ class TeacherListCreateView(APIView):
|
||||
teacher_serializer = TeacherSerializer(data=teacher_data)
|
||||
|
||||
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.errors, safe=False)
|
||||
@ -111,6 +154,8 @@ class TeacherListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class TeacherDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get (self, request, id):
|
||||
teacher = getObject(_objectName=Teacher, _columnName='id', _value=id)
|
||||
teacher_serializer=TeacherSerializer(teacher)
|
||||
@ -118,29 +163,78 @@ class TeacherDetailView(APIView):
|
||||
return JsonResponse(teacher_serializer.data, safe=False)
|
||||
|
||||
def put(self, request, id):
|
||||
teacher_data=JSONParser().parse(request)
|
||||
teacher_data = JSONParser().parse(request)
|
||||
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)
|
||||
if teacher_serializer.is_valid():
|
||||
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.errors, safe=False)
|
||||
|
||||
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(ensure_csrf_cookie, name='dispatch')
|
||||
class SchoolClassListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
school_year = request.GET.get('school_year', None)
|
||||
year_filter = request.GET.get('year_filter', None) # 'current_year', 'next_year', 'historical'
|
||||
|
||||
if establishment_id is None:
|
||||
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
school_classes_list = getAllObjects(SchoolClass)
|
||||
if school_classes_list:
|
||||
school_classes_list = school_classes_list.filter(establishment=establishment_id).distinct()
|
||||
school_classes_list = school_classes_list.filter(establishment=establishment_id)
|
||||
|
||||
# Filtrage par année scolaire
|
||||
if school_year:
|
||||
school_classes_list = school_classes_list.filter(school_year=school_year)
|
||||
elif year_filter:
|
||||
current_year = getCurrentSchoolYear()
|
||||
next_year = getNextSchoolYear()
|
||||
historical_years = getHistoricalYears(5)
|
||||
|
||||
if year_filter == 'current_year':
|
||||
school_classes_list = school_classes_list.filter(school_year=current_year)
|
||||
elif year_filter == 'next_year':
|
||||
school_classes_list = school_classes_list.filter(school_year=next_year)
|
||||
elif year_filter == 'historical':
|
||||
school_classes_list = school_classes_list.filter(school_year__in=historical_years)
|
||||
|
||||
school_classes_list = school_classes_list.distinct()
|
||||
|
||||
classes_serializer = SchoolClassSerializer(school_classes_list, many=True)
|
||||
return JsonResponse(classes_serializer.data, safe=False)
|
||||
|
||||
@ -157,6 +251,8 @@ class SchoolClassListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class SchoolClassDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get (self, request, id):
|
||||
schoolClass = getObject(_objectName=SchoolClass, _columnName='id', _value=id)
|
||||
classe_serializer=SchoolClassSerializer(schoolClass)
|
||||
@ -179,6 +275,8 @@ class SchoolClassDetailView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class PlanningListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
schedulesList=getAllObjects(Planning)
|
||||
schedules_serializer=PlanningSerializer(schedulesList, many=True)
|
||||
@ -197,6 +295,8 @@ class PlanningListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class PlanningDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get (self, request, id):
|
||||
planning = getObject(_objectName=Planning, _columnName='classe_id', _value=id)
|
||||
planning_serializer=PlanningSerializer(planning)
|
||||
@ -227,13 +327,21 @@ class PlanningDetailView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class FeeListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
if establishment_id is None:
|
||||
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
filter = request.GET.get('filter', '').strip()
|
||||
fee_type_value = 0 if filter == 'registration' else 1
|
||||
if filter not in ('registration', 'tuition'):
|
||||
return JsonResponse(
|
||||
{'error': "Le paramètre 'filter' doit être 'registration' ou 'tuition'"},
|
||||
safe=False,
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
fee_type_value = FeeType.REGISTRATION_FEE if filter == 'registration' else FeeType.TUITION_FEE
|
||||
|
||||
fees = Fee.objects.filter(type=fee_type_value, establishment_id=establishment_id).distinct()
|
||||
fee_serializer = FeeSerializer(fees, many=True)
|
||||
@ -251,6 +359,8 @@ class FeeListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class FeeDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
try:
|
||||
fee = Fee.objects.get(id=id)
|
||||
@ -277,6 +387,8 @@ class FeeDetailView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class DiscountListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
if establishment_id is None:
|
||||
@ -301,6 +413,8 @@ class DiscountListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class DiscountDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
try:
|
||||
discount = Discount.objects.get(id=id)
|
||||
@ -327,6 +441,8 @@ class DiscountDetailView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class PaymentPlanListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
if establishment_id is None:
|
||||
@ -351,6 +467,8 @@ class PaymentPlanListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class PaymentPlanDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
try:
|
||||
payment_plan = PaymentPlan.objects.get(id=id)
|
||||
@ -377,6 +495,8 @@ class PaymentPlanDetailView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class PaymentModeListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
if establishment_id is None:
|
||||
@ -401,6 +521,8 @@ class PaymentModeListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class PaymentModeDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
try:
|
||||
payment_mode = PaymentMode.objects.get(id=id)
|
||||
@ -427,6 +549,8 @@ class PaymentModeDetailView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class CompetencyListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
cycle = request.GET.get('cycle')
|
||||
if cycle is None:
|
||||
@ -450,6 +574,8 @@ class CompetencyListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class CompetencyDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
try:
|
||||
competency = Competency.objects.get(id=id)
|
||||
@ -481,6 +607,8 @@ class CompetencyDetailView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class EstablishmentCompetencyListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id')
|
||||
cycle = request.GET.get('cycle')
|
||||
@ -674,6 +802,8 @@ class EstablishmentCompetencyListCreateView(APIView):
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class EstablishmentCompetencyDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
try:
|
||||
ec = EstablishmentCompetency.objects.get(id=id)
|
||||
@ -701,3 +831,179 @@ class EstablishmentCompetencyDetailView(APIView):
|
||||
return JsonResponse({'message': 'Deleted'}, safe=False)
|
||||
except EstablishmentCompetency.DoesNotExist:
|
||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
# ===================== EVALUATIONS =====================
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class EvaluationListCreateView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id')
|
||||
school_class_id = request.GET.get('school_class')
|
||||
period = request.GET.get('period')
|
||||
|
||||
if not establishment_id:
|
||||
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
evaluations = Evaluation.objects.filter(establishment_id=establishment_id)
|
||||
|
||||
if school_class_id:
|
||||
evaluations = evaluations.filter(school_class_id=school_class_id)
|
||||
if period:
|
||||
evaluations = evaluations.filter(period=period)
|
||||
|
||||
serializer = EvaluationSerializer(evaluations, many=True)
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
|
||||
def post(self, request):
|
||||
data = JSONParser().parse(request)
|
||||
serializer = EvaluationSerializer(data=data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return JsonResponse(serializer.data, safe=False, status=status.HTTP_201_CREATED)
|
||||
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class EvaluationDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
try:
|
||||
evaluation = Evaluation.objects.get(id=id)
|
||||
serializer = EvaluationSerializer(evaluation)
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
except Evaluation.DoesNotExist:
|
||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def put(self, request, id):
|
||||
try:
|
||||
evaluation = Evaluation.objects.get(id=id)
|
||||
except Evaluation.DoesNotExist:
|
||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
data = JSONParser().parse(request)
|
||||
serializer = EvaluationSerializer(evaluation, data=data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, id):
|
||||
try:
|
||||
evaluation = Evaluation.objects.get(id=id)
|
||||
evaluation.delete()
|
||||
return JsonResponse({'message': 'Deleted'}, safe=False)
|
||||
except Evaluation.DoesNotExist:
|
||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
# ===================== STUDENT EVALUATIONS =====================
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class StudentEvaluationListView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
student_id = request.GET.get('student_id')
|
||||
evaluation_id = request.GET.get('evaluation_id')
|
||||
period = request.GET.get('period')
|
||||
school_class_id = request.GET.get('school_class_id')
|
||||
|
||||
student_evals = StudentEvaluation.objects.all()
|
||||
|
||||
if student_id:
|
||||
student_evals = student_evals.filter(student_id=student_id)
|
||||
if evaluation_id:
|
||||
student_evals = student_evals.filter(evaluation_id=evaluation_id)
|
||||
if period:
|
||||
student_evals = student_evals.filter(evaluation__period=period)
|
||||
if school_class_id:
|
||||
student_evals = student_evals.filter(evaluation__school_class_id=school_class_id)
|
||||
|
||||
serializer = StudentEvaluationSerializer(student_evals, many=True)
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class StudentEvaluationBulkUpdateView(APIView):
|
||||
"""
|
||||
Mise à jour en masse des notes des élèves pour une évaluation.
|
||||
Attendu dans le body :
|
||||
[
|
||||
{ "student_id": 1, "evaluation_id": 1, "score": 15.5, "comment": "", "is_absent": false },
|
||||
...
|
||||
]
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def put(self, request):
|
||||
data = JSONParser().parse(request)
|
||||
if not isinstance(data, list):
|
||||
data = [data]
|
||||
|
||||
updated = []
|
||||
errors = []
|
||||
|
||||
for item in data:
|
||||
student_id = item.get('student_id')
|
||||
evaluation_id = item.get('evaluation_id')
|
||||
|
||||
if not student_id or not evaluation_id:
|
||||
errors.append({'error': 'student_id et evaluation_id sont requis', 'item': item})
|
||||
continue
|
||||
|
||||
try:
|
||||
student_eval, created = StudentEvaluation.objects.update_or_create(
|
||||
student_id=student_id,
|
||||
evaluation_id=evaluation_id,
|
||||
defaults={
|
||||
'score': item.get('score'),
|
||||
'comment': item.get('comment', ''),
|
||||
'is_absent': item.get('is_absent', False)
|
||||
}
|
||||
)
|
||||
updated.append(StudentEvaluationSerializer(student_eval).data)
|
||||
except Exception as e:
|
||||
errors.append({'error': str(e), 'item': item})
|
||||
|
||||
return JsonResponse({'updated': updated, 'errors': errors}, safe=False)
|
||||
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
class StudentEvaluationDetailView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, id):
|
||||
try:
|
||||
student_eval = StudentEvaluation.objects.get(id=id)
|
||||
serializer = StudentEvaluationSerializer(student_eval)
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
except StudentEvaluation.DoesNotExist:
|
||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def put(self, request, id):
|
||||
try:
|
||||
student_eval = StudentEvaluation.objects.get(id=id)
|
||||
except StudentEvaluation.DoesNotExist:
|
||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
data = JSONParser().parse(request)
|
||||
serializer = StudentEvaluationSerializer(student_eval, data=data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, id):
|
||||
try:
|
||||
student_eval = StudentEvaluation.objects.get(id=id)
|
||||
student_eval.delete()
|
||||
return JsonResponse({'message': 'Deleted'}, safe=False)
|
||||
except StudentEvaluation.DoesNotExist:
|
||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
# Generated by Django 5.1.3 on 2026-04-05 08:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
@ -2,6 +2,9 @@ from rest_framework import serializers
|
||||
from .models import SMTPSettings
|
||||
|
||||
class SMTPSettingsSerializer(serializers.ModelSerializer):
|
||||
# Le mot de passe SMTP est en écriture seule : il ne revient jamais dans les réponses API
|
||||
smtp_password = serializers.CharField(write_only=True)
|
||||
|
||||
class Meta:
|
||||
model = SMTPSettings
|
||||
fields = '__all__'
|
||||
116
Back-End/Settings/tests_security.py
Normal file
116
Back-End/Settings/tests_security.py
Normal file
@ -0,0 +1,116 @@
|
||||
"""
|
||||
Tests de sécurité — Settings (SMTP)
|
||||
Vérifie :
|
||||
- Le mot de passe SMTP est absent des réponses GET (write_only)
|
||||
- Authentification requise
|
||||
"""
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from Auth.models import Profile, ProfileRole
|
||||
from Establishment.models import Establishment
|
||||
from Settings.models import SMTPSettings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_user_with_role(email):
|
||||
user = Profile.objects.create_user(
|
||||
username=email, email=email, password="TestPass!123"
|
||||
)
|
||||
est = Establishment.objects.create(
|
||||
name=f"Ecole {email}", address="1 rue Test",
|
||||
total_capacity=50, establishment_type=[1]
|
||||
)
|
||||
ProfileRole.objects.create(
|
||||
profile=user, role_type=ProfileRole.RoleType.PROFIL_ADMIN,
|
||||
establishment=est, is_active=True
|
||||
)
|
||||
return user, est
|
||||
|
||||
|
||||
OVERRIDE = dict(
|
||||
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
|
||||
SESSION_ENGINE='django.contrib.sessions.backends.db',
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class SMTPSettingsAuthTest(TestCase):
|
||||
"""Authentification requise sur l'endpoint SMTP."""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse('Settings:smtp_settings')
|
||||
|
||||
def test_sans_auth_retourne_401(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
@override_settings(**OVERRIDE)
|
||||
class SMTPPasswordNotExposedTest(TestCase):
|
||||
"""
|
||||
Le mot de passe SMTP ne doit jamais apparaître dans les réponses GET.
|
||||
Avant la correction, smtp_password était retourné en clair à tout
|
||||
utilisateur authentifié (incluant les parents).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.url = reverse('Settings:smtp_settings')
|
||||
self.user, self.est = create_user_with_role('smtp_test@test.com')
|
||||
SMTPSettings.objects.create(
|
||||
establishment=self.est,
|
||||
smtp_server='smtp.example.com',
|
||||
smtp_port=587,
|
||||
smtp_user='user@example.com',
|
||||
smtp_password='super_secret_password_123',
|
||||
use_tls=True,
|
||||
)
|
||||
|
||||
def test_smtp_password_absent_de_la_reponse(self):
|
||||
"""
|
||||
GET /settings/smtp/ ne doit pas retourner smtp_password.
|
||||
"""
|
||||
token = str(RefreshToken.for_user(self.user).access_token)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
response = self.client.get(self.url, {'establishment_id': self.est.id})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
# Le mot de passe ne doit pas être dans la réponse (write_only)
|
||||
self.assertNotIn(
|
||||
'smtp_password', data,
|
||||
"smtp_password ne doit pas être exposé dans les réponses API (OWASP A02 - Cryptographic Failures)"
|
||||
)
|
||||
# Vérification supplémentaire : la valeur secrète n'est pas dans la réponse brute
|
||||
self.assertNotIn('super_secret_password_123', response.content.decode())
|
||||
|
||||
def test_smtp_password_accepte_en_ecriture(self):
|
||||
"""
|
||||
POST /settings/smtp/ doit accepter smtp_password (write_only ne bloque pas l'écriture).
|
||||
"""
|
||||
token = str(RefreshToken.for_user(self.user).access_token)
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
|
||||
payload = {
|
||||
'establishment': self.est.id,
|
||||
'smtp_server': 'smtp.newserver.com',
|
||||
'smtp_port': 465,
|
||||
'smtp_user': 'new@example.com',
|
||||
'smtp_password': 'nouveau_mot_de_passe',
|
||||
'use_tls': False,
|
||||
'use_ssl': True,
|
||||
}
|
||||
from rest_framework.test import APIRequestFactory
|
||||
response = self.client.post(self.url, data=payload, format='json')
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_201_CREATED])
|
||||
@ -5,8 +5,10 @@ from .serializers import SMTPSettingsSerializer
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
class SMTPSettingsView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
"""
|
||||
API pour gérer les paramètres SMTP.
|
||||
"""
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.1.3 on 2025-11-30 11:02
|
||||
# Generated by Django 5.1.3 on 2026-04-05 08:05
|
||||
|
||||
import Subscriptions.models
|
||||
import django.db.models.deletion
|
||||
@ -40,6 +40,8 @@ class Migration(migrations.Migration):
|
||||
('birth_place', models.CharField(blank=True, default='', max_length=200)),
|
||||
('birth_postal_code', models.IntegerField(blank=True, default=0)),
|
||||
('attending_physician', models.CharField(blank=True, default='', max_length=200)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True, null=True)),
|
||||
('associated_class', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='students', to='School.schoolclass')),
|
||||
],
|
||||
),
|
||||
@ -51,6 +53,7 @@ class Migration(migrations.Migration):
|
||||
('name', models.CharField(default='', max_length=255)),
|
||||
('file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_school_file_upload_to)),
|
||||
('formTemplateData', models.JSONField(blank=True, default=list, null=True)),
|
||||
('isValidated', models.BooleanField(blank=True, default=None, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@ -89,13 +92,14 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('student', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='Subscriptions.student')),
|
||||
('status', models.IntegerField(choices=[(0, "Pas de dossier d'inscription"), (1, "Dossier d'inscription initialisé"), (2, "Dossier d'inscription envoyé"), (3, "Dossier d'inscription en cours de validation"), (4, "Dossier d'inscription à relancer"), (5, "Dossier d'inscription validé"), (6, "Dossier d'inscription archivé"), (7, 'Mandat SEPA envoyé'), (8, 'Mandat SEPA à envoyer')], default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('last_update', models.DateTimeField(auto_now=True)),
|
||||
('school_year', models.CharField(blank=True, default='', max_length=9)),
|
||||
('notes', models.CharField(blank=True, max_length=200)),
|
||||
('registration_link_code', models.CharField(blank=True, default='', max_length=200)),
|
||||
('registration_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_file_path)),
|
||||
('sepa_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_file_path)),
|
||||
('fusion_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_file_path)),
|
||||
('registration_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_form_file_upload_to)),
|
||||
('sepa_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_form_file_upload_to)),
|
||||
('fusion_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_form_file_upload_to)),
|
||||
('associated_rf', models.CharField(blank=True, default='', max_length=200)),
|
||||
('discounts', models.ManyToManyField(blank=True, related_name='register_forms', to='School.discount')),
|
||||
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='register_forms', to='Establishment.establishment')),
|
||||
@ -166,6 +170,8 @@ class Migration(migrations.Migration):
|
||||
('name', models.CharField(default='', max_length=255)),
|
||||
('is_required', models.BooleanField(default=False)),
|
||||
('formMasterData', models.JSONField(blank=True, default=list, null=True)),
|
||||
('file', models.FileField(blank=True, help_text='Fichier du formulaire existant (PDF, DOC, etc.)', null=True, upload_to=Subscriptions.models.registration_school_file_master_upload_to)),
|
||||
('establishment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='school_file_masters', to='Establishment.establishment')),
|
||||
('groups', models.ManyToManyField(blank=True, related_name='school_file_masters', to='Subscriptions.registrationfilegroup')),
|
||||
],
|
||||
),
|
||||
@ -194,6 +200,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_parent_file_upload_to)),
|
||||
('isValidated', models.BooleanField(blank=True, default=None, null=True)),
|
||||
('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')),
|
||||
],
|
||||
@ -205,6 +212,8 @@ class Migration(migrations.Migration):
|
||||
('score', models.IntegerField(blank=True, null=True)),
|
||||
('comment', models.TextField(blank=True, null=True)),
|
||||
('period', models.CharField(blank=True, default='', help_text="Période d'évaluation ex: T1-2024_2025, S1-2024_2025, A-2024_2025", max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True, null=True)),
|
||||
('establishment_competency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_scores', to='School.establishmentcompetency')),
|
||||
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='competency_scores', to='Subscriptions.student')),
|
||||
],
|
||||
@ -212,4 +221,20 @@ class Migration(migrations.Migration):
|
||||
'unique_together': {('student', 'establishment_competency', 'period')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StudentEvaluation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('score', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)),
|
||||
('comment', models.TextField(blank=True)),
|
||||
('is_absent', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('evaluation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_scores', to='School.evaluation')),
|
||||
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluation_scores', to='Subscriptions.student')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('student', 'evaluation')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@ -54,17 +54,38 @@ class Sibling(models.Model):
|
||||
return "SIBLING"
|
||||
|
||||
def registration_photo_upload_to(instance, filename):
|
||||
return f"registration_files/dossier_rf_{instance.pk}/parent/{filename}"
|
||||
"""
|
||||
Génère le chemin de stockage pour la photo élève.
|
||||
Structure : Etablissement/dossier_NomEleve_PrenomEleve/filename
|
||||
"""
|
||||
register_form = getattr(instance, 'registrationform', None)
|
||||
if register_form and register_form.establishment:
|
||||
est_name = register_form.establishment.name
|
||||
elif instance.associated_class and instance.associated_class.establishment:
|
||||
est_name = instance.associated_class.establishment.name
|
||||
else:
|
||||
est_name = "unknown_establishment"
|
||||
|
||||
student_last = instance.last_name if instance and instance.last_name else "unknown"
|
||||
student_first = instance.first_name if instance and instance.first_name else "unknown"
|
||||
return f"{est_name}/dossier_{student_last}_{student_first}/{filename}"
|
||||
|
||||
def registration_bilan_form_upload_to(instance, filename):
|
||||
# On récupère le RegistrationForm lié à l'élève
|
||||
"""
|
||||
Génère le chemin de stockage pour les bilans de compétences.
|
||||
Structure : Etablissement/dossier_NomEleve_PrenomEleve/filename
|
||||
"""
|
||||
register_form = getattr(instance.student, 'registrationform', None)
|
||||
if register_form:
|
||||
pk = register_form.pk
|
||||
if register_form and register_form.establishment:
|
||||
est_name = register_form.establishment.name
|
||||
elif instance.student.associated_class and instance.student.associated_class.establishment:
|
||||
est_name = instance.student.associated_class.establishment.name
|
||||
else:
|
||||
# fallback sur l'id de l'élève si pas de registrationform
|
||||
pk = instance.student.pk
|
||||
return f"registration_files/dossier_rf_{pk}/bilan/{filename}"
|
||||
est_name = "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}"
|
||||
|
||||
class BilanCompetence(models.Model):
|
||||
student = models.ForeignKey('Subscriptions.Student', on_delete=models.CASCADE, related_name='bilans')
|
||||
@ -130,6 +151,10 @@ class Student(models.Model):
|
||||
# One-to-Many Relationship
|
||||
associated_class = models.ForeignKey('School.SchoolClass', on_delete=models.SET_NULL, null=True, blank=True, related_name='students')
|
||||
|
||||
# Audit fields
|
||||
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
||||
updated_at = models.DateTimeField(auto_now=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.last_name + "_" + self.first_name
|
||||
|
||||
@ -214,9 +239,28 @@ class RegistrationFileGroup(models.Model):
|
||||
def __str__(self):
|
||||
return f'{self.group.name} - {self.id}'
|
||||
|
||||
def registration_file_path(instance, filename):
|
||||
# Génère le chemin : registration_files/dossier_rf_{student_id}/filename
|
||||
return f'registration_files/dossier_rf_{instance.student_id}/{filename}'
|
||||
def registration_form_file_upload_to(instance, filename):
|
||||
"""
|
||||
Génère le chemin de stockage pour les fichiers du dossier d'inscription.
|
||||
Structure : Etablissement/dossier_NomEleve_PrenomEleve/filename
|
||||
"""
|
||||
est_name = instance.establishment.name if instance.establishment else "unknown_establishment"
|
||||
student_last = instance.student.last_name if instance.student else "unknown"
|
||||
student_first = instance.student.first_name if instance.student else "unknown"
|
||||
return f"{est_name}/dossier_{student_last}_{student_first}/{filename}"
|
||||
|
||||
def _delete_file_if_exists(file_field):
|
||||
"""
|
||||
Supprime le fichier physique s'il existe.
|
||||
Utile pour éviter les suffixes automatiques Django lors du remplacement.
|
||||
"""
|
||||
if file_field and file_field.name:
|
||||
try:
|
||||
if hasattr(file_field, 'path') and os.path.exists(file_field.path):
|
||||
os.remove(file_field.path)
|
||||
logger.debug(f"Fichier supprimé: {file_field.path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la suppression du fichier {file_field.name}: {e}")
|
||||
|
||||
class RegistrationForm(models.Model):
|
||||
class RegistrationFormStatus(models.IntegerChoices):
|
||||
@ -233,22 +277,23 @@ class RegistrationForm(models.Model):
|
||||
# One-to-One Relationship
|
||||
student = models.OneToOneField(Student, on_delete=models.CASCADE, primary_key=True)
|
||||
status = models.IntegerField(choices=RegistrationFormStatus, default=RegistrationFormStatus.RF_IDLE)
|
||||
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
||||
last_update = models.DateTimeField(auto_now=True)
|
||||
school_year = models.CharField(max_length=9, default="", blank=True)
|
||||
notes = models.CharField(max_length=200, blank=True)
|
||||
registration_link_code = models.CharField(max_length=200, default="", blank=True)
|
||||
registration_file = models.FileField(
|
||||
upload_to=registration_file_path,
|
||||
upload_to=registration_form_file_upload_to,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
sepa_file = models.FileField(
|
||||
upload_to=registration_file_path,
|
||||
upload_to=registration_form_file_upload_to,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
fusion_file = models.FileField(
|
||||
upload_to=registration_file_path,
|
||||
upload_to=registration_form_file_upload_to,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
@ -285,13 +330,23 @@ class RegistrationForm(models.Model):
|
||||
except RegistrationForm.DoesNotExist:
|
||||
old_fileGroup = None
|
||||
|
||||
# Vérifier si un fichier existant doit être remplacé
|
||||
# Supprimer les anciens fichiers si remplacés (évite les suffixes Django)
|
||||
if self.pk: # Si l'objet existe déjà dans la base de données
|
||||
try:
|
||||
old_instance = RegistrationForm.objects.get(pk=self.pk)
|
||||
|
||||
# Gestion du sepa_file
|
||||
if old_instance.sepa_file and old_instance.sepa_file != self.sepa_file:
|
||||
# Supprimer l'ancien fichier
|
||||
old_instance.sepa_file.delete(save=False)
|
||||
_delete_file_if_exists(old_instance.sepa_file)
|
||||
|
||||
# Gestion du registration_file
|
||||
if old_instance.registration_file and old_instance.registration_file != self.registration_file:
|
||||
_delete_file_if_exists(old_instance.registration_file)
|
||||
|
||||
# Gestion du fusion_file
|
||||
if old_instance.fusion_file and old_instance.fusion_file != self.fusion_file:
|
||||
_delete_file_if_exists(old_instance.fusion_file)
|
||||
|
||||
except RegistrationForm.DoesNotExist:
|
||||
pass # L'objet n'existe pas encore, rien à supprimer
|
||||
|
||||
@ -369,7 +424,9 @@ class RegistrationSchoolFileMaster(models.Model):
|
||||
and isinstance(self.formMasterData, dict)
|
||||
and self.formMasterData.get("fields")
|
||||
):
|
||||
new_filename = f"{self.name}.pdf"
|
||||
# Si un fichier source est déjà présent, conserver son extension.
|
||||
extension = os.path.splitext(old_filename)[1] or '.pdf'
|
||||
new_filename = f"{self.name}{extension}"
|
||||
else:
|
||||
# Pour les forms existants, le nom attendu est self.name + extension du fichier existant
|
||||
extension = os.path.splitext(old_filename)[1]
|
||||
@ -404,16 +461,9 @@ class RegistrationSchoolFileMaster(models.Model):
|
||||
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)
|
||||
# IMPORTANT: pour les formulaires dynamiques, le fichier du master doit
|
||||
# rester le document source uploadé (PDF/image). La génération du PDF final
|
||||
# est faite au niveau des templates (par élève), pas sur le master.
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@ -485,10 +535,18 @@ class RegistrationParentFileMaster(models.Model):
|
||||
############################################################
|
||||
|
||||
def registration_school_file_upload_to(instance, filename):
|
||||
"""
|
||||
Génère le chemin pour les fichiers templates école.
|
||||
Structure : Etablissement/dossier_NomEleve_PrenomEleve/filename
|
||||
"""
|
||||
return f"{instance.registration_form.establishment.name}/dossier_{instance.registration_form.student.last_name}_{instance.registration_form.student.first_name}/{filename}"
|
||||
|
||||
def registration_parent_file_upload_to(instance, filename):
|
||||
return f"registration_files/{instance.registration_form.establishment.name}/dossier_rf_{instance.registration_form.pk}/parent/{filename}"
|
||||
"""
|
||||
Génère le chemin pour les fichiers à fournir par les parents.
|
||||
Structure : Etablissement/dossier_NomEleve_PrenomEleve/parent/filename
|
||||
"""
|
||||
return f"{instance.registration_form.establishment.name}/dossier_{instance.registration_form.student.last_name}_{instance.registration_form.student.first_name}/parent/{filename}"
|
||||
|
||||
####### Formulaires templates (par dossier d'inscription) #######
|
||||
class RegistrationSchoolFileTemplate(models.Model):
|
||||
@ -498,19 +556,37 @@ class RegistrationSchoolFileTemplate(models.Model):
|
||||
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
|
||||
file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to)
|
||||
formTemplateData = models.JSONField(default=list, blank=True, null=True)
|
||||
# Tri-etat: None=en attente, True=valide, False=refuse
|
||||
isValidated = models.BooleanField(null=True, blank=True, default=None)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Supprimer l'ancien fichier si remplacé (évite les suffixes Django)
|
||||
if self.pk:
|
||||
try:
|
||||
old_instance = RegistrationSchoolFileTemplate.objects.get(pk=self.pk)
|
||||
if old_instance.file and old_instance.file != self.file:
|
||||
_delete_file_if_exists(old_instance.file)
|
||||
except RegistrationSchoolFileTemplate.DoesNotExist:
|
||||
pass
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def get_files_from_rf(register_form_id):
|
||||
"""
|
||||
Récupère tous les fichiers liés à un dossier d’inscription donné.
|
||||
Ignore les fichiers qui n'existent pas physiquement.
|
||||
"""
|
||||
registration_files = RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form_id)
|
||||
filenames = []
|
||||
for reg_file in registration_files:
|
||||
if reg_file.file and hasattr(reg_file.file, 'path'):
|
||||
if os.path.exists(reg_file.file.path):
|
||||
filenames.append(reg_file.file.path)
|
||||
else:
|
||||
logger.warning(f"Fichier introuvable ignoré: {reg_file.file.path}")
|
||||
return filenames
|
||||
|
||||
class StudentCompetency(models.Model):
|
||||
@ -524,6 +600,8 @@ class StudentCompetency(models.Model):
|
||||
default="",
|
||||
blank=True
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
||||
updated_at = models.DateTimeField(auto_now=True, null=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('student', 'establishment_competency', 'period')
|
||||
@ -535,27 +613,51 @@ class StudentCompetency(models.Model):
|
||||
def __str__(self):
|
||||
return f"{self.student} - {self.establishment_competency} - Score: {self.score} - Period: {self.period}"
|
||||
|
||||
|
||||
class StudentEvaluation(models.Model):
|
||||
"""
|
||||
Note d'un élève pour une évaluation.
|
||||
Déplacé depuis School pour éviter les dépendances circulaires.
|
||||
"""
|
||||
student = models.ForeignKey('Subscriptions.Student', on_delete=models.CASCADE, related_name='evaluation_scores')
|
||||
evaluation = models.ForeignKey('School.Evaluation', on_delete=models.CASCADE, related_name='student_scores')
|
||||
score = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True)
|
||||
comment = models.TextField(blank=True)
|
||||
is_absent = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('student', 'evaluation')
|
||||
|
||||
def __str__(self):
|
||||
score_display = 'Absent' if self.is_absent else self.score
|
||||
return f"{self.student} - {self.evaluation.name}: {score_display}"
|
||||
|
||||
####### Parent files templates (par dossier d'inscription) #######
|
||||
class RegistrationParentFileTemplate(models.Model):
|
||||
master = models.ForeignKey(RegistrationParentFileMaster, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
|
||||
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
|
||||
file = models.FileField(null=True,blank=True, upload_to=registration_parent_file_upload_to)
|
||||
# Tri-etat: None=en attente, True=valide, False=refuse
|
||||
isValidated = models.BooleanField(null=True, blank=True, default=None)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
return self.master.name if self.master else f"ParentFile_{self.pk}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.pk: # Si l'objet existe déjà dans la base de données
|
||||
# Supprimer l'ancien fichier si remplacé (évite les suffixes Django)
|
||||
if self.pk:
|
||||
try:
|
||||
old_instance = RegistrationParentFileTemplate.objects.get(pk=self.pk)
|
||||
if old_instance.file and (not self.file or self.file.name == ''):
|
||||
if os.path.exists(old_instance.file.path):
|
||||
old_instance.file.delete(save=False)
|
||||
# Si le fichier change ou est supprimé
|
||||
if old_instance.file:
|
||||
if old_instance.file != self.file or not self.file or self.file.name == '':
|
||||
_delete_file_if_exists(old_instance.file)
|
||||
if not self.file or self.file.name == '':
|
||||
self.file = None
|
||||
else:
|
||||
print(f"Le fichier {old_instance.file.path} n'existe pas.")
|
||||
except RegistrationParentFileTemplate.DoesNotExist:
|
||||
print("Ancienne instance introuvable.")
|
||||
pass
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -38,10 +38,15 @@ class AbsenceManagementSerializer(serializers.ModelSerializer):
|
||||
|
||||
class RegistrationSchoolFileMasterSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
file_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RegistrationSchoolFileMaster
|
||||
fields = '__all__'
|
||||
|
||||
def get_file_url(self, obj):
|
||||
return obj.file.url if obj.file else None
|
||||
|
||||
class RegistrationParentFileMasterSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
class Meta:
|
||||
@ -51,6 +56,7 @@ class RegistrationParentFileMasterSerializer(serializers.ModelSerializer):
|
||||
class RegistrationSchoolFileTemplateSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
file_url = serializers.SerializerMethodField()
|
||||
master_file_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = RegistrationSchoolFileTemplate
|
||||
@ -60,6 +66,12 @@ class RegistrationSchoolFileTemplateSerializer(serializers.ModelSerializer):
|
||||
# Retourne l'URL complète du fichier si disponible
|
||||
return obj.file.url if obj.file else None
|
||||
|
||||
def get_master_file_url(self, obj):
|
||||
# URL du fichier source du master (pour l'aperçu FileUpload côté parent)
|
||||
if obj.master and obj.master.file:
|
||||
return obj.master.file.url
|
||||
return None
|
||||
|
||||
class RegistrationParentFileTemplateSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
file_url = serializers.SerializerMethodField()
|
||||
@ -390,10 +402,11 @@ class RegistrationFormSerializer(serializers.ModelSerializer):
|
||||
class StudentByParentSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
associated_class_name = serializers.SerializerMethodField()
|
||||
associated_class_id = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Student
|
||||
fields = ['id', 'last_name', 'first_name', 'level', 'photo', 'associated_class_name']
|
||||
fields = ['id', 'last_name', 'first_name', 'level', 'photo', 'associated_class_name', 'associated_class_id']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(StudentByParentSerializer, self).__init__(*args, **kwargs)
|
||||
@ -403,6 +416,9 @@ class StudentByParentSerializer(serializers.ModelSerializer):
|
||||
def get_associated_class_name(self, obj):
|
||||
return obj.associated_class.atmosphere_name if obj.associated_class else None
|
||||
|
||||
def get_associated_class_id(self, obj):
|
||||
return obj.associated_class.id if obj.associated_class else None
|
||||
|
||||
class RegistrationFormByParentSerializer(serializers.ModelSerializer):
|
||||
student = StudentByParentSerializer(many=False, required=True)
|
||||
|
||||
@ -443,11 +459,12 @@ class StudentByRFCreationSerializer(serializers.ModelSerializer):
|
||||
id = serializers.IntegerField(required=False)
|
||||
guardians = GuardianByDICreationSerializer(many=True, required=False)
|
||||
associated_class_name = serializers.SerializerMethodField()
|
||||
associated_class_id = serializers.SerializerMethodField()
|
||||
bilans = BilanCompetenceSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Student
|
||||
fields = ['id', 'last_name', 'first_name', 'guardians', 'level', 'associated_class_name', 'photo', 'bilans']
|
||||
fields = ['id', 'last_name', 'first_name', 'guardians', 'level', 'associated_class_name', 'associated_class_id', 'photo', 'bilans']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(StudentByRFCreationSerializer, self).__init__(*args, **kwargs)
|
||||
@ -457,6 +474,9 @@ class StudentByRFCreationSerializer(serializers.ModelSerializer):
|
||||
def get_associated_class_name(self, obj):
|
||||
return obj.associated_class.atmosphere_name if obj.associated_class else None
|
||||
|
||||
def get_associated_class_id(self, obj):
|
||||
return obj.associated_class.id if obj.associated_class else None
|
||||
|
||||
class NotificationSerializer(serializers.ModelSerializer):
|
||||
notification_type_label = serializers.ReadOnlyField()
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -4,9 +4,22 @@
|
||||
<meta charset="UTF-8">
|
||||
<title>Bilan de compétences</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 2em; }
|
||||
body { font-family: Arial, sans-serif; margin: 1.2em; color: #111827; }
|
||||
h1, h2 { color: #059669; }
|
||||
.student-info { margin-bottom: 2em; }
|
||||
.top-header { width: 100%; border-bottom: 2px solid #d1fae5; border-collapse: collapse; margin-bottom: 14px; }
|
||||
.top-header td { vertical-align: top; border: none; padding: 0; }
|
||||
.school-logo { width: 54px; height: 54px; object-fit: contain; margin-right: 8px; }
|
||||
.product-logo { width: 58px; }
|
||||
.title-row { margin: 8px 0 10px 0; }
|
||||
.student-info {
|
||||
margin-bottom: 1em;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.student-info table { width: 100%; border-collapse: collapse; font-size: 0.98em; }
|
||||
.student-info td { border: none; padding: 1px 0; }
|
||||
.domain-table { width: 100%; border-collapse: collapse; margin-bottom: 2em; }
|
||||
.domain-header th {
|
||||
background: #d1fae5;
|
||||
@ -25,16 +38,77 @@
|
||||
th, td { border: 1px solid #e5e7eb; padding: 0.5em; }
|
||||
th.competence-header { background: #d1fae5; }
|
||||
td.competence-nom { word-break: break-word; max-width: 320px; }
|
||||
.footer-note { margin-top: 32px; }
|
||||
.comment-space {
|
||||
min-height: 180px;
|
||||
margin-top: 18px;
|
||||
margin-bottom: 78px;
|
||||
}
|
||||
.footer-grid { width: 100%; border-collapse: collapse; }
|
||||
.footer-grid td {
|
||||
border: none;
|
||||
padding: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.field-line { border-bottom: 1px solid #9ca3af; height: 24px; margin-top: 6px; }
|
||||
.signature-line { border-bottom: 2px solid #059669; height: 30px; margin-top: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table class="top-header">
|
||||
<tr>
|
||||
<td style="width: 68%; padding-bottom: 8px;">
|
||||
<table style="border-collapse: collapse; width: 100%;">
|
||||
<tr>
|
||||
{% if establishment.logo_path %}
|
||||
<td style="width: 64px; border: none; vertical-align: top;">
|
||||
<img src="{{ establishment.logo_path }}" alt="Logo établissement" class="school-logo">
|
||||
</td>
|
||||
{% endif %}
|
||||
<td style="border: none; vertical-align: top;">
|
||||
<div style="font-size: 1.25em; font-weight: 700; color: #065f46; margin-top: 2px;">{{ establishment.name }}</div>
|
||||
{% if establishment.address %}
|
||||
<div style="font-size: 0.9em; color: #4b5563; margin-top: 4px;">{{ establishment.address }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td style="width: 32%; text-align: right; padding-bottom: 8px;">
|
||||
<table style="border-collapse: collapse; width: 100%; margin-left: auto;">
|
||||
<tr>
|
||||
<td style="border: none; text-align: right;">
|
||||
<div style="font-size: 0.86em; color: #6b7280; margin-bottom: 4px;">Généré avec</div>
|
||||
{% if product.logo_path %}
|
||||
<div style="margin-bottom: 4px;"><img src="{{ product.logo_path }}" alt="Logo n3wt" class="product-logo"></div>
|
||||
{% endif %}
|
||||
<div style="font-size: 0.95em; font-weight: 700; color: #059669;">{{ product.name }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="title-row">
|
||||
<h1>Bilan de compétences</h1>
|
||||
</div>
|
||||
|
||||
<div class="student-info">
|
||||
<strong>Élève :</strong> {{ student.last_name }} {{ student.first_name }}<br>
|
||||
<strong>Niveau :</strong> {{ student.level }}<br>
|
||||
<strong>Classe :</strong> {{ student.class_name }}<br>
|
||||
<strong>Période :</strong> {{ period }}<br>
|
||||
<strong>Date :</strong> {{ date }}
|
||||
<table>
|
||||
<tr>
|
||||
<td><strong>Élève :</strong> {{ student.last_name }} {{ student.first_name }}</td>
|
||||
<td style="text-align: right;"><strong>Date :</strong> {{ date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Niveau :</strong> {{ student.level }}</td>
|
||||
<td style="text-align: right;"><strong>Période :</strong> {{ period }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Classe :</strong> {{ student.class_name }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% for domaine in domaines %}
|
||||
@ -72,41 +146,33 @@
|
||||
</table>
|
||||
{% endfor %}
|
||||
|
||||
<div style="margin-top: 60px; padding: 0; max-width: 700px;">
|
||||
<div style="
|
||||
min-height: 180px;
|
||||
background: #fff;
|
||||
border: 1.5px dashed #a7f3d0;
|
||||
border-radius: 12px;
|
||||
padding: 24px 24px 18px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
position: relative;
|
||||
margin-bottom: 64px; /* Augmente l'espace après l'encadré */
|
||||
">
|
||||
<div style="font-weight: bold; color: #059669; font-size: 1.25em; display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<span>Appréciation générale / Commentaire : </span>
|
||||
</div>
|
||||
<!-- Espace vide pour écrire -->
|
||||
<div style="flex:1;"></div>
|
||||
<div style="flex:1;"></div>
|
||||
<div style="flex:1;"></div>
|
||||
<div style="flex:1;"></div>
|
||||
<div style="flex:1;"></div>
|
||||
<div style="flex:1;"></div>
|
||||
<div style="flex:1;"></div>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 48px; margin-top: 32px;">
|
||||
<div>
|
||||
<span style="font-weight: bold; color: #059669;font-size: 1.25em;">Date :</span>
|
||||
<span style="display: inline-block; min-width: 120px; border-bottom: 1.5px solid #a7f3d0; margin-left: 8px;"> </span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-weight: bold; color: #059669;font-size: 1.25em;">Signature :</span>
|
||||
<span style="display: inline-block; min-width: 180px; border-bottom: 2px solid #059669; margin-left: 8px;"> </span>
|
||||
</div>
|
||||
<div class="footer-note">
|
||||
<div style="font-weight: 700; color: #059669; font-size: 1.1em;">
|
||||
Appréciation générale / Commentaire
|
||||
</div>
|
||||
<div class="comment-space"></div>
|
||||
|
||||
<table class="footer-grid">
|
||||
<tr>
|
||||
<td style="width: 45%;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="width: 70px; border: none; font-weight: 700; color: #059669;">Date :</td>
|
||||
<td style="border: none;"><div class="field-line"></div></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td style="width: 10%;"></td>
|
||||
<td style="width: 45%;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="width: 90px; border: none; font-weight: 700; color: #059669;">Signature :</td>
|
||||
<td style="border: none;"><div class="signature-line"></div></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,228 +1,319 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Fiche élève de {{ student.last_name }} {{ student.first_name }}</title>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>
|
||||
Fiche élève — {{ student.last_name }} {{ student.first_name }}
|
||||
</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 2cm;
|
||||
margin: 1.5cm 2cm;
|
||||
}
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
font-size: 12pt;
|
||||
color: #222;
|
||||
font-family: "Helvetica", "Arial", sans-serif;
|
||||
font-size: 10pt;
|
||||
color: #1e293b;
|
||||
background: #fff;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.container {
|
||||
|
||||
/* ── Header ── */
|
||||
.header-table {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
border: none;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
padding-bottom: 12px;
|
||||
position: relative;
|
||||
.header-table td {
|
||||
border: none;
|
||||
padding: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.header-left {
|
||||
width: 80%;
|
||||
}
|
||||
.header-right {
|
||||
width: 20%;
|
||||
text-align: right;
|
||||
}
|
||||
.school-name {
|
||||
font-size: 10pt;
|
||||
color: #64748b;
|
||||
margin: 0 0 4px 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.title {
|
||||
font-size: 22pt;
|
||||
font-size: 20pt;
|
||||
font-weight: bold;
|
||||
color: #4CAF50;
|
||||
color: #064e3b;
|
||||
margin: 0 0 2px 0;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 11pt;
|
||||
color: #059669;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
.header-line {
|
||||
border: none;
|
||||
border-top: 3px solid #059669;
|
||||
margin: 12px 0 20px 0;
|
||||
}
|
||||
.photo {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #4CAF50;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #059669;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── Sections ── */
|
||||
.section {
|
||||
margin-bottom: 32px; /* Espacement augmenté entre les sections */
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 15pt;
|
||||
.section-header {
|
||||
background-color: #059669;
|
||||
color: #ffffff;
|
||||
font-size: 11pt;
|
||||
font-weight: bold;
|
||||
color: #4CAF50;
|
||||
margin-bottom: 18px; /* Espacement sous le titre de section */
|
||||
border-bottom: 1px solid #4CAF50;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #bbb;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background: #f3f3f3;
|
||||
font-weight: bold;
|
||||
}
|
||||
tr:nth-child(even) {
|
||||
background: #fafafa;
|
||||
}
|
||||
.label-cell {
|
||||
font-weight: bold;
|
||||
width: 30%;
|
||||
background: #f3f3f3;
|
||||
}
|
||||
.value-cell {
|
||||
width: 70%;
|
||||
}
|
||||
.signature {
|
||||
margin-top: 30px;
|
||||
text-align: right;
|
||||
font-style: italic;
|
||||
color: #555;
|
||||
}
|
||||
.signature-text {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
padding: 6px 12px;
|
||||
margin-bottom: 0;
|
||||
letter-spacing: 0.5px;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
.subsection-title {
|
||||
font-size: 12pt;
|
||||
color: #333;
|
||||
margin: 8px 0 4px 0;
|
||||
font-size: 10pt;
|
||||
color: #064e3b;
|
||||
font-weight: bold;
|
||||
padding: 6px 0 2px 0;
|
||||
margin: 8px 0 4px 0;
|
||||
border-bottom: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
/* ── Tables ── */
|
||||
table.data {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
table.data td {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 10pt;
|
||||
vertical-align: top;
|
||||
}
|
||||
table.data .label {
|
||||
font-weight: bold;
|
||||
color: #064e3b;
|
||||
background-color: #f0fdf4;
|
||||
width: 25%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.data .value {
|
||||
color: #1e293b;
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
/* ── Paiement ── */
|
||||
table.payment {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table.payment td {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: 10pt;
|
||||
}
|
||||
table.payment .label {
|
||||
font-weight: bold;
|
||||
color: #064e3b;
|
||||
background-color: #f0fdf4;
|
||||
width: 35%;
|
||||
}
|
||||
table.payment .value {
|
||||
width: 65%;
|
||||
}
|
||||
|
||||
/* ── Footer / Signature ── */
|
||||
.signature-block {
|
||||
margin-top: 24px;
|
||||
padding: 10px 12px;
|
||||
background-color: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.signature-block p {
|
||||
margin: 0;
|
||||
font-size: 10pt;
|
||||
color: #475569;
|
||||
}
|
||||
.signature-date {
|
||||
font-weight: bold;
|
||||
color: #064e3b;
|
||||
}
|
||||
.footer-line {
|
||||
border: none;
|
||||
border-top: 2px solid #059669;
|
||||
margin: 20px 0 8px 0;
|
||||
}
|
||||
.footer-text {
|
||||
text-align: center;
|
||||
font-size: 8pt;
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
</head>
|
||||
<body>
|
||||
{% load myTemplateTag %}
|
||||
<div class="container">
|
||||
<!-- Header Section -->
|
||||
<div class="header">
|
||||
<h1 class="title">Fiche élève de {{ student.last_name }} {{ student.first_name }}</h1>
|
||||
{% if student.photo %}
|
||||
<img src="{{ student.get_photo_url }}" alt="Photo de l'élève" class="photo" />
|
||||
{% else %}
|
||||
<img src="/static/img/default-photo.jpg" alt="Photo par défaut" class="photo" />
|
||||
|
||||
<!-- ═══════ HEADER ═══════ -->
|
||||
<table class="header-table">
|
||||
<tr>
|
||||
<td class="header-left">
|
||||
{% if establishment %}
|
||||
<p class="school-name">{{ establishment.name }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1 class="title">Fiche Élèves</h1>
|
||||
<!-- prettier-ignore -->
|
||||
<p class="subtitle">{{ student.last_name }} {{ student.first_name }}{% if school_year %} — {{ school_year }}{% endif %}</p>
|
||||
</td>
|
||||
<td class="header-right">
|
||||
{% if student.photo %}
|
||||
<img src="{{ student.get_photo_url }}" alt="Photo" class="photo" />
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<hr class="header-line" />
|
||||
|
||||
<!-- Élève -->
|
||||
<!-- ═══════ ÉLÈVE ═══════ -->
|
||||
<div class="section">
|
||||
<div class="section-title">ÉLÈVE</div>
|
||||
<table>
|
||||
<div class="section-header">INFORMATIONS DE L'ÉLÈVE</div>
|
||||
<table class="data">
|
||||
<tr>
|
||||
<td class="label-cell">Nom</td>
|
||||
<td class="value-cell">{{ student.last_name }}</td>
|
||||
<td class="label-cell">Prénom</td>
|
||||
<td class="value-cell">{{ student.first_name }}</td>
|
||||
<td class="label">Nom</td>
|
||||
<td class="value">{{ student.last_name }}</td>
|
||||
<td class="label">Prénom</td>
|
||||
<td class="value">{{ student.first_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Adresse</td>
|
||||
<td class="value-cell" colspan="3">{{ student.address }}</td>
|
||||
<td class="label">Genre</td>
|
||||
<td class="value">{{ student|getStudentGender }}</td>
|
||||
<td class="label">Niveau</td>
|
||||
<td class="value">{{ student|getStudentLevel }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Genre</td>
|
||||
<td class="value-cell">{{ student|getStudentGender }}</td>
|
||||
<td class="label-cell">Né(e) le</td>
|
||||
<td class="value-cell">{{ student.birth_date }}</td>
|
||||
<td class="label">Date de naissance</td>
|
||||
<td class="value">{{ student.formatted_birth_date }}</td>
|
||||
<td class="label">Lieu de naissance</td>
|
||||
<!-- prettier-ignore -->
|
||||
<td class="value">{{ student.birth_place }}{% if student.birth_postal_code %} ({{ student.birth_postal_code }}){% endif %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">À</td>
|
||||
<td class="value-cell">{{ student.birth_place }} ({{ student.birth_postal_code }})</td>
|
||||
<td class="label-cell">Nationalité</td>
|
||||
<td class="value-cell">{{ student.nationality }}</td>
|
||||
<td class="label">Nationalité</td>
|
||||
<td class="value">{{ student.nationality }}</td>
|
||||
<td class="label">Médecin traitant</td>
|
||||
<td class="value">{{ student.attending_physician }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Niveau</td>
|
||||
<td class="value-cell">{{ student|getStudentLevel }}</td>
|
||||
<td class="label-cell"></td>
|
||||
<td class="value-cell"></td>
|
||||
<td class="label">Adresse</td>
|
||||
<td class="value" colspan="3">{{ student.address }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Responsables -->
|
||||
<!-- ═══════ RESPONSABLES ═══════ -->
|
||||
<div class="section">
|
||||
<div class="section-title">RESPONSABLES</div>
|
||||
<div class="section-header">RESPONSABLES LÉGAUX</div>
|
||||
{% for guardian in student.getGuardians %}
|
||||
<div>
|
||||
<div class="subsection-title">Responsable {{ forloop.counter }}</div>
|
||||
<table>
|
||||
<table class="data">
|
||||
<tr>
|
||||
<td class="label-cell">Nom</td>
|
||||
<td class="value-cell">{{ guardian.last_name }}</td>
|
||||
<td class="label-cell">Prénom</td>
|
||||
<td class="value-cell">{{ guardian.first_name }}</td>
|
||||
<td class="label">Nom</td>
|
||||
<td class="value">{{ guardian.last_name }}</td>
|
||||
<td class="label">Prénom</td>
|
||||
<td class="value">{{ guardian.first_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Adresse</td>
|
||||
<td class="value-cell" colspan="3">{{ guardian.address }}</td>
|
||||
<td class="label">Date de naissance</td>
|
||||
<td class="value">{{ guardian.birth_date }}</td>
|
||||
<td class="label">Téléphone</td>
|
||||
<td class="value">{{ guardian.phone|phone_format }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Email</td>
|
||||
<td class="value-cell" colspan="3">{{ guardian.email }}</td>
|
||||
<td class="label">Email</td>
|
||||
<td class="value" colspan="3">{{ guardian.email }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Né(e) le</td>
|
||||
<td class="value-cell">{{ guardian.birth_date }}</td>
|
||||
<td class="label-cell">Téléphone</td>
|
||||
<td class="value-cell">{{ guardian.phone|phone_format }}</td>
|
||||
<td class="label">Adresse</td>
|
||||
<td class="value" colspan="3">{{ guardian.address }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Profession</td>
|
||||
<td class="value-cell" colspan="3">{{ guardian.profession }}</td>
|
||||
<td class="label">Profession</td>
|
||||
<td class="value" colspan="3">{{ guardian.profession }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p style="color: #94a3b8; font-style: italic; padding: 8px">
|
||||
Aucun responsable renseigné.
|
||||
</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Fratrie -->
|
||||
<!-- ═══════ FRATRIE ═══════ -->
|
||||
{% if student.getSiblings %}
|
||||
<div class="section">
|
||||
<div class="section-title">FRATRIE</div>
|
||||
<div class="section-header">FRATRIE</div>
|
||||
{% for sibling in student.getSiblings %}
|
||||
<div>
|
||||
<div class="subsection-title">Frère/Soeur {{ forloop.counter }}</div>
|
||||
<table>
|
||||
<div class="subsection-title">Frère / Sœur {{ forloop.counter }}</div>
|
||||
<table class="data">
|
||||
<tr>
|
||||
<td class="label-cell">Nom</td>
|
||||
<td class="value-cell">{{ sibling.last_name }}</td>
|
||||
<td class="label-cell">Prénom</td>
|
||||
<td class="value-cell">{{ sibling.first_name }}</td>
|
||||
<td class="label">Nom</td>
|
||||
<td class="value">{{ sibling.last_name }}</td>
|
||||
<td class="label">Prénom</td>
|
||||
<td class="value">{{ sibling.first_name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Né(e) le</td>
|
||||
<td class="value-cell" colspan="3">{{ sibling.birth_date }}</td>
|
||||
<td class="label">Date de naissance</td>
|
||||
<td class="value" colspan="3">{{ sibling.birth_date }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Paiement -->
|
||||
<!-- ═══════ PAIEMENT ═══════ -->
|
||||
<div class="section">
|
||||
<div class="section-title">MODALITÉS DE PAIEMENT</div>
|
||||
<table>
|
||||
<div class="section-header">MODALITÉS DE PAIEMENT</div>
|
||||
<table class="payment">
|
||||
<tr>
|
||||
<td class="label-cell">Frais d'inscription</td>
|
||||
<td class="value-cell">{{ student|getRegistrationPaymentMethod }} en {{ student|getRegistrationPaymentPlan }}</td>
|
||||
<td class="label">Frais d'inscription</td>
|
||||
<!-- prettier-ignore -->
|
||||
<td class="value">{{ student|getRegistrationPaymentMethod }} — {{ student|getRegistrationPaymentPlan }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label-cell">Frais de scolarité</td>
|
||||
<td class="value-cell">{{ student|getTuitionPaymentMethod }} en {{ student|getTuitionPaymentPlan }}</td>
|
||||
<td class="label">Frais de scolarité</td>
|
||||
<!-- prettier-ignore -->
|
||||
<td class="value">{{ student|getTuitionPaymentMethod }} — {{ student|getTuitionPaymentPlan }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Signature -->
|
||||
<div class="signature">
|
||||
Fait le <span class="signature-text">{{ signatureDate }}</span> à <span class="signature-text">{{ signatureTime }}</span>
|
||||
<!-- ═══════ SIGNATURE ═══════ -->
|
||||
<div class="signature-block">
|
||||
<p>
|
||||
Document généré le
|
||||
<span class="signature-date">{{ signatureDate }}</span> à
|
||||
<span class="signature-date">{{ signatureTime }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
<hr class="footer-line" />
|
||||
<p class="footer-text">
|
||||
Ce document est généré automatiquement et fait office de fiche
|
||||
d'inscription.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@ -3,7 +3,7 @@ from django.urls import path, re_path
|
||||
from . import views
|
||||
|
||||
# RF
|
||||
from .views import RegisterFormView, RegisterFormWithIdView, send, resend, archive
|
||||
from .views import RegisterFormView, RegisterFormWithIdView, send, resend, archive, generate_registration_pdf
|
||||
# SubClasses
|
||||
from .views import StudentView, GuardianView, ChildrenListView, StudentListView, DissociateGuardianView
|
||||
# Files
|
||||
@ -30,6 +30,7 @@ from .views import (
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'^registerForms/(?P<id>[0-9]+)/pdf$', generate_registration_pdf, name="generate_registration_pdf"),
|
||||
re_path(r'^registerForms/(?P<id>[0-9]+)/archive$', archive, name="archive"),
|
||||
re_path(r'^registerForms/(?P<id>[0-9]+)/resend$', resend, name="resend"),
|
||||
re_path(r'^registerForms/(?P<id>[0-9]+)/send$', send, name="send"),
|
||||
|
||||
@ -8,18 +8,23 @@ from N3wtSchool import renderers
|
||||
from N3wtSchool import bdd
|
||||
|
||||
from io import BytesIO
|
||||
import base64
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.graphics import renderPDF
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files import File
|
||||
from pathlib import Path
|
||||
import os
|
||||
from enum import Enum
|
||||
from urllib.parse import unquote_to_bytes
|
||||
|
||||
import random
|
||||
import string
|
||||
from rest_framework.parsers import JSONParser
|
||||
from PyPDF2 import PdfMerger
|
||||
from PyPDF2 import PdfMerger, PdfReader, PdfWriter
|
||||
from PyPDF2.errors import PdfReadError
|
||||
|
||||
import shutil
|
||||
import logging
|
||||
@ -28,9 +33,138 @@ import json
|
||||
from django.http import QueryDict
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from svglib.svglib import svg2rlg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _draw_signature_data_url(cnv, data_url, x, y, width, height):
|
||||
"""
|
||||
Dessine une signature issue d'un data URL dans un canvas ReportLab.
|
||||
Supporte les images raster (PNG/JPEG/...) et SVG.
|
||||
Retourne True si la signature a pu etre dessinee.
|
||||
"""
|
||||
if not isinstance(data_url, str) or not data_url.startswith("data:image"):
|
||||
return False
|
||||
|
||||
try:
|
||||
header, payload = data_url.split(',', 1)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
is_base64 = ';base64' in header
|
||||
mime_type = header.split(':', 1)[1].split(';', 1)[0] if ':' in header else ''
|
||||
|
||||
try:
|
||||
raw_bytes = base64.b64decode(payload) if is_base64 else unquote_to_bytes(payload)
|
||||
except Exception as e:
|
||||
logger.error(f"[_draw_signature_data_url] Decodage impossible: {e}")
|
||||
return False
|
||||
|
||||
# Support SVG via svglib (deja present dans requirements)
|
||||
if mime_type == 'image/svg+xml':
|
||||
try:
|
||||
drawing = svg2rlg(BytesIO(raw_bytes))
|
||||
if drawing is None:
|
||||
return False
|
||||
|
||||
src_w = float(getattr(drawing, 'width', 0) or 0)
|
||||
src_h = float(getattr(drawing, 'height', 0) or 0)
|
||||
if src_w <= 0 or src_h <= 0:
|
||||
return False
|
||||
|
||||
scale = min(width / src_w, height / src_h)
|
||||
draw_w = src_w * scale
|
||||
draw_h = src_h * scale
|
||||
offset_x = x + (width - draw_w) / 2
|
||||
offset_y = y + (height - draw_h) / 2
|
||||
|
||||
cnv.saveState()
|
||||
cnv.translate(offset_x, offset_y)
|
||||
cnv.scale(scale, scale)
|
||||
renderPDF.draw(drawing, cnv, 0, 0)
|
||||
cnv.restoreState()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[_draw_signature_data_url] Rendu SVG impossible: {e}")
|
||||
return False
|
||||
|
||||
# Support images raster classiques
|
||||
try:
|
||||
img_reader = ImageReader(BytesIO(raw_bytes))
|
||||
cnv.drawImage(
|
||||
img_reader,
|
||||
x,
|
||||
y,
|
||||
width=width,
|
||||
height=height,
|
||||
preserveAspectRatio=True,
|
||||
mask='auto',
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[_draw_signature_data_url] Rendu raster impossible: {e}")
|
||||
return False
|
||||
|
||||
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 save_file_field_without_suffix(instance, field_name, filename, content, save=False):
|
||||
"""
|
||||
Sauvegarde un fichier dans un FileField Django en ecrasant le precedent,
|
||||
sans laisser Django generer de suffixe (_abc123).
|
||||
|
||||
Args:
|
||||
instance: instance Django portant le FileField
|
||||
field_name: nom du FileField (ex: 'file')
|
||||
filename: nom de fichier cible (basename)
|
||||
content: contenu fichier (ContentFile, File, etc.)
|
||||
save: si True, persiste immediatement l'instance
|
||||
"""
|
||||
file_field = getattr(instance, field_name)
|
||||
field = instance._meta.get_field(field_name)
|
||||
storage = file_field.storage
|
||||
|
||||
target_name = field.generate_filename(instance, filename)
|
||||
|
||||
# Supprimer le fichier actuellement reference si different
|
||||
if file_field and file_field.name and file_field.name != target_name:
|
||||
try:
|
||||
if storage.exists(file_field.name):
|
||||
storage.delete(file_field.name)
|
||||
except Exception as e:
|
||||
logger.error(f"[save_file_field_without_suffix] Erreur suppression ancien fichier ({file_field.name}): {e}")
|
||||
|
||||
# Supprimer explicitement la cible si elle existe deja
|
||||
try:
|
||||
if storage.exists(target_name):
|
||||
storage.delete(target_name)
|
||||
except Exception as e:
|
||||
logger.error(f"[save_file_field_without_suffix] Erreur suppression cible ({target_name}): {e}")
|
||||
|
||||
# Sauvegarde: la cible n'existe plus, donc pas de suffixe
|
||||
file_field.save(filename, content, save=save)
|
||||
|
||||
def build_payload_from_request(request):
|
||||
"""
|
||||
Normalise la request en payload prêt à être donné au serializer.
|
||||
@ -170,6 +304,91 @@ def create_templates_for_registration_form(register_form):
|
||||
base_slug = (m.name or "master").strip().replace(" ", "_")[:40]
|
||||
slug = f"{base_slug}_{register_form.pk}_{m.pk}"
|
||||
|
||||
is_dynamic_master = (
|
||||
isinstance(m.formMasterData, dict)
|
||||
and bool(m.formMasterData.get("fields"))
|
||||
)
|
||||
|
||||
# Formulaire dynamique: toujours générer le PDF final depuis le JSON
|
||||
# (aperçu admin) au lieu de copier le fichier source brut (PNG/PDF).
|
||||
if is_dynamic_master:
|
||||
base_pdf_content = None
|
||||
base_file_ext = None
|
||||
if m.file and hasattr(m.file, 'name') and m.file.name:
|
||||
base_file_ext = os.path.splitext(m.file.name)[1].lower()
|
||||
try:
|
||||
m.file.open('rb')
|
||||
base_pdf_content = m.file.read()
|
||||
m.file.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lecture fichier source master dynamique: {e}")
|
||||
|
||||
try:
|
||||
generated_pdf = generate_form_json_pdf(
|
||||
register_form,
|
||||
m.formMasterData,
|
||||
base_pdf_content=base_pdf_content,
|
||||
base_file_ext=base_file_ext,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur génération PDF dynamique pour template: {e}")
|
||||
generated_pdf = None
|
||||
|
||||
if tmpl:
|
||||
try:
|
||||
if tmpl.file and tmpl.file.name:
|
||||
tmpl.file.delete(save=False)
|
||||
except Exception:
|
||||
logger.exception("Erreur suppression ancien fichier template dynamique %s", getattr(tmpl, "pk", None))
|
||||
|
||||
tmpl.name = m.name or ""
|
||||
tmpl.slug = slug
|
||||
tmpl.formTemplateData = m.formMasterData or []
|
||||
if generated_pdf is not None:
|
||||
output_filename = os.path.basename(generated_pdf.name)
|
||||
save_file_field_without_suffix(
|
||||
tmpl,
|
||||
'file',
|
||||
output_filename,
|
||||
generated_pdf,
|
||||
save=False,
|
||||
)
|
||||
tmpl.save()
|
||||
created.append(tmpl)
|
||||
logger.info(
|
||||
"util.create_templates_for_registration_form - Regenerated dynamic school template %s from master %s for RF %s",
|
||||
tmpl.pk,
|
||||
m.pk,
|
||||
register_form.pk,
|
||||
)
|
||||
continue
|
||||
|
||||
tmpl = RegistrationSchoolFileTemplate(
|
||||
master=m,
|
||||
registration_form=register_form,
|
||||
name=m.name or "",
|
||||
formTemplateData=m.formMasterData or [],
|
||||
slug=slug,
|
||||
)
|
||||
if generated_pdf is not None:
|
||||
output_filename = os.path.basename(generated_pdf.name)
|
||||
save_file_field_without_suffix(
|
||||
tmpl,
|
||||
'file',
|
||||
output_filename,
|
||||
generated_pdf,
|
||||
save=False,
|
||||
)
|
||||
tmpl.save()
|
||||
created.append(tmpl)
|
||||
logger.info(
|
||||
"util.create_templates_for_registration_form - Created dynamic school template %s from master %s for RF %s",
|
||||
tmpl.pk,
|
||||
m.pk,
|
||||
register_form.pk,
|
||||
)
|
||||
continue
|
||||
|
||||
file_name = None
|
||||
if m.file and hasattr(m.file, 'name') and m.file.name:
|
||||
file_name = os.path.basename(m.file.name)
|
||||
@ -183,19 +402,21 @@ def create_templates_for_registration_form(register_form):
|
||||
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
|
||||
|
||||
def _build_upload_path(template_pk):
|
||||
"""Génère le chemin relatif et absolu pour un template avec un pk connu."""
|
||||
rel = registration_school_file_upload_to(
|
||||
type("Tmp", (), {
|
||||
"registration_form": register_form,
|
||||
"pk": template_pk,
|
||||
})(),
|
||||
file_name,
|
||||
)
|
||||
return rel, os.path.join(settings.MEDIA_ROOT, rel)
|
||||
|
||||
if tmpl:
|
||||
upload_rel_path, abs_path = _build_upload_path(tmpl.pk)
|
||||
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é ---
|
||||
@ -230,7 +451,7 @@ def create_templates_for_registration_form(register_form):
|
||||
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
|
||||
# Sinon, création du template — sauvegarder d'abord pour obtenir un pk
|
||||
tmpl = RegistrationSchoolFileTemplate(
|
||||
master=m,
|
||||
registration_form=register_form,
|
||||
@ -238,8 +459,10 @@ def create_templates_for_registration_form(register_form):
|
||||
formTemplateData=m.formMasterData or [],
|
||||
slug=slug,
|
||||
)
|
||||
tmpl.save() # pk attribué ici
|
||||
if file_name:
|
||||
# Copier le fichier du master si besoin (form existant)
|
||||
upload_rel_path, abs_path = _build_upload_path(tmpl.pk)
|
||||
# Copier le fichier du master si besoin
|
||||
if master_file_path and not os.path.exists(abs_path):
|
||||
try:
|
||||
import shutil
|
||||
@ -344,12 +567,70 @@ def getArgFromRequest(_argument, _request):
|
||||
def merge_files_pdf(file_paths):
|
||||
"""
|
||||
Fusionne plusieurs fichiers PDF et retourne le contenu fusionné en mémoire.
|
||||
Les fichiers non-PDF (images) sont convertis en PDF avant fusion.
|
||||
Les fichiers invalides sont ignorés avec un log d'erreur.
|
||||
"""
|
||||
merger = PdfMerger()
|
||||
files_added = 0
|
||||
|
||||
# Extensions d'images supportées
|
||||
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif'}
|
||||
|
||||
# Ajouter les fichiers valides au merger
|
||||
for file_path in file_paths:
|
||||
# Vérifier que le fichier existe
|
||||
if not os.path.exists(file_path):
|
||||
logger.warning(f"[merge_files_pdf] Fichier introuvable, ignoré: {file_path}")
|
||||
continue
|
||||
|
||||
file_ext = os.path.splitext(file_path)[1].lower()
|
||||
|
||||
# Si c'est une image, la convertir en PDF
|
||||
if file_ext in image_extensions:
|
||||
try:
|
||||
from PIL import Image
|
||||
from reportlab.lib.utils import ImageReader
|
||||
|
||||
img = Image.open(file_path)
|
||||
img_width, img_height = img.size
|
||||
|
||||
# Créer un PDF en mémoire avec l'image
|
||||
img_pdf = BytesIO()
|
||||
c = canvas.Canvas(img_pdf, pagesize=(img_width, img_height))
|
||||
c.drawImage(file_path, 0, 0, width=img_width, height=img_height)
|
||||
c.save()
|
||||
img_pdf.seek(0)
|
||||
|
||||
merger.append(img_pdf)
|
||||
files_added += 1
|
||||
logger.debug(f"[merge_files_pdf] Image convertie et ajoutée: {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"[merge_files_pdf] Erreur lors de la conversion de l'image {file_path}: {e}")
|
||||
continue
|
||||
|
||||
# Sinon, essayer de l'ajouter comme PDF
|
||||
try:
|
||||
# Valider que c'est un PDF lisible
|
||||
with open(file_path, 'rb') as f:
|
||||
PdfReader(f, strict=False)
|
||||
|
||||
# Si la validation passe, ajouter au merger
|
||||
merger.append(file_path)
|
||||
files_added += 1
|
||||
logger.debug(f"[merge_files_pdf] PDF ajouté: {file_path}")
|
||||
except PdfReadError as e:
|
||||
logger.error(f"[merge_files_pdf] Fichier PDF invalide, ignoré: {file_path} - {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"[merge_files_pdf] Erreur lors de la lecture du fichier {file_path}: {e}")
|
||||
|
||||
if files_added == 0:
|
||||
logger.warning("[merge_files_pdf] Aucun fichier valide à fusionner")
|
||||
# Retourner un PDF vide
|
||||
empty_pdf = BytesIO()
|
||||
c = canvas.Canvas(empty_pdf, pagesize=A4)
|
||||
c.drawString(100, 750, "Aucun document à afficher")
|
||||
c.save()
|
||||
empty_pdf.seek(0)
|
||||
return empty_pdf
|
||||
|
||||
# Sauvegarder le fichier fusionné en mémoire
|
||||
merged_pdf = BytesIO()
|
||||
@ -371,6 +652,8 @@ def rfToPDF(registerForm, filename):
|
||||
'signatureDate': convertToStr(_now(), '%d-%m-%Y'),
|
||||
'signatureTime': convertToStr(_now(), '%H:%M'),
|
||||
'student': registerForm.student,
|
||||
'establishment': registerForm.establishment,
|
||||
'school_year': registerForm.school_year,
|
||||
}
|
||||
|
||||
# Générer le PDF
|
||||
@ -378,25 +661,11 @@ def rfToPDF(registerForm, filename):
|
||||
if not pdf:
|
||||
raise ValueError("Erreur lors de la génération du PDF.")
|
||||
|
||||
# Vérifier si un fichier avec le même nom existe déjà et le supprimer
|
||||
if registerForm.registration_file and registerForm.registration_file.name:
|
||||
# Vérifiez si le chemin est déjà absolu ou relatif
|
||||
if os.path.isabs(registerForm.registration_file.name):
|
||||
existing_file_path = registerForm.registration_file.name
|
||||
else:
|
||||
existing_file_path = os.path.join(settings.MEDIA_ROOT, registerForm.registration_file.name.lstrip('/'))
|
||||
|
||||
# Vérifier si le fichier existe et le supprimer
|
||||
if os.path.exists(existing_file_path):
|
||||
os.remove(existing_file_path)
|
||||
registerForm.registration_file.delete(save=False)
|
||||
else:
|
||||
print(f'File does not exist: {existing_file_path}')
|
||||
|
||||
# Enregistrer directement le fichier dans le champ registration_file
|
||||
# Enregistrer directement le fichier dans le champ registration_file (écrase l'existant)
|
||||
try:
|
||||
registerForm.registration_file.save(
|
||||
os.path.basename(filename), # Utiliser uniquement le nom de fichier
|
||||
save_file_replacing_existing(
|
||||
registerForm.registration_file,
|
||||
os.path.basename(filename),
|
||||
File(BytesIO(pdf.content)),
|
||||
save=True
|
||||
)
|
||||
@ -406,6 +675,24 @@ def rfToPDF(registerForm, filename):
|
||||
|
||||
return registerForm.registration_file
|
||||
|
||||
def generateRegistrationPDF(registerForm):
|
||||
"""
|
||||
Génère le PDF d'un dossier d'inscription à la volée et retourne le contenu binaire.
|
||||
Ne sauvegarde pas le fichier sur disque.
|
||||
"""
|
||||
data = {
|
||||
'pdf_title': f"Dossier d'inscription de {registerForm.student.first_name}",
|
||||
'signatureDate': convertToStr(_now(), '%d-%m-%Y'),
|
||||
'signatureTime': convertToStr(_now(), '%H:%M'),
|
||||
'student': registerForm.student,
|
||||
'establishment': registerForm.establishment,
|
||||
'school_year': registerForm.school_year,
|
||||
}
|
||||
pdf = renderers.render_to_pdf('pdfs/fiche_eleve.html', data)
|
||||
if not pdf:
|
||||
raise ValueError("Erreur lors de la génération du PDF.")
|
||||
return pdf.content
|
||||
|
||||
def delete_registration_files(registerForm):
|
||||
"""
|
||||
Supprime le fichier et le dossier associés à un RegistrationForm.
|
||||
@ -459,50 +746,196 @@ def getHistoricalYears(count=5):
|
||||
|
||||
return historical_years
|
||||
|
||||
def generate_form_json_pdf(register_form, form_json):
|
||||
def generate_form_json_pdf(register_form, form_json, base_pdf_content=None, base_file_ext=None, base_pdf_path=None):
|
||||
"""
|
||||
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.
|
||||
Génère un PDF composite du formulaire dynamique:
|
||||
- le document source uploadé (PDF/image) si présent,
|
||||
- puis un rendu du formulaire (similaire à l'aperçu),
|
||||
- avec overlay de signature(s) sur la dernière page du document source.
|
||||
"""
|
||||
|
||||
# Récupérer le nom du formulaire
|
||||
form_name = (form_json.get("title") or "formulaire").strip().replace(" ", "_")
|
||||
filename = f"{form_name}.pdf"
|
||||
fields = form_json.get("fields", []) if isinstance(form_json, dict) else []
|
||||
|
||||
# Générer le PDF
|
||||
buffer = BytesIO()
|
||||
c = canvas.Canvas(buffer, pagesize=A4)
|
||||
# Compatibilité ascendante : charger depuis un chemin si nécessaire
|
||||
if base_pdf_content is None and base_pdf_path and os.path.exists(base_pdf_path):
|
||||
base_file_ext = os.path.splitext(base_pdf_path)[1].lower()
|
||||
try:
|
||||
with open(base_pdf_path, 'rb') as f:
|
||||
base_pdf_content = f.read()
|
||||
except Exception as e:
|
||||
logger.error(f"[generate_form_json_pdf] Lecture fichier source: {e}")
|
||||
|
||||
writer = PdfWriter()
|
||||
has_source_document = False
|
||||
source_is_image = False
|
||||
source_image_reader = None
|
||||
source_image_size = None
|
||||
|
||||
# 1) Charger le document source (PDF/image) si présent
|
||||
if base_pdf_content:
|
||||
try:
|
||||
image_exts = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp'}
|
||||
ext = (base_file_ext or '').lower()
|
||||
|
||||
if ext in image_exts:
|
||||
# Pour les images, on les rend dans la section [fichier uploade]
|
||||
# au lieu de les ajouter comme page source separee.
|
||||
img_buffer = BytesIO(base_pdf_content)
|
||||
source_image_reader = ImageReader(img_buffer)
|
||||
source_image_size = source_image_reader.getSize()
|
||||
source_is_image = True
|
||||
else:
|
||||
source_reader = PdfReader(BytesIO(base_pdf_content))
|
||||
for page in source_reader.pages:
|
||||
writer.add_page(page)
|
||||
has_source_document = len(source_reader.pages) > 0
|
||||
except Exception as e:
|
||||
logger.error(f"[generate_form_json_pdf] Erreur chargement source: {e}")
|
||||
|
||||
# 2) Overlay des signatures sur la dernière page du document source
|
||||
# Desactive ici pour eviter les doublons: la signature est rendue
|
||||
# dans la section JSON du formulaire (et non plus en overlay source).
|
||||
signatures = []
|
||||
for field in fields:
|
||||
if field.get("type") == "signature":
|
||||
value = field.get("value")
|
||||
if isinstance(value, str) and value.startswith("data:image"):
|
||||
signatures.append(value)
|
||||
|
||||
enable_source_signature_overlay = False
|
||||
if signatures and len(writer.pages) > 0 and enable_source_signature_overlay:
|
||||
try:
|
||||
target_page = writer.pages[len(writer.pages) - 1]
|
||||
page_width = float(target_page.mediabox.width)
|
||||
page_height = float(target_page.mediabox.height)
|
||||
|
||||
packet = BytesIO()
|
||||
c_overlay = canvas.Canvas(packet, pagesize=(page_width, page_height))
|
||||
|
||||
sig_width = 170
|
||||
sig_height = 70
|
||||
margin = 36
|
||||
spacing = 10
|
||||
|
||||
for i, data_url in enumerate(signatures[:3]):
|
||||
try:
|
||||
x = page_width - sig_width - margin
|
||||
y = margin + i * (sig_height + spacing)
|
||||
_draw_signature_data_url(c_overlay, data_url, x, y, sig_width, sig_height)
|
||||
except Exception as e:
|
||||
logger.error(f"[generate_form_json_pdf] Signature ignorée: {e}")
|
||||
|
||||
c_overlay.save()
|
||||
packet.seek(0)
|
||||
overlay_pdf = PdfReader(packet)
|
||||
if overlay_pdf.pages:
|
||||
target_page.merge_page(overlay_pdf.pages[0])
|
||||
except Exception as e:
|
||||
logger.error(f"[generate_form_json_pdf] Erreur overlay signature: {e}")
|
||||
|
||||
# 3) Rendu JSON explicite du formulaire final (toujours genere).
|
||||
# Cela garantit la presence des sections H1 / FileUpload / Signature
|
||||
# dans le PDF final, meme si un document source est fourni.
|
||||
fields_to_render = fields
|
||||
|
||||
if fields_to_render:
|
||||
layout_buffer = BytesIO()
|
||||
c = canvas.Canvas(layout_buffer, pagesize=A4)
|
||||
y = 800
|
||||
|
||||
# Titre
|
||||
c.setFont("Helvetica-Bold", 20)
|
||||
c.drawString(100, y, form_json.get("title", "Formulaire"))
|
||||
y -= 40
|
||||
c.setFont("Helvetica-Bold", 18)
|
||||
c.drawString(60, y, form_json.get("title", "Formulaire"))
|
||||
y -= 35
|
||||
|
||||
# Champs
|
||||
c.setFont("Helvetica", 12)
|
||||
fields = form_json.get("fields", [])
|
||||
for field in fields:
|
||||
label = field.get("label", field.get("id", ""))
|
||||
c.setFont("Helvetica", 11)
|
||||
for field in fields_to_render:
|
||||
ftype = field.get("type", "")
|
||||
c.drawString(100, y, f"{label} [{ftype}]")
|
||||
y -= 25
|
||||
if y < 100:
|
||||
if ftype in {"heading1", "heading2", "heading3", "heading4", "heading5", "heading6", "paragraph"}:
|
||||
text = field.get("text", "")
|
||||
if text:
|
||||
c.setFont("Helvetica-Bold" if ftype.startswith("heading") else "Helvetica", 11)
|
||||
c.drawString(60, y, text[:120])
|
||||
y -= 18
|
||||
c.setFont("Helvetica", 11)
|
||||
continue
|
||||
|
||||
label = field.get("label", field.get("id", "Champ"))
|
||||
value = field.get("value", "")
|
||||
|
||||
if ftype == "file":
|
||||
c.drawString(60, y, f"{label}")
|
||||
y -= 18
|
||||
|
||||
if source_is_image and source_image_reader and source_image_size:
|
||||
img_w, img_h = source_image_size
|
||||
max_w = 420
|
||||
max_h = 260
|
||||
ratio = min(max_w / img_w, max_h / img_h)
|
||||
draw_w = img_w * ratio
|
||||
draw_h = img_h * ratio
|
||||
|
||||
if y - draw_h < 80:
|
||||
c.showPage()
|
||||
y = 800
|
||||
c.setFont("Helvetica", 11)
|
||||
|
||||
c.drawImage(
|
||||
source_image_reader,
|
||||
60,
|
||||
y - draw_h,
|
||||
width=draw_w,
|
||||
height=draw_h,
|
||||
preserveAspectRatio=True,
|
||||
mask='auto',
|
||||
)
|
||||
y -= draw_h + 14
|
||||
elif ftype == "signature":
|
||||
c.drawString(60, y, f"{label}")
|
||||
sig_drawn = False
|
||||
if isinstance(value, str) and value.startswith("data:image"):
|
||||
try:
|
||||
sig_drawn = _draw_signature_data_url(c, value, 260, y - 55, 170, 55)
|
||||
except Exception as e:
|
||||
logger.error(f"[generate_form_json_pdf] Signature render echoue: {e}")
|
||||
if not sig_drawn:
|
||||
c.rect(260, y - 55, 170, 55)
|
||||
y -= 70
|
||||
else:
|
||||
if value not in (None, ""):
|
||||
c.drawString(60, y, f"{label} [{ftype}] : {str(value)[:120]}")
|
||||
else:
|
||||
c.drawString(60, y, f"{label} [{ftype}]")
|
||||
y -= 18
|
||||
|
||||
if y < 80:
|
||||
c.showPage()
|
||||
y = 800
|
||||
c.setFont("Helvetica", 11)
|
||||
|
||||
c.save()
|
||||
buffer.seek(0)
|
||||
pdf_content = buffer.read()
|
||||
layout_buffer.seek(0)
|
||||
try:
|
||||
layout_reader = PdfReader(layout_buffer)
|
||||
for page in layout_reader.pages:
|
||||
writer.add_page(page)
|
||||
except Exception as e:
|
||||
logger.error(f"[generate_form_json_pdf] Erreur ajout rendu formulaire: {e}")
|
||||
|
||||
# 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)
|
||||
# 4) Fallback minimal si aucune page n'a été créée
|
||||
if len(writer.pages) == 0:
|
||||
fallback = BytesIO()
|
||||
c_fb = canvas.Canvas(fallback, pagesize=A4)
|
||||
c_fb.setFont("Helvetica-Bold", 16)
|
||||
c_fb.drawString(60, 800, form_json.get("title", "Formulaire"))
|
||||
c_fb.save()
|
||||
fallback.seek(0)
|
||||
fallback_reader = PdfReader(fallback)
|
||||
for page in fallback_reader.pages:
|
||||
writer.add_page(page)
|
||||
|
||||
# Retourner le ContentFile avec uniquement le nom du fichier
|
||||
return ContentFile(pdf_content, name=os.path.basename(filename))
|
||||
out = BytesIO()
|
||||
writer.write(out)
|
||||
out.seek(0)
|
||||
return ContentFile(out.read(), name=os.path.basename(filename))
|
||||
|
||||
@ -5,7 +5,8 @@ from .register_form_views import (
|
||||
resend,
|
||||
archive,
|
||||
get_school_file_templates_by_rf,
|
||||
get_parent_file_templates_by_rf
|
||||
get_parent_file_templates_by_rf,
|
||||
generate_registration_pdf
|
||||
)
|
||||
from .registration_school_file_masters_views import (
|
||||
RegistrationSchoolFileMasterView,
|
||||
@ -48,6 +49,7 @@ __all__ = [
|
||||
'get_registration_files_by_group',
|
||||
'get_school_file_templates_by_rf',
|
||||
'get_parent_file_templates_by_rf',
|
||||
'generate_registration_pdf',
|
||||
'StudentView',
|
||||
'StudentListView',
|
||||
'ChildrenListView',
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from django.http.response import JsonResponse
|
||||
from django.http import HttpResponse
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
|
||||
from django.utils.decorators import method_decorator
|
||||
from rest_framework.views import APIView
|
||||
@ -9,6 +10,7 @@ from drf_yasg import openapi
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from django.core.files import File
|
||||
|
||||
import N3wtSchool.mailManager as mailer
|
||||
@ -323,6 +325,27 @@ class RegisterFormWithIdView(APIView):
|
||||
registerForm.registration_file = util.rfToPDF(registerForm, initial_pdf)
|
||||
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
|
||||
# Vérification de la présence du fichier SEPA
|
||||
if registerForm.sepa_file:
|
||||
@ -332,9 +355,32 @@ class RegisterFormWithIdView(APIView):
|
||||
# Mise à jour de l'automate pour une signature classique
|
||||
updateStateMachine(registerForm, 'EVENT_SIGNATURE')
|
||||
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)
|
||||
elif _status == RegistrationForm.RegistrationFormStatus.RF_SENT:
|
||||
if registerForm.status == RegistrationForm.RegistrationFormStatus.RF_UNDER_REVIEW:
|
||||
# Envoi de l'email de refus aux responsables légaux
|
||||
try:
|
||||
student = registerForm.student
|
||||
student_name = f"{student.first_name} {student.last_name}"
|
||||
notes = registerForm.notes or "Aucun motif spécifié"
|
||||
|
||||
guardians = student.guardians.all()
|
||||
for guardian in guardians:
|
||||
email = None
|
||||
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
|
||||
email = guardian.profile_role.profile.email
|
||||
if not email:
|
||||
email = getattr(guardian, "email", None)
|
||||
|
||||
if email:
|
||||
logger.info(f"[RF_SENT] Envoi email de refus à {email} pour l'élève {student_name}")
|
||||
mailer.sendRefusDossier(email, registerForm.establishment.pk, student_name, notes)
|
||||
except Exception as e:
|
||||
logger.error(f"[RF_SENT] Erreur lors de l'envoi de l'email de refus: {e}")
|
||||
|
||||
updateStateMachine(registerForm, 'EVENT_REFUSE')
|
||||
util.delete_registration_files(registerForm)
|
||||
elif _status == RegistrationForm.RegistrationFormStatus.RF_SEPA_SENT:
|
||||
@ -366,6 +412,17 @@ class RegisterFormWithIdView(APIView):
|
||||
# Initialisation de la liste des fichiers à fusionner
|
||||
fileNames = []
|
||||
|
||||
# Régénérer la fiche élève avec le nouveau template avant fusion
|
||||
try:
|
||||
base_dir = os.path.join(settings.MEDIA_ROOT, f"registration_files/dossier_rf_{registerForm.pk}")
|
||||
os.makedirs(base_dir, exist_ok=True)
|
||||
initial_pdf = f"{base_dir}/Inscription_{registerForm.student.last_name}_{registerForm.student.first_name}.pdf"
|
||||
registerForm.registration_file = util.rfToPDF(registerForm, initial_pdf)
|
||||
registerForm.save()
|
||||
logger.debug(f"[RF_VALIDATED] Fiche élève régénérée avant fusion")
|
||||
except Exception as e:
|
||||
logger.error(f"[RF_VALIDATED] Erreur lors de la régénération de la fiche élève: {e}")
|
||||
|
||||
# Ajout du fichier registration_file en première position
|
||||
if registerForm.registration_file:
|
||||
fileNames.append(registerForm.registration_file.path)
|
||||
@ -377,14 +434,23 @@ class RegisterFormWithIdView(APIView):
|
||||
fileNames.extend(parent_file_templates)
|
||||
|
||||
# Création du fichier PDF fusionné
|
||||
merged_pdf_content = None
|
||||
try:
|
||||
merged_pdf_content = util.merge_files_pdf(fileNames)
|
||||
|
||||
# Mise à jour du champ registration_file avec le fichier fusionné
|
||||
registerForm.fusion_file.save(
|
||||
f"dossier_complet.pdf",
|
||||
# Mise à jour du champ fusion_file avec le fichier fusionné
|
||||
util.save_file_replacing_existing(
|
||||
registerForm.fusion_file,
|
||||
"dossier_complet.pdf",
|
||||
File(merged_pdf_content),
|
||||
save=True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[RF_VALIDATED] Erreur lors de la fusion des fichiers: {e}")
|
||||
finally:
|
||||
# Libérer explicitement la mémoire du BytesIO
|
||||
if merged_pdf_content is not None:
|
||||
merged_pdf_content.close()
|
||||
# Valorisation des StudentCompetency pour l'élève
|
||||
try:
|
||||
student = registerForm.student
|
||||
@ -426,8 +492,65 @@ class RegisterFormWithIdView(APIView):
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la valorisation des StudentCompetency: {e}")
|
||||
|
||||
# Envoi de l'email de validation aux responsables légaux (en arrière-plan)
|
||||
def send_validation_emails():
|
||||
try:
|
||||
student = registerForm.student
|
||||
student_name = f"{student.first_name} {student.last_name}"
|
||||
class_name = None
|
||||
if student.associated_class:
|
||||
class_name = student.associated_class.atmosphere_name
|
||||
|
||||
guardians = student.guardians.all()
|
||||
for guardian in guardians:
|
||||
email = None
|
||||
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
|
||||
email = guardian.profile_role.profile.email
|
||||
if not email:
|
||||
email = getattr(guardian, "email", None)
|
||||
|
||||
if email:
|
||||
logger.info(f"[RF_VALIDATED] Envoi email de validation à {email} pour l'élève {student_name}")
|
||||
mailer.sendValidationDossier(email, registerForm.establishment.pk, student_name, class_name)
|
||||
except Exception as e:
|
||||
logger.error(f"[RF_VALIDATED] Erreur lors de l'envoi de l'email de validation: {e}")
|
||||
|
||||
# Lancer l'envoi d'email dans un thread séparé pour ne pas bloquer la réponse
|
||||
email_thread = threading.Thread(target=send_validation_emails)
|
||||
email_thread.start()
|
||||
|
||||
updateStateMachine(registerForm, 'EVENT_VALIDATE')
|
||||
|
||||
elif _status == RegistrationForm.RegistrationFormStatus.RF_ARCHIVED:
|
||||
# Vérifier si on vient de l'état "À valider" (RF_UNDER_REVIEW) pour un refus définitif
|
||||
if registerForm.status == RegistrationForm.RegistrationFormStatus.RF_UNDER_REVIEW:
|
||||
# Envoi de l'email de refus définitif aux responsables légaux (en arrière-plan)
|
||||
def send_refus_definitif_emails():
|
||||
try:
|
||||
student = registerForm.student
|
||||
student_name = f"{student.first_name} {student.last_name}"
|
||||
notes = data.get('notes', '') or "Aucun motif spécifié"
|
||||
|
||||
guardians = student.guardians.all()
|
||||
for guardian in guardians:
|
||||
email = None
|
||||
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
|
||||
email = guardian.profile_role.profile.email
|
||||
if not email:
|
||||
email = getattr(guardian, "email", None)
|
||||
|
||||
if email:
|
||||
logger.info(f"[RF_ARCHIVED] Envoi email de refus définitif à {email} pour l'élève {student_name}")
|
||||
mailer.sendRefusDefinitif(email, registerForm.establishment.pk, student_name, notes)
|
||||
except Exception as e:
|
||||
logger.error(f"[RF_ARCHIVED] Erreur lors de l'envoi de l'email de refus définitif: {e}")
|
||||
|
||||
# Lancer l'envoi d'email dans un thread séparé pour ne pas bloquer la réponse
|
||||
email_thread = threading.Thread(target=send_refus_definitif_emails)
|
||||
email_thread.start()
|
||||
|
||||
updateStateMachine(registerForm, 'EVENT_ARCHIVE')
|
||||
|
||||
# Retourner les données mises à jour
|
||||
return JsonResponse(studentForm_serializer.data, safe=False)
|
||||
|
||||
@ -835,3 +958,26 @@ def get_parent_file_templates_by_rf(request, id):
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
except RegistrationParentFileTemplate.DoesNotExist:
|
||||
return JsonResponse({'error': 'Aucune pièce à fournir trouvée pour ce dossier d\'inscription'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@swagger_auto_schema(
|
||||
method='get',
|
||||
responses={200: openapi.Response('PDF file', schema=openapi.Schema(type=openapi.TYPE_FILE))},
|
||||
operation_description="Génère et retourne le PDF de la fiche élève à la volée",
|
||||
operation_summary="Télécharger la fiche élève (régénérée)"
|
||||
)
|
||||
@api_view(['GET'])
|
||||
def generate_registration_pdf(request, id):
|
||||
try:
|
||||
registerForm = RegistrationForm.objects.select_related('student', 'establishment').get(student__id=id)
|
||||
except RegistrationForm.DoesNotExist:
|
||||
return JsonResponse({"error": "Dossier d'inscription introuvable"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
try:
|
||||
pdf_content = util.generateRegistrationPDF(registerForm)
|
||||
except ValueError as e:
|
||||
return JsonResponse({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
filename = f"Inscription_{registerForm.student.last_name}_{registerForm.student.first_name}.pdf"
|
||||
response = HttpResponse(pdf_content, content_type='application/pdf')
|
||||
response['Content-Disposition'] = f'inline; filename="{filename}"'
|
||||
return response
|
||||
|
||||
@ -6,11 +6,10 @@ from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import status
|
||||
|
||||
from Subscriptions.serializers import RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer
|
||||
from Subscriptions.serializers import RegistrationParentFileMasterSerializer
|
||||
from Subscriptions.models import (
|
||||
RegistrationForm,
|
||||
RegistrationParentFileMaster,
|
||||
RegistrationParentFileTemplate
|
||||
RegistrationParentFileMaster
|
||||
)
|
||||
from N3wtSchool import bdd
|
||||
import logging
|
||||
@ -176,97 +175,3 @@ class RegistrationParentFileMasterSimpleView(APIView):
|
||||
return JsonResponse({'message': 'La suppression du fichier parent a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||
else:
|
||||
return JsonResponse({'erreur': 'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
class RegistrationParentFileTemplateView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère tous les templates parents pour un établissement donné",
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
'establishment_id',
|
||||
openapi.IN_QUERY,
|
||||
description="ID de l'établissement",
|
||||
type=openapi.TYPE_INTEGER,
|
||||
required=True
|
||||
)
|
||||
],
|
||||
responses={200: RegistrationParentFileTemplateSerializer(many=True)}
|
||||
)
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id')
|
||||
if not establishment_id:
|
||||
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Filtrer les templates parents liés à l'établissement via master.groups.establishment
|
||||
templates = RegistrationParentFileTemplate.objects.filter(
|
||||
master__groups__establishment__id=establishment_id
|
||||
).distinct()
|
||||
serializer = RegistrationParentFileTemplateSerializer(templates, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Crée un nouveau template d'inscription",
|
||||
request_body=RegistrationParentFileTemplateSerializer,
|
||||
responses={
|
||||
201: RegistrationParentFileTemplateSerializer,
|
||||
400: "Données invalides"
|
||||
}
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = RegistrationParentFileTemplateSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class RegistrationParentFileTemplateSimpleView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère un template d'inscription spécifique",
|
||||
responses={
|
||||
200: RegistrationParentFileTemplateSerializer,
|
||||
404: "Template non trouvé"
|
||||
}
|
||||
)
|
||||
def get(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||
if template is None:
|
||||
return JsonResponse({"errorMessage":'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
serializer = RegistrationParentFileTemplateSerializer(template)
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Met à jour un template d'inscription existant",
|
||||
request_body=RegistrationParentFileTemplateSerializer,
|
||||
responses={
|
||||
200: RegistrationParentFileTemplateSerializer,
|
||||
400: "Données invalides",
|
||||
404: "Template non trouvé"
|
||||
}
|
||||
)
|
||||
def put(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||
if template is None:
|
||||
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
serializer = RegistrationParentFileTemplateSerializer(template, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Supprime un template d'inscription",
|
||||
responses={
|
||||
204: "Suppression réussie",
|
||||
404: "Template non trouvé"
|
||||
}
|
||||
)
|
||||
def delete(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||
if template is not None:
|
||||
# Suppression du fichier PDF associé avant suppression de l'objet
|
||||
if template.file and template.file.name:
|
||||
template.file.delete(save=False)
|
||||
template.delete()
|
||||
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||
else:
|
||||
return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@ -58,6 +58,8 @@ class RegistrationParentFileTemplateView(APIView):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class RegistrationParentFileTemplateSimpleView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère un template d'inscription spécifique",
|
||||
responses={
|
||||
@ -82,11 +84,15 @@ class RegistrationParentFileTemplateSimpleView(APIView):
|
||||
}
|
||||
)
|
||||
def put(self, request, id):
|
||||
payload, resp = util.build_payload_from_request(request)
|
||||
if resp is not None:
|
||||
return resp
|
||||
|
||||
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)
|
||||
serializer = RegistrationParentFileTemplateSerializer(template, data=payload, 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)
|
||||
|
||||
@ -125,6 +125,25 @@ class RegistrationSchoolFileMasterSimpleView(APIView):
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
# Garde-fou: eviter d'ecraser un master dynamique existant avec un
|
||||
# formMasterData vide/malforme (cas observe en multipart).
|
||||
if 'formMasterData' in payload:
|
||||
incoming_form_data = payload.get('formMasterData')
|
||||
current_is_dynamic = (
|
||||
isinstance(master.formMasterData, dict)
|
||||
and bool(master.formMasterData.get('fields'))
|
||||
)
|
||||
incoming_is_dynamic = (
|
||||
isinstance(incoming_form_data, dict)
|
||||
and bool(incoming_form_data.get('fields'))
|
||||
)
|
||||
if current_is_dynamic and not incoming_is_dynamic:
|
||||
logger.warning(
|
||||
"formMasterData invalide recu pour master %s: conservation de la config dynamique existante",
|
||||
master.pk,
|
||||
)
|
||||
payload['formMasterData'] = master.formMasterData
|
||||
|
||||
|
||||
logger.info(f"payload for update serializer: {payload}")
|
||||
serializer = RegistrationSchoolFileMasterSerializer(master, data=payload, partial=True)
|
||||
|
||||
@ -1,127 +1,34 @@
|
||||
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
|
||||
import os
|
||||
import glob
|
||||
|
||||
from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer
|
||||
from Subscriptions.models import RegistrationSchoolFileMaster, RegistrationSchoolFileTemplate, RegistrationParentFileMaster, RegistrationParentFileTemplate
|
||||
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 RegistrationSchoolFileMasterView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère tous les masters de templates d'inscription pour un établissement donné",
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
'establishment_id',
|
||||
openapi.IN_QUERY,
|
||||
description="ID de l'établissement",
|
||||
type=openapi.TYPE_INTEGER,
|
||||
required=True
|
||||
)
|
||||
],
|
||||
responses={200: RegistrationSchoolFileMasterSerializer(many=True)}
|
||||
)
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id')
|
||||
if not establishment_id:
|
||||
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Filtrer les masters liés à l'établissement via groups.establishment
|
||||
masters = RegistrationSchoolFileMaster.objects.filter(
|
||||
groups__establishment__id=establishment_id
|
||||
).distinct()
|
||||
serializer = RegistrationSchoolFileMasterSerializer(masters, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Crée un nouveau master de template d'inscription",
|
||||
request_body=RegistrationSchoolFileMasterSerializer,
|
||||
responses={
|
||||
201: RegistrationSchoolFileMasterSerializer,
|
||||
400: "Données invalides"
|
||||
}
|
||||
)
|
||||
def post(self, request):
|
||||
logger.info(f"raw request.data: {request.data}")
|
||||
|
||||
payload, resp = util.build_payload_from_request(request)
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
logger.info(f"payload for serializer: {payload}")
|
||||
serializer = RegistrationSchoolFileMasterSerializer(data=payload, partial=True)
|
||||
if serializer.is_valid():
|
||||
obj = serializer.save()
|
||||
return Response(RegistrationSchoolFileMasterSerializer(obj).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
logger.error(f"serializer errors: {serializer.errors}")
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class RegistrationSchoolFileMasterSimpleView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère un master de template d'inscription spécifique",
|
||||
responses={
|
||||
200: RegistrationSchoolFileMasterSerializer,
|
||||
404: "Master non trouvé"
|
||||
}
|
||||
)
|
||||
def get(self, request, id):
|
||||
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
|
||||
if master is None:
|
||||
return JsonResponse({"errorMessage":'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
serializer = RegistrationSchoolFileMasterSerializer(master)
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Met à jour un master de template d'inscription existant",
|
||||
request_body=RegistrationSchoolFileMasterSerializer,
|
||||
responses={
|
||||
200: RegistrationSchoolFileMasterSerializer,
|
||||
400: "Données invalides",
|
||||
404: "Master non trouvé"
|
||||
}
|
||||
)
|
||||
def put(self, request, id):
|
||||
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
|
||||
if master is None:
|
||||
return JsonResponse({'erreur': "Le master de template n'a pas été trouvé"}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Normaliser payload (supporte form-data avec champ 'data' JSON ou fichier JSON)
|
||||
payload, resp = util.build_payload_from_request(request)
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
logger.info(f"payload for update serializer: {payload}")
|
||||
serializer = RegistrationSchoolFileMasterSerializer(master, data=payload, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
logger.error(f"serializer errors on put: {serializer.errors}")
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Supprime un master de template d'inscription",
|
||||
responses={
|
||||
204: "Suppression réussie",
|
||||
404: "Master non trouvé"
|
||||
}
|
||||
)
|
||||
def delete(self, request, id):
|
||||
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
|
||||
if master is not None:
|
||||
master.delete()
|
||||
return JsonResponse({'message': 'La suppression du master de template a été effectuée avec succès'}, safe=False, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return JsonResponse({'erreur': 'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
def _extract_nested_responses(data, max_depth=8):
|
||||
"""Extrait le dictionnaire de reponses depuis des structures imbriquees."""
|
||||
current = data
|
||||
for _ in range(max_depth):
|
||||
if not isinstance(current, dict):
|
||||
return None
|
||||
nested = current.get("responses")
|
||||
if isinstance(nested, dict):
|
||||
current = nested
|
||||
continue
|
||||
return current
|
||||
return current if isinstance(current, dict) else None
|
||||
|
||||
class RegistrationSchoolFileTemplateView(APIView):
|
||||
@swagger_auto_schema(
|
||||
@ -165,6 +72,8 @@ class RegistrationSchoolFileTemplateView(APIView):
|
||||
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={
|
||||
@ -189,12 +98,125 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
|
||||
}
|
||||
)
|
||||
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"]
|
||||
responses = _extract_nested_responses(resp)
|
||||
|
||||
# Nettoyer les meta-cles qui ne sont pas des reponses de champs
|
||||
if isinstance(responses, dict):
|
||||
cleaned = {
|
||||
key: value
|
||||
for key, value in responses.items()
|
||||
if key not in {"responses", "formId", "id", "templateId"}
|
||||
}
|
||||
responses = cleaned
|
||||
|
||||
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]
|
||||
|
||||
# Stocker les reponses aplaties pour eviter l'empilement responses.responses
|
||||
if isinstance(responses, dict):
|
||||
formTemplateData["responses"] = responses
|
||||
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)
|
||||
serializer = RegistrationSchoolFileTemplateSerializer(template, data=request.data)
|
||||
|
||||
# Cas 1 : Upload d'un fichier existant (PDF/image)
|
||||
if 'file' in request.FILES:
|
||||
upload = request.FILES['file']
|
||||
file_field = template.file
|
||||
upload_name = upload.name
|
||||
upload_dir = os.path.dirname(file_field.path) if file_field and file_field.name else None
|
||||
if upload_dir:
|
||||
base_name, _ = os.path.splitext(upload_name)
|
||||
pattern = os.path.join(upload_dir, f"{base_name}.*")
|
||||
for f in glob.glob(pattern):
|
||||
try:
|
||||
if os.path.exists(f):
|
||||
os.remove(f)
|
||||
logger.info(f"Suppression du fichier existant (pattern): {f}")
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur suppression fichier existant (pattern): {e}")
|
||||
target_path = os.path.join(upload_dir, upload_name)
|
||||
if os.path.exists(target_path):
|
||||
try:
|
||||
os.remove(target_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur suppression fichier cible: {e}")
|
||||
# On écrase le fichier existant sans passer par le serializer
|
||||
template.file.save(upload_name, upload, save=True)
|
||||
return Response({'message': 'Template mis à jour avec succès', 'data': RegistrationSchoolFileTemplateSerializer(template).data}, status=status.HTTP_200_OK)
|
||||
|
||||
# Cas 2 : Formulaire dynamique (JSON)
|
||||
serializer = RegistrationSchoolFileTemplateSerializer(template, data=payload, partial=True)
|
||||
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")
|
||||
):
|
||||
# Lire le contenu du fichier source en mémoire AVANT suppression.
|
||||
# Priorité au fichier master (document source admin) pour éviter
|
||||
# de re-générer à partir d'un PDF template déjà enrichi.
|
||||
base_pdf_content = None
|
||||
base_file_ext = None
|
||||
if template.master and template.master.file and template.master.file.name:
|
||||
base_file_ext = os.path.splitext(template.master.file.name)[1].lower()
|
||||
try:
|
||||
template.master.file.open('rb')
|
||||
base_pdf_content = template.master.file.read()
|
||||
template.master.file.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lecture fichier source master: {e}")
|
||||
elif template.file and template.file.name:
|
||||
base_file_ext = os.path.splitext(template.file.name)[1].lower()
|
||||
try:
|
||||
template.file.open('rb')
|
||||
base_pdf_content = template.file.read()
|
||||
template.file.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lecture fichier source template: {e}")
|
||||
try:
|
||||
old_path = template.file.path
|
||||
template.file.delete(save=False)
|
||||
if os.path.exists(old_path):
|
||||
os.remove(old_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,
|
||||
base_pdf_content=base_pdf_content,
|
||||
base_file_ext=base_file_ext,
|
||||
)
|
||||
form_name = (formTemplateData.get("title") or template.name or f"formulaire_{template.id}").strip().replace(" ", "_")
|
||||
pdf_filename = f"{form_name}.pdf"
|
||||
util.save_file_field_without_suffix(
|
||||
template,
|
||||
'file',
|
||||
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)
|
||||
|
||||
@ -223,195 +245,3 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
|
||||
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||
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:
|
||||
# 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)
|
||||
|
||||
@ -176,12 +176,42 @@ class StudentCompetencyListCreateView(APIView):
|
||||
if domaine_dict["categories"]:
|
||||
result.append(domaine_dict)
|
||||
|
||||
establishment = None
|
||||
if student.associated_class and student.associated_class.establishment:
|
||||
establishment = student.associated_class.establishment
|
||||
else:
|
||||
try:
|
||||
establishment = student.registrationform.establishment
|
||||
except Exception:
|
||||
establishment = None
|
||||
|
||||
establishment_logo_path = None
|
||||
if establishment and establishment.logo:
|
||||
try:
|
||||
if establishment.logo.path and os.path.exists(establishment.logo.path):
|
||||
establishment_logo_path = establishment.logo.path
|
||||
except Exception:
|
||||
establishment_logo_path = None
|
||||
|
||||
n3wt_logo_path = os.path.join(settings.BASE_DIR, 'static', 'img', 'logo_min.svg')
|
||||
if not os.path.exists(n3wt_logo_path):
|
||||
n3wt_logo_path = None
|
||||
|
||||
context = {
|
||||
"student": {
|
||||
"first_name": student.first_name,
|
||||
"last_name": student.last_name,
|
||||
"level": student.level,
|
||||
"class_name": student.associated_class.atmosphere_name,
|
||||
"class_name": student.associated_class.atmosphere_name if student.associated_class else "Non assignée",
|
||||
},
|
||||
"establishment": {
|
||||
"name": establishment.name if establishment else "Établissement",
|
||||
"address": establishment.address if establishment else "",
|
||||
"logo_path": establishment_logo_path,
|
||||
},
|
||||
"product": {
|
||||
"name": "n3wt-school",
|
||||
"logo_path": n3wt_logo_path,
|
||||
},
|
||||
"period": period,
|
||||
"date": date.today().strftime("%d/%m/%Y"),
|
||||
|
||||
@ -54,6 +54,12 @@ class StudentListView(APIView):
|
||||
description="ID de l'établissement",
|
||||
type=openapi.TYPE_INTEGER,
|
||||
required=True
|
||||
),
|
||||
openapi.Parameter(
|
||||
'school_year', openapi.IN_QUERY,
|
||||
description="Année scolaire (ex: 2025-2026)",
|
||||
type=openapi.TYPE_STRING,
|
||||
required=False
|
||||
)
|
||||
]
|
||||
)
|
||||
@ -61,6 +67,7 @@ class StudentListView(APIView):
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id', None)
|
||||
status_filter = request.GET.get('status', None) # Nouveau filtre optionnel
|
||||
school_year_filter = request.GET.get('school_year', None) # Filtre année scolaire
|
||||
|
||||
if establishment_id is None:
|
||||
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
|
||||
@ -70,6 +77,9 @@ class StudentListView(APIView):
|
||||
if status_filter:
|
||||
students_qs = students_qs.filter(registrationform__status=status_filter)
|
||||
|
||||
if school_year_filter:
|
||||
students_qs = students_qs.filter(registrationform__school_year=school_year_filter)
|
||||
|
||||
students_qs = students_qs.distinct()
|
||||
students_serializer = StudentByRFCreationSerializer(students_qs, many=True)
|
||||
return JsonResponse(students_serializer.data, safe=False)
|
||||
|
||||
@ -1 +1 @@
|
||||
__version__ = "0.0.4"
|
||||
__version__ = "0.0.3"
|
||||
|
||||
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
|
||||
@ -11,9 +11,9 @@ def run_command(command):
|
||||
print(f"stderr: {stderr.decode()}")
|
||||
return process.returncode
|
||||
|
||||
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'
|
||||
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 = [
|
||||
@ -61,12 +61,6 @@ if __name__ == "__main__":
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
if flush_data:
|
||||
for command in flush_data_cmd:
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
if migrate_data:
|
||||
for command in migrate_commands:
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
@ -75,6 +69,11 @@ if __name__ == "__main__":
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
if flush_data:
|
||||
for command in flush_data_cmd:
|
||||
if run_command(command) != 0:
|
||||
exit(1)
|
||||
|
||||
if test_mode:
|
||||
for test_command in test_commands:
|
||||
if run_command(test_command) != 0:
|
||||
|
||||
7
Back-End/static/img/n3wt.svg
Normal file
7
Back-End/static/img/n3wt.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="420" height="120" viewBox="0 0 420 120" role="img" aria-label="n3wt-school">
|
||||
<rect width="420" height="120" rx="16" fill="#F0FDF4"/>
|
||||
<circle cx="56" cy="60" r="30" fill="#10B981"/>
|
||||
<path d="M42 60h28M56 46v28" stroke="#064E3B" stroke-width="8" stroke-linecap="round"/>
|
||||
<text x="104" y="70" font-family="Arial, sans-serif" font-size="42" font-weight="700" fill="#064E3B">n3wt</text>
|
||||
<text x="245" y="70" font-family="Arial, sans-serif" font-size="30" font-weight="600" fill="#059669">school</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 561 B |
91
CLAUDE.md
Normal file
91
CLAUDE.md
Normal file
@ -0,0 +1,91 @@
|
||||
# CLAUDE.md — N3WT-SCHOOL
|
||||
|
||||
Instructions permanentes pour Claude Code sur ce projet.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Backend** : Python / Django — dossier `Back-End/`
|
||||
- **Frontend** : Next.js 14 (App Router) — dossier `Front-End/`
|
||||
- **Tests frontend** : `Front-End/src/test/`
|
||||
- **CSS** : Tailwind CSS 3 + `@tailwindcss/forms`
|
||||
|
||||
## Design System
|
||||
|
||||
Le design system complet est dans [`docs/design-system.md`](docs/design-system.md). À lire et appliquer systématiquement.
|
||||
|
||||
### Tokens de couleur (Tailwind)
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|-------------|-----------|------------------------------------|
|
||||
| `primary` | `#059669` | Boutons, CTA, éléments actifs |
|
||||
| `secondary` | `#064E3B` | Hover, accents sombres |
|
||||
| `tertiary` | `#10B981` | Badges, icônes d'accent |
|
||||
| `neutral` | `#F8FAFC` | Fonds de page, surfaces |
|
||||
|
||||
> **Règle absolue** : ne jamais utiliser `emerald-*`, `green-*` pour les éléments interactifs. Utiliser les tokens ci-dessus.
|
||||
|
||||
### Typographie
|
||||
|
||||
- `font-headline` → titres `h1`/`h2`/`h3` (Manrope)
|
||||
- `font-body` → texte courant, défaut sur `<body>` (Inter)
|
||||
- `font-label` → boutons, labels de form (Inter)
|
||||
|
||||
### Arrondi & Espacement
|
||||
|
||||
- Arrondi par défaut : `rounded` (4px)
|
||||
- Espacement : grille 4px/8px — pas de valeurs arbitraires
|
||||
- Mode : **light uniquement**, pas de dark mode
|
||||
|
||||
## Conventions de code
|
||||
|
||||
### Frontend (Next.js)
|
||||
|
||||
- **Composants** : React fonctionnels, pas de classes
|
||||
- **Styles** : Tailwind CSS uniquement — pas de CSS inline sauf animations
|
||||
- **Icônes** : `lucide-react`
|
||||
- **i18n** : `next-intl` — toutes les chaînes UI via `useTranslations()`
|
||||
- **Formulaires** : `react-hook-form`
|
||||
- **Imports** : alias `@/` pointe vers `Front-End/src/`
|
||||
|
||||
### Qualité
|
||||
|
||||
- Linting : ESLint (`npm run lint` dans `Front-End/`)
|
||||
- Format : Prettier
|
||||
- Tests : Jest + React Testing Library (`Front-End/src/test/`)
|
||||
|
||||
## Gestion des branches
|
||||
|
||||
- Base : `develop`
|
||||
- Nomenclature : `<type>-<nom_ticket>-<numero>` (ex: `feat-ma-feature-1234`)
|
||||
- Types : `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
|
||||
|
||||
## Icônes
|
||||
|
||||
Utiliser **uniquement `lucide-react`** — jamais d'autre bibliothèque d'icônes.
|
||||
|
||||
```jsx
|
||||
import { Home, Plus } from 'lucide-react';
|
||||
<Home size={20} className="text-primary" />
|
||||
```
|
||||
|
||||
## Responsive & PWA
|
||||
|
||||
- **Mobile-first** : styles de base = mobile, breakpoints `sm:`/`md:`/`lg:` pour agrandir.
|
||||
- Touch targets ≥ 44px (`min-h-[44px]`) sur tous les éléments interactifs.
|
||||
- Pas d'interactions uniquement au `:hover` — prévoir l'équivalent tactile.
|
||||
- Les tableaux utilisent la classe `responsive-table` sur mobile.
|
||||
|
||||
## Réutilisation des composants
|
||||
|
||||
Avant de créer un composant, **vérifier `Front-End/src/components/`**.
|
||||
Composants disponibles : `AlertMessage`, `Modal`, `Pagination`, `SectionHeader`, `ProgressStep`, `EventCard`, `Calendar/*`, `Chat/*`, `Evaluation/*`, `Grades/*`, `Form/*`, `Admin/*`, `Charts/*`.
|
||||
|
||||
## À éviter
|
||||
|
||||
- Ne pas ajouter de dépendances inutiles
|
||||
- Ne pas modifier `package-lock.json` / `yarn.lock` manuellement
|
||||
- Ne pas committer sans avoir vérifié ESLint et les tests
|
||||
- Ne pas utiliser de CSS arbitraire (`p-[13px]`) sauf cas justifié
|
||||
- Ne pas ajouter de support dark mode
|
||||
- Ne pas utiliser d'autres bibliothèques d'icônes que `lucide-react`
|
||||
- Ne pas créer un composant qui existe déjà dans `components/`
|
||||
@ -1,4 +0,0 @@
|
||||
{
|
||||
"presets": ["next/babel"],
|
||||
"plugins": []
|
||||
}
|
||||
21
Front-End/messages/en/feedback.json
Normal file
21
Front-End/messages/en/feedback.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "Feedback",
|
||||
"description": "Share your feedback, report a bug, or suggest an improvement. We read every message!",
|
||||
"category_label": "Category",
|
||||
"category_placeholder": "Select a category",
|
||||
"category_bug": "Report a bug",
|
||||
"category_feature": "Suggest a feature",
|
||||
"category_question": "Ask a question",
|
||||
"category_other": "Other",
|
||||
"subject_label": "Subject",
|
||||
"subject_placeholder": "Summarize your request",
|
||||
"message_label": "Message",
|
||||
"message_placeholder": "Describe your feedback in detail...",
|
||||
"send": "Send",
|
||||
"sending": "Sending...",
|
||||
"success": "Success",
|
||||
"success_message": "Your feedback has been sent. Thank you!",
|
||||
"error": "Error",
|
||||
"error_required_fields": "Please fill in all required fields.",
|
||||
"error_sending": "An error occurred while sending your feedback."
|
||||
}
|
||||
@ -7,5 +7,6 @@
|
||||
"educational_monitoring": "Educational Monitoring",
|
||||
"settings": "Settings",
|
||||
"schoolAdmin": "School Administration",
|
||||
"messagerie": "Messenger"
|
||||
"messagerie": "Messenger",
|
||||
"feedback": "Feedback"
|
||||
}
|
||||
|
||||
21
Front-End/messages/fr/feedback.json
Normal file
21
Front-End/messages/fr/feedback.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "Feedback",
|
||||
"description": "Partagez vos retours, signalez un bug ou proposez une amélioration. Nous lisons chaque message !",
|
||||
"category_label": "Catégorie",
|
||||
"category_placeholder": "Sélectionnez une catégorie",
|
||||
"category_bug": "Signaler un bug",
|
||||
"category_feature": "Proposer une fonctionnalité",
|
||||
"category_question": "Poser une question",
|
||||
"category_other": "Autre",
|
||||
"subject_label": "Sujet",
|
||||
"subject_placeholder": "Résumez votre demande",
|
||||
"message_label": "Message",
|
||||
"message_placeholder": "Décrivez en détail votre retour...",
|
||||
"send": "Envoyer",
|
||||
"sending": "Envoi en cours...",
|
||||
"success": "Succès",
|
||||
"success_message": "Votre feedback a bien été envoyé. Merci !",
|
||||
"error": "Erreur",
|
||||
"error_required_fields": "Veuillez remplir tous les champs obligatoires.",
|
||||
"error_sending": "Une erreur est survenue lors de l'envoi du feedback."
|
||||
}
|
||||
@ -7,5 +7,6 @@
|
||||
"educational_monitoring": "Suivi pédagogique",
|
||||
"settings": "Paramètres",
|
||||
"schoolAdmin": "Administration Scolaire",
|
||||
"messagerie": "Messagerie"
|
||||
"messagerie": "Messagerie",
|
||||
"feedback": "Feedback"
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n3wt-school-front-end",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
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))
|
||||
);
|
||||
});
|
||||
@ -3,16 +3,19 @@ import Logo from '../components/Logo';
|
||||
|
||||
export default function Custom500() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-emerald-500">
|
||||
<div className="text-center p-6 ">
|
||||
<div className="flex items-center justify-center min-h-screen bg-primary">
|
||||
<div className="text-center p-6 bg-white rounded-md shadow-sm border border-gray-200">
|
||||
<Logo className="w-32 h-32 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-emerald-900 mb-4">
|
||||
<h2 className="font-headline text-2xl font-bold text-secondary mb-4">
|
||||
500 | Erreur interne
|
||||
</h2>
|
||||
<p className="text-emerald-900 mb-4">
|
||||
<p className="font-body text-gray-600 mb-4">
|
||||
Une erreur interne est survenue.
|
||||
</p>
|
||||
<Link className="text-gray-900 hover:underline" href="/">
|
||||
<Link
|
||||
className="inline-flex items-center justify-center min-h-[44px] px-4 py-2 rounded font-label font-medium bg-primary hover:bg-secondary text-white transition-colors"
|
||||
href="/"
|
||||
>
|
||||
Retour Accueil
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@ -2,8 +2,17 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { PARENT_FILTER, SCHOOL_FILTER } from '@/utils/constants';
|
||||
import { Trash2, ToggleLeft, ToggleRight, Info, XCircle } from 'lucide-react';
|
||||
import {
|
||||
Trash2,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
Info,
|
||||
XCircle,
|
||||
Users,
|
||||
UserPlus,
|
||||
} from 'lucide-react';
|
||||
import Table from '@/components/Table';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import Popup from '@/components/Popup';
|
||||
import StatusLabel from '@/components/StatusLabel';
|
||||
import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem';
|
||||
@ -17,7 +26,6 @@ import { dissociateGuardian } from '@/app/actions/subscriptionAction';
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
|
||||
import logger from '@/utils/logger';
|
||||
import AlertMessage from '@/components/AlertMessage';
|
||||
|
||||
const roleTypeToLabel = (roleType) => {
|
||||
switch (roleType) {
|
||||
@ -39,7 +47,7 @@ const roleTypeToBadgeClass = (roleType) => {
|
||||
case 1:
|
||||
return 'bg-red-100 text-red-600';
|
||||
case 2:
|
||||
return 'bg-green-100 text-green-600';
|
||||
return 'bg-tertiary/10 text-tertiary';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-600';
|
||||
}
|
||||
@ -378,7 +386,7 @@ export default function Page() {
|
||||
type="button"
|
||||
className={
|
||||
row.is_active
|
||||
? 'text-emerald-500 hover:text-emerald-700'
|
||||
? 'text-primary hover:text-secondary'
|
||||
: 'text-orange-500 hover:text-orange-700'
|
||||
}
|
||||
onClick={() => handleConfirmActivateProfile(row)}
|
||||
@ -474,7 +482,7 @@ export default function Page() {
|
||||
type="button"
|
||||
className={
|
||||
row.is_active
|
||||
? 'text-emerald-500 hover:text-emerald-700'
|
||||
? 'text-primary hover:text-secondary'
|
||||
: 'text-orange-500 hover:text-orange-700'
|
||||
}
|
||||
onClick={() => handleConfirmActivateProfile(row)}
|
||||
@ -516,10 +524,10 @@ export default function Page() {
|
||||
totalPages={totalProfilesParentPages}
|
||||
onPageChange={handlePageChange}
|
||||
emptyMessage={
|
||||
<AlertMessage
|
||||
type="info"
|
||||
title="Aucun profil PARENT enregistré"
|
||||
message="Un profil Parent est ajouté lors de la création d'un nouveau dossier d'inscription."
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="Aucun profil parent enregistré"
|
||||
description="Les profils parents sont créés automatiquement lors de la création d'un dossier d'inscription."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@ -540,10 +548,10 @@ export default function Page() {
|
||||
totalPages={totalProfilesSchoolPages}
|
||||
onPageChange={handlePageChange}
|
||||
emptyMessage={
|
||||
<AlertMessage
|
||||
type="info"
|
||||
title="Aucun profil ECOLE enregistré"
|
||||
message="Un profil ECOLE est ajouté lors de la création d'un nouvel enseignant."
|
||||
<EmptyState
|
||||
icon={UserPlus}
|
||||
title="Aucun profil école enregistré"
|
||||
description="Les profils école sont créés automatiquement lors de l'ajout d'un enseignant."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
136
Front-End/src/app/[locale]/admin/feedback/page.js
Normal file
136
Front-End/src/app/[locale]/admin/feedback/page.js
Normal file
@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { sendFeedback } from '@/app/actions/emailAction';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import WisiwigTextArea from '@/components/Form/WisiwigTextArea';
|
||||
import InputText from '@/components/Form/InputText';
|
||||
import Button from '@/components/Form/Button';
|
||||
import SelectChoice from '@/components/Form/SelectChoice';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
export default function FeedbackPage() {
|
||||
const t = useTranslations('feedback');
|
||||
const { showNotification } = useNotification();
|
||||
const { selectedEstablishmentId, establishments, user } = useEstablishment();
|
||||
|
||||
// Récupérer les infos complètes de l'établissement sélectionné
|
||||
const selectedEstablishment = establishments?.find(
|
||||
(e) => e.id === selectedEstablishmentId
|
||||
);
|
||||
|
||||
const [category, setCategory] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const categoryChoices = [
|
||||
{ value: 'bug', label: t('category_bug') },
|
||||
{ value: 'feature', label: t('category_feature') },
|
||||
{ value: 'question', label: t('category_question') },
|
||||
{ value: 'other', label: t('category_other') },
|
||||
];
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!category || !subject || !message) {
|
||||
showNotification(t('error_required_fields'), 'error', t('error'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Construire le nom de l'utilisateur (fallback vers l'email si nom indisponible)
|
||||
const userName = user
|
||||
? user.first_name && user.last_name
|
||||
? `${user.first_name} ${user.last_name}`
|
||||
: user.username || user.email?.split('@')[0] || ''
|
||||
: '';
|
||||
|
||||
const feedbackData = {
|
||||
category,
|
||||
subject,
|
||||
message,
|
||||
establishment: selectedEstablishment
|
||||
? {
|
||||
id: selectedEstablishment.id,
|
||||
name: selectedEstablishment.name,
|
||||
total_capacity: selectedEstablishment.total_capacity,
|
||||
evaluation_frequency: selectedEstablishment.evaluation_frequency,
|
||||
}
|
||||
: { id: selectedEstablishmentId },
|
||||
user_email: user?.email || '',
|
||||
user_name: userName,
|
||||
};
|
||||
|
||||
try {
|
||||
await sendFeedback(feedbackData);
|
||||
showNotification(t('success_message'), 'success', t('success'));
|
||||
// Réinitialiser les champs après succès
|
||||
setCategory('');
|
||||
setSubject('');
|
||||
setMessage('');
|
||||
} catch (error) {
|
||||
logger.error("Erreur lors de l'envoi du feedback:", { error });
|
||||
showNotification(t('error_sending'), 'error', t('error'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col p-4">
|
||||
<div className="max-w-3xl mx-auto w-full">
|
||||
<h1 className="font-headline text-2xl font-bold text-gray-800 mb-2">
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">{t('description')}</p>
|
||||
|
||||
<div className="bg-white rounded-md shadow-sm border border-gray-200 p-6">
|
||||
{/* Catégorie */}
|
||||
<SelectChoice
|
||||
name="category"
|
||||
label={t('category_label')}
|
||||
selected={category}
|
||||
callback={(e) => setCategory(e.target.value)}
|
||||
choices={categoryChoices}
|
||||
placeHolder={t('category_placeholder')}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Sujet */}
|
||||
<InputText
|
||||
name="subject"
|
||||
label={t('subject_label')}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={t('subject_placeholder')}
|
||||
required
|
||||
className="mb-4 mt-4"
|
||||
/>
|
||||
|
||||
{/* Message */}
|
||||
<div className="mb-6">
|
||||
<WisiwigTextArea
|
||||
label={t('message_label')}
|
||||
value={message}
|
||||
onChange={setMessage}
|
||||
placeholder={t('message_placeholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bouton d'envoi */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
text={isSubmitting ? t('sending') : t('send')}
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
371
Front-End/src/app/[locale]/admin/grades/[studentId]/page.js
Normal file
371
Front-End/src/app/[locale]/admin/grades/[studentId]/page.js
Normal file
@ -0,0 +1,371 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import SelectChoice from '@/components/Form/SelectChoice';
|
||||
import Attendance from '@/components/Grades/Attendance';
|
||||
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
|
||||
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
|
||||
import { EvaluationStudentView } from '@/components/Evaluation';
|
||||
import Button from '@/components/Form/Button';
|
||||
import logger from '@/utils/logger';
|
||||
import { FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL } from '@/utils/Url';
|
||||
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||
import {
|
||||
fetchStudents,
|
||||
fetchStudentCompetencies,
|
||||
fetchAbsences,
|
||||
editAbsences,
|
||||
deleteAbsences,
|
||||
} from '@/app/actions/subscriptionAction';
|
||||
import {
|
||||
fetchEvaluations,
|
||||
fetchStudentEvaluations,
|
||||
updateStudentEvaluation,
|
||||
} from '@/app/actions/schoolAction';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { useClasses } from '@/context/ClassesContext';
|
||||
import { Award, ArrowLeft, BookOpen } from 'lucide-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
|
||||
function getPeriodString(selectedPeriod, frequency) {
|
||||
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
|
||||
const nextYear = (year + 1).toString();
|
||||
const schoolYear = `${year}-${nextYear}`;
|
||||
if (frequency === 1) return `T${selectedPeriod}_${schoolYear}`;
|
||||
if (frequency === 2) return `S${selectedPeriod}_${schoolYear}`;
|
||||
if (frequency === 3) return `A_${schoolYear}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function StudentGradesPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const studentId = Number(params.studentId);
|
||||
const csrfToken = useCsrfToken();
|
||||
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
|
||||
useEstablishment();
|
||||
const { getNiveauLabel } = useClasses();
|
||||
|
||||
const [student, setStudent] = useState(null);
|
||||
const [studentCompetencies, setStudentCompetencies] = useState(null);
|
||||
const [grades, setGrades] = useState({});
|
||||
const [selectedPeriod, setSelectedPeriod] = useState(null);
|
||||
const [allAbsences, setAllAbsences] = useState([]);
|
||||
|
||||
// Evaluation states
|
||||
const [evaluations, setEvaluations] = useState([]);
|
||||
const [studentEvaluationsData, setStudentEvaluationsData] = useState([]);
|
||||
|
||||
const getPeriods = () => {
|
||||
if (selectedEstablishmentEvaluationFrequency === 1) {
|
||||
return [
|
||||
{ label: 'Trimestre 1', value: 1, start: '09-01', end: '12-31' },
|
||||
{ label: 'Trimestre 2', value: 2, start: '01-01', end: '03-31' },
|
||||
{ label: 'Trimestre 3', value: 3, start: '04-01', end: '07-15' },
|
||||
];
|
||||
}
|
||||
if (selectedEstablishmentEvaluationFrequency === 2) {
|
||||
return [
|
||||
{ label: 'Semestre 1', value: 1, start: '09-01', end: '01-31' },
|
||||
{ label: 'Semestre 2', value: 2, start: '02-01', end: '07-15' },
|
||||
];
|
||||
}
|
||||
if (selectedEstablishmentEvaluationFrequency === 3) {
|
||||
return [{ label: 'Année', value: 1, start: '09-01', end: '07-15' }];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// Load student info
|
||||
useEffect(() => {
|
||||
if (selectedEstablishmentId) {
|
||||
fetchStudents(selectedEstablishmentId, null, 5)
|
||||
.then((students) => {
|
||||
const found = students.find((s) => s.id === studentId);
|
||||
setStudent(found || null);
|
||||
})
|
||||
.catch((error) => logger.error('Error fetching students:', error));
|
||||
}
|
||||
}, [selectedEstablishmentId, studentId]);
|
||||
|
||||
// Auto-select current period
|
||||
useEffect(() => {
|
||||
const periods = getPeriods();
|
||||
const today = dayjs();
|
||||
const current = periods.find((p) => {
|
||||
const start = dayjs(`${today.year()}-${p.start}`);
|
||||
const end = dayjs(`${today.year()}-${p.end}`);
|
||||
return (
|
||||
today.isAfter(start.subtract(1, 'day')) &&
|
||||
today.isBefore(end.add(1, 'day'))
|
||||
);
|
||||
});
|
||||
setSelectedPeriod(current ? current.value : null);
|
||||
}, [selectedEstablishmentEvaluationFrequency]);
|
||||
|
||||
// Load competencies
|
||||
useEffect(() => {
|
||||
if (studentId && selectedPeriod) {
|
||||
const periodString = getPeriodString(
|
||||
selectedPeriod,
|
||||
selectedEstablishmentEvaluationFrequency
|
||||
);
|
||||
fetchStudentCompetencies(studentId, periodString)
|
||||
.then((data) => {
|
||||
setStudentCompetencies(data);
|
||||
if (data && data.data) {
|
||||
const initialGrades = {};
|
||||
data.data.forEach((domaine) => {
|
||||
domaine.categories.forEach((cat) => {
|
||||
cat.competences.forEach((comp) => {
|
||||
initialGrades[comp.competence_id] = comp.score ?? 0;
|
||||
});
|
||||
});
|
||||
});
|
||||
setGrades(initialGrades);
|
||||
}
|
||||
})
|
||||
.catch((error) =>
|
||||
logger.error('Error fetching studentCompetencies:', error)
|
||||
);
|
||||
} else {
|
||||
setGrades({});
|
||||
setStudentCompetencies(null);
|
||||
}
|
||||
}, [studentId, selectedPeriod]);
|
||||
|
||||
// Load absences
|
||||
useEffect(() => {
|
||||
if (selectedEstablishmentId) {
|
||||
fetchAbsences(selectedEstablishmentId)
|
||||
.then((data) => setAllAbsences(data))
|
||||
.catch((error) =>
|
||||
logger.error('Erreur lors du fetch des absences:', error)
|
||||
);
|
||||
}
|
||||
}, [selectedEstablishmentId]);
|
||||
|
||||
// Load evaluations for the student
|
||||
useEffect(() => {
|
||||
if (
|
||||
student?.associated_class_id &&
|
||||
selectedPeriod &&
|
||||
selectedEstablishmentId
|
||||
) {
|
||||
const periodString = getPeriodString(
|
||||
selectedPeriod,
|
||||
selectedEstablishmentEvaluationFrequency
|
||||
);
|
||||
|
||||
// Load evaluations for the class
|
||||
fetchEvaluations(
|
||||
selectedEstablishmentId,
|
||||
student.associated_class_id,
|
||||
periodString
|
||||
)
|
||||
.then((data) => setEvaluations(data))
|
||||
.catch((error) =>
|
||||
logger.error('Erreur lors du fetch des évaluations:', error)
|
||||
);
|
||||
|
||||
// Load student's evaluation scores
|
||||
fetchStudentEvaluations(studentId, null, periodString, null)
|
||||
.then((data) => setStudentEvaluationsData(data))
|
||||
.catch((error) =>
|
||||
logger.error('Erreur lors du fetch des notes:', error)
|
||||
);
|
||||
}
|
||||
}, [student, selectedPeriod, selectedEstablishmentId]);
|
||||
|
||||
const absences = React.useMemo(() => {
|
||||
return allAbsences
|
||||
.filter((a) => a.student === studentId)
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
date: a.day,
|
||||
type: [2, 1].includes(a.reason) ? 'Absence' : 'Retard',
|
||||
reason: a.reason,
|
||||
justified: [1, 3].includes(a.reason),
|
||||
moment: a.moment,
|
||||
commentaire: a.commentaire,
|
||||
}));
|
||||
}, [allAbsences, studentId]);
|
||||
|
||||
const handleToggleJustify = (absence) => {
|
||||
const newReason =
|
||||
absence.type === 'Absence'
|
||||
? absence.justified
|
||||
? 2
|
||||
: 1
|
||||
: absence.justified
|
||||
? 4
|
||||
: 3;
|
||||
|
||||
editAbsences(absence.id, { ...absence, reason: newReason }, csrfToken)
|
||||
.then(() => {
|
||||
setAllAbsences((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === absence.id ? { ...a, reason: newReason } : a
|
||||
)
|
||||
);
|
||||
})
|
||||
.catch((e) =>
|
||||
logger.error('Erreur lors du changement de justification', e)
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteAbsence = (absence) => {
|
||||
return deleteAbsences(absence.id, csrfToken)
|
||||
.then(() => {
|
||||
setAllAbsences((prev) => prev.filter((a) => a.id !== absence.id));
|
||||
})
|
||||
.catch((e) =>
|
||||
logger.error("Erreur lors de la suppression de l'absence", e)
|
||||
);
|
||||
};
|
||||
|
||||
const handleUpdateGrade = async (studentEvalId, data) => {
|
||||
try {
|
||||
await updateStudentEvaluation(studentEvalId, data, csrfToken);
|
||||
// Reload student evaluations
|
||||
const periodString = getPeriodString(
|
||||
selectedPeriod,
|
||||
selectedEstablishmentEvaluationFrequency
|
||||
);
|
||||
const updatedData = await fetchStudentEvaluations(
|
||||
studentId,
|
||||
null,
|
||||
periodString,
|
||||
null
|
||||
);
|
||||
setStudentEvaluationsData(updatedData);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la modification de la note:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push('/admin/grades')}
|
||||
className="p-2 rounded hover:bg-gray-100 border border-gray-200 min-h-[44px] min-w-[44px] flex items-center justify-center transition-colors"
|
||||
aria-label="Retour à la liste"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h1 className="font-headline text-xl font-bold text-gray-800">Suivi pédagogique</h1>
|
||||
</div>
|
||||
|
||||
{/* Student profile */}
|
||||
{student && (
|
||||
<div className="bg-neutral rounded-md 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={getSecureFileUrl(student.photo)}
|
||||
alt={`${student.first_name} ${student.last_name}`}
|
||||
className="w-24 h-24 object-cover rounded-full border-4 border-primary/20 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-primary/10">
|
||||
{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-secondary">
|
||||
{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 shadow bg-primary text-white font-label font-medium hover:bg-secondary w-full sm:w-auto min-h-[44px] transition-colors"
|
||||
icon={<Award className="w-5 h-5" />}
|
||||
text="Évaluer"
|
||||
title="Évaluer l'élève"
|
||||
disabled={!selectedPeriod}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats + Absences */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Attendance
|
||||
absences={absences}
|
||||
onToggleJustify={handleToggleJustify}
|
||||
onDelete={handleDeleteAbsence}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<GradesStatsCircle grades={grades} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<GradesDomainBarChart studentCompetencies={studentCompetencies} />
|
||||
</div>
|
||||
|
||||
{/* Évaluations par matière */}
|
||||
<div className="bg-neutral rounded-md shadow-sm border border-gray-200 p-4 md:p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<BookOpen className="w-6 h-6 text-primary" />
|
||||
<h2 className="font-headline text-xl font-semibold text-gray-800">
|
||||
Évaluations par matière
|
||||
</h2>
|
||||
</div>
|
||||
<EvaluationStudentView
|
||||
evaluations={evaluations}
|
||||
studentEvaluations={studentEvaluationsData}
|
||||
editable={true}
|
||||
onUpdateGrade={handleUpdateGrade}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -8,8 +8,8 @@ import {
|
||||
fetchStudentCompetencies,
|
||||
editStudentCompetencies,
|
||||
} from '@/app/actions/subscriptionAction';
|
||||
import SectionHeader from '@/components/SectionHeader';
|
||||
import { Award } from 'lucide-react';
|
||||
import { Award, ArrowLeft } from 'lucide-react';
|
||||
import logger from '@/utils/logger';
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
|
||||
@ -70,7 +70,7 @@ export default function StudentCompetenciesPage() {
|
||||
'success',
|
||||
'Succès'
|
||||
);
|
||||
router.back();
|
||||
router.push('/admin/grades');
|
||||
})
|
||||
.catch((error) => {
|
||||
showNotification(
|
||||
@ -83,11 +83,16 @@ export default function StudentCompetenciesPage() {
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col p-4">
|
||||
<SectionHeader
|
||||
icon={Award}
|
||||
title="Bilan de compétence"
|
||||
description="Evaluez les compétence de l'élève"
|
||||
/>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<button
|
||||
onClick={() => router.push('/admin/grades')}
|
||||
className="p-2 rounded hover:bg-gray-100 border border-gray-200 min-h-[44px] min-w-[44px] flex items-center justify-center transition-colors"
|
||||
aria-label="Retour à la fiche élève"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h1 className="font-headline text-xl font-bold text-gray-800">Bilan de compétence</h1>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<form
|
||||
className="flex-1 min-h-0 flex flex-col"
|
||||
@ -105,15 +110,6 @@ export default function StudentCompetenciesPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
text="Retour"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
router.back();
|
||||
}}
|
||||
className="mr-2 bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||
/>
|
||||
<Button text="Enregistrer" primary type="submit" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -11,8 +11,8 @@ import {
|
||||
Award,
|
||||
Calendar,
|
||||
Settings,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
MessageCircleHeart,
|
||||
} from 'lucide-react';
|
||||
|
||||
import Popup from '@/components/Popup';
|
||||
@ -25,20 +25,20 @@ import {
|
||||
FE_ADMIN_PLANNING_URL,
|
||||
FE_ADMIN_SETTINGS_URL,
|
||||
FE_ADMIN_MESSAGERIE_URL,
|
||||
FE_ADMIN_FEEDBACK_URL,
|
||||
} from '@/utils/Url';
|
||||
|
||||
import { disconnect } from '@/app/actions/authAction';
|
||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||
import { getGravatarUrl } from '@/utils/gravatar';
|
||||
import Footer from '@/components/Footer';
|
||||
import { getRightStr, RIGHTS } from '@/utils/rights';
|
||||
import MobileTopbar from '@/components/MobileTopbar';
|
||||
import { RIGHTS } from '@/utils/rights';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import ProfileSelector from '@/components/ProfileSelector';
|
||||
|
||||
export default function Layout({ children }) {
|
||||
const t = useTranslations('sidebar');
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const { profileRole, establishments, user, clearContext } =
|
||||
const { profileRole, establishments, clearContext } =
|
||||
useEstablishment();
|
||||
|
||||
const sidebarItems = {
|
||||
@ -84,6 +84,12 @@ export default function Layout({ children }) {
|
||||
url: FE_ADMIN_MESSAGERIE_URL,
|
||||
icon: MessageSquare,
|
||||
},
|
||||
feedback: {
|
||||
id: 'feedback',
|
||||
name: t('feedback'),
|
||||
url: FE_ADMIN_FEEDBACK_URL,
|
||||
icon: MessageCircleHeart,
|
||||
},
|
||||
settings: {
|
||||
id: 'settings',
|
||||
name: t('settings'),
|
||||
@ -97,45 +103,15 @@ export default function Layout({ children }) {
|
||||
const pathname = usePathname();
|
||||
const currentPage = pathname.split('/').pop();
|
||||
|
||||
const headerTitle = sidebarItems[currentPage]?.name || t('dashboard');
|
||||
|
||||
const softwareName = 'N3WT School';
|
||||
const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`;
|
||||
|
||||
const handleDisconnect = () => {
|
||||
setIsPopupVisible(true);
|
||||
};
|
||||
|
||||
const confirmDisconnect = () => {
|
||||
setIsPopupVisible(false);
|
||||
disconnect();
|
||||
clearContext();
|
||||
};
|
||||
|
||||
const dropdownItems = [
|
||||
{
|
||||
type: 'info',
|
||||
content: (
|
||||
<div className="px-4 py-2">
|
||||
<div className="font-medium">{user?.email || 'Utilisateur'}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{getRightStr(profileRole) || ''}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
content: <hr className="my-2 border-gray-200" />,
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Déconnexion',
|
||||
onClick: handleDisconnect,
|
||||
icon: LogOut,
|
||||
},
|
||||
];
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsSidebarOpen(!isSidebarOpen);
|
||||
};
|
||||
@ -145,18 +121,30 @@ export default function Layout({ children }) {
|
||||
setIsSidebarOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Filtrage dynamique des items de la sidebar selon le rôle
|
||||
let sidebarItemsToDisplay = Object.values(sidebarItems);
|
||||
if (profileRole === 0) {
|
||||
// Si pas admin, on retire "directory" et "settings"
|
||||
sidebarItemsToDisplay = sidebarItemsToDisplay.filter(
|
||||
(item) => item.id !== 'directory' && item.id !== 'settings'
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute requiredRight={[RIGHTS.ADMIN, RIGHTS.TEACHER]}>
|
||||
{/* Topbar mobile (hamburger + logo) */}
|
||||
<MobileTopbar onMenuClick={toggleSidebar} />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`absolute top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
|
||||
className={`absolute top-14 md:top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
|
||||
isSidebarOpen ? 'block' : 'hidden md:block'
|
||||
}`}
|
||||
>
|
||||
<Sidebar
|
||||
establishments={establishments}
|
||||
currentPage={currentPage}
|
||||
items={Object.values(sidebarItems)}
|
||||
items={sidebarItemsToDisplay}
|
||||
onCloseMobile={toggleSidebar}
|
||||
/>
|
||||
</div>
|
||||
@ -170,7 +158,7 @@ export default function Layout({ children }) {
|
||||
)}
|
||||
|
||||
{/* Main container */}
|
||||
<div className="absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-0 bottom-16 left-64 right-0">
|
||||
<div className="absolute overflow-auto bg-gradient-to-br from-primary/5 via-sky-50 to-primary/10 top-14 md:top-0 bottom-16 left-0 md:left-64 right-0">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,17 +1,10 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import SidebarTabs from '@/components/SidebarTabs';
|
||||
import EmailSender from '@/components/Admin/EmailSender';
|
||||
import InstantMessaging from '@/components/Admin/InstantMessaging';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
export default function MessageriePage({ csrfToken }) {
|
||||
const tabs = [
|
||||
{
|
||||
id: 'email',
|
||||
label: 'Envoyer un Mail',
|
||||
content: <EmailSender csrfToken={csrfToken} />,
|
||||
},
|
||||
{
|
||||
id: 'instant',
|
||||
label: 'Messagerie Instantanée',
|
||||
|
||||
@ -163,7 +163,7 @@ export default function DashboardPage() {
|
||||
if (isLoading) return <Loader />;
|
||||
|
||||
return (
|
||||
<div key={selectedEstablishmentId} className="p-6">
|
||||
<div key={selectedEstablishmentId} className="p-4 md:p-6">
|
||||
{/* Statistiques principales */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard
|
||||
@ -174,14 +174,14 @@ export default function DashboardPage() {
|
||||
<StatCard
|
||||
title={t('pendingRegistrations')}
|
||||
value={pendingRegistrationCount}
|
||||
icon={<Clock className="text-green-500" size={24} />}
|
||||
color="green"
|
||||
icon={<Clock className="text-tertiary" size={24} />}
|
||||
color="tertiary"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('structureCapacity')}
|
||||
value={selectedEstablishmentTotalCapacity}
|
||||
icon={<School className="text-green-500" size={24} />}
|
||||
color="emerald"
|
||||
icon={<School className="text-primary" size={24} />}
|
||||
color="primary"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('capacityRate')}
|
||||
@ -200,12 +200,12 @@ export default function DashboardPage() {
|
||||
{/* Colonne de gauche : Graphique des inscriptions + Présence */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Graphique des inscriptions */}
|
||||
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
|
||||
<h2 className="text-lg font-semibold mb-6">
|
||||
<div className="bg-neutral p-4 md:p-6 rounded-md shadow-sm border border-gray-100 flex-1">
|
||||
<h2 className="font-headline text-lg font-semibold mb-4 md:mb-6">
|
||||
{t('inscriptionTrends')}
|
||||
</h2>
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="flex-1 p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-6 mt-4">
|
||||
<div className="flex-1">
|
||||
<LineChart data={monthlyRegistrations} />
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
@ -214,14 +214,14 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
{/* Présence et assiduité */}
|
||||
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
|
||||
<div className="bg-neutral p-4 md:p-6 rounded-md shadow-sm border border-gray-100 flex-1">
|
||||
<Attendance absences={absencesToday} readOnly={true} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colonne de droite : Événements à venir */}
|
||||
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1 h-full">
|
||||
<h2 className="text-lg font-semibold mb-4">{t('upcomingEvents')}</h2>
|
||||
<div className="bg-neutral p-4 md:p-6 rounded-md shadow-sm border border-gray-100 flex-1 h-full">
|
||||
<h2 className="font-headline text-lg font-semibold mb-4">{t('upcomingEvents')}</h2>
|
||||
{upcomingEvents.map((event, index) => (
|
||||
<EventCard key={index} {...event} />
|
||||
))}
|
||||
|
||||
@ -9,9 +9,11 @@ import EventModal from '@/components/Calendar/EventModal';
|
||||
import ScheduleNavigation from '@/components/Calendar/ScheduleNavigation';
|
||||
import { useState } from 'react';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { usePlanning } from '@/context/PlanningContext';
|
||||
|
||||
export default function Page() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [eventData, setEventData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
@ -28,17 +30,24 @@ export default function Page() {
|
||||
});
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
|
||||
const PlanningContent = ({ isDrawerOpen, setIsDrawerOpen, isModalOpen, setIsModalOpen, eventData, setEventData }) => {
|
||||
const { selectedSchedule, schedules } = usePlanning();
|
||||
|
||||
const initializeNewEvent = (date = new Date()) => {
|
||||
// S'assurer que date est un objet Date valide
|
||||
const eventDate = date instanceof Date ? date : new Date();
|
||||
|
||||
const selected =
|
||||
schedules.find((schedule) => Number(schedule.id) === Number(selectedSchedule)) ||
|
||||
schedules[0];
|
||||
|
||||
setEventData({
|
||||
title: '',
|
||||
description: '',
|
||||
start: eventDate.toISOString(),
|
||||
end: new Date(eventDate.getTime() + 2 * 60 * 60 * 1000).toISOString(),
|
||||
location: '',
|
||||
planning: '', // Ne pas définir de valeur par défaut ici non plus
|
||||
planning: selected?.id || '',
|
||||
color: selected?.color || '',
|
||||
recursionType: RecurrenceType.NONE,
|
||||
selectedDays: [],
|
||||
recursionEnd: new Date(
|
||||
@ -51,18 +60,18 @@ export default function Page() {
|
||||
};
|
||||
|
||||
return (
|
||||
<PlanningProvider
|
||||
establishmentId={selectedEstablishmentId}
|
||||
modeSet={PlanningModes.PLANNING}
|
||||
>
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<ScheduleNavigation />
|
||||
<ScheduleNavigation
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => setIsDrawerOpen(false)}
|
||||
/>
|
||||
<Calendar
|
||||
onDateClick={initializeNewEvent}
|
||||
onEventClick={(event) => {
|
||||
setEventData(event);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
onOpenDrawer={() => setIsDrawerOpen(true)}
|
||||
/>
|
||||
<EventModal
|
||||
isOpen={isModalOpen}
|
||||
@ -71,6 +80,22 @@ export default function Page() {
|
||||
setEventData={setEventData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PlanningProvider
|
||||
establishmentId={selectedEstablishmentId}
|
||||
modeSet={PlanningModes.PLANNING}
|
||||
>
|
||||
<PlanningContent
|
||||
isDrawerOpen={isDrawerOpen}
|
||||
setIsDrawerOpen={setIsDrawerOpen}
|
||||
isModalOpen={isModalOpen}
|
||||
setIsModalOpen={setIsModalOpen}
|
||||
eventData={eventData}
|
||||
setEventData={setEventData}
|
||||
/>
|
||||
</PlanningProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Tab from '@/components/Tab';
|
||||
import TabContent from '@/components/TabContent';
|
||||
import Button from '@/components/Form/Button';
|
||||
import InputText from '@/components/Form/InputText';
|
||||
import CheckBox from '@/components/Form/CheckBox'; // Import du composant CheckBox
|
||||
@ -13,13 +11,8 @@ import {
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { useCsrfToken } from '@/context/CsrfContext'; // Import du hook pour récupérer le csrfToken
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
import { useSearchParams } from 'next/navigation'; // Ajoute cet import
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState('smtp');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [smtpServer, setSmtpServer] = useState('');
|
||||
const [smtpPort, setSmtpPort] = useState('');
|
||||
const [smtpUser, setSmtpUser] = useState('');
|
||||
@ -29,23 +22,10 @@ export default function SettingsPage() {
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
const csrfToken = useCsrfToken(); // Récupération du csrfToken
|
||||
const { showNotification } = useNotification();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const handleTabClick = (tab) => {
|
||||
setActiveTab(tab);
|
||||
};
|
||||
|
||||
// Ajout : sélection automatique de l'onglet via l'ancre ou le paramètre de recherche
|
||||
useEffect(() => {
|
||||
const tabParam = searchParams.get('tab');
|
||||
if (tabParam === 'smtp') {
|
||||
setActiveTab('smtp');
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Charger les paramètres SMTP existants
|
||||
useEffect(() => {
|
||||
if (activeTab === 'smtp') {
|
||||
if (csrfToken && selectedEstablishmentId) {
|
||||
fetchSmtpSettings(csrfToken, selectedEstablishmentId) // Passer le csrfToken ici
|
||||
.then((data) => {
|
||||
setSmtpServer(data.smtp_server || '');
|
||||
@ -75,7 +55,7 @@ export default function SettingsPage() {
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [activeTab, csrfToken]); // Ajouter csrfToken comme dépendance
|
||||
}, [csrfToken, selectedEstablishmentId]);
|
||||
|
||||
const handleSmtpServerChange = (e) => {
|
||||
setSmtpServer(e.target.value);
|
||||
@ -128,16 +108,14 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex space-x-4 mb-4">
|
||||
<Tab
|
||||
text="Paramètres SMTP"
|
||||
active={activeTab === 'smtp'}
|
||||
onClick={() => handleTabClick('smtp')}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<TabContent isActive={activeTab === 'smtp'}>
|
||||
<div className="p-6">
|
||||
<h1 className="font-headline text-2xl font-bold text-gray-900 mb-6">
|
||||
Paramètres
|
||||
</h1>
|
||||
<div className="bg-white rounded-md border border-gray-200 shadow-sm p-6">
|
||||
<h2 className="font-headline text-lg font-semibold text-gray-800 mb-4">
|
||||
Paramètres SMTP
|
||||
</h2>
|
||||
<form onSubmit={handleSmtpSubmit}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InputText
|
||||
@ -187,7 +165,6 @@ export default function SettingsPage() {
|
||||
className="mt-6"
|
||||
></Button>
|
||||
</form>
|
||||
</TabContent>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
225
Front-End/src/app/[locale]/admin/structure/FormBuilder/page.js
Normal file
225
Front-End/src/app/[locale]/admin/structure/FormBuilder/page.js
Normal file
@ -0,0 +1,225 @@
|
||||
'use client';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
import {
|
||||
fetchRegistrationFileGroups,
|
||||
fetchRegistrationSchoolFileMasterById,
|
||||
createRegistrationSchoolFileMaster,
|
||||
editRegistrationSchoolFileMaster,
|
||||
} from '@/app/actions/registerFileGroupAction';
|
||||
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||
import logger from '@/utils/logger';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
import { FE_ADMIN_STRUCTURE_URL } from '@/utils/Url';
|
||||
|
||||
export default function FormBuilderPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
const csrfToken = useCsrfToken();
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const formId = searchParams.get('id');
|
||||
const preGroupId = searchParams.get('groupId');
|
||||
const isEditing = !!formId;
|
||||
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [initialData, setInitialData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploadedFile, setUploadedFile] = useState(null);
|
||||
const [existingFileUrl, setExistingFileUrl] = useState(null);
|
||||
|
||||
const normalizeBackendFile = (rawFile, rawFileUrl) => {
|
||||
if (typeof rawFileUrl === 'string' && rawFileUrl.trim()) {
|
||||
return rawFileUrl;
|
||||
}
|
||||
|
||||
if (typeof rawFile === 'string' && rawFile.trim()) {
|
||||
return rawFile;
|
||||
}
|
||||
|
||||
if (rawFile && typeof rawFile === 'object') {
|
||||
if (typeof rawFile.url === 'string' && rawFile.url.trim()) {
|
||||
return rawFile.url;
|
||||
}
|
||||
if (typeof rawFile.path === 'string' && rawFile.path.trim()) {
|
||||
return rawFile.path;
|
||||
}
|
||||
if (typeof rawFile.name === 'string' && rawFile.name.trim()) {
|
||||
return rawFile.name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const previewFileUrl = useMemo(() => {
|
||||
if (uploadedFile instanceof File) {
|
||||
return URL.createObjectURL(uploadedFile);
|
||||
}
|
||||
return existingFileUrl || null;
|
||||
}, [uploadedFile, existingFileUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewFileUrl && previewFileUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewFileUrl);
|
||||
}
|
||||
};
|
||||
}, [previewFileUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedEstablishmentId) return;
|
||||
|
||||
Promise.all([
|
||||
fetchRegistrationFileGroups(selectedEstablishmentId),
|
||||
formId ? fetchRegistrationSchoolFileMasterById(formId) : Promise.resolve(null),
|
||||
])
|
||||
.then(([groupsData, formData]) => {
|
||||
setGroups(groupsData || []);
|
||||
if (formData) {
|
||||
setInitialData(formData);
|
||||
const resolvedFile = normalizeBackendFile(
|
||||
formData.file,
|
||||
formData.file_url
|
||||
);
|
||||
if (resolvedFile) {
|
||||
setExistingFileUrl(resolvedFile);
|
||||
}
|
||||
} else if (preGroupId) {
|
||||
setInitialData({ groups: [{ id: Number(preGroupId) }] });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('Error loading FormBuilder data:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [selectedEstablishmentId, formId, preGroupId]);
|
||||
|
||||
const buildFormData = async (name, group_ids, formMasterData) => {
|
||||
const dataToSend = new FormData();
|
||||
dataToSend.append(
|
||||
'data',
|
||||
JSON.stringify({
|
||||
name,
|
||||
groups: group_ids,
|
||||
formMasterData,
|
||||
establishment: selectedEstablishmentId,
|
||||
})
|
||||
);
|
||||
|
||||
if (uploadedFile instanceof File) {
|
||||
const ext =
|
||||
uploadedFile.name.lastIndexOf('.') !== -1
|
||||
? uploadedFile.name.substring(uploadedFile.name.lastIndexOf('.'))
|
||||
: '';
|
||||
const cleanName = (name || 'document')
|
||||
.replace(/[^a-zA-Z0-9_\-]/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
dataToSend.append('file', uploadedFile, `${cleanName}${ext}`);
|
||||
} else if (existingFileUrl && isEditing) {
|
||||
const lastDot = existingFileUrl.lastIndexOf('.');
|
||||
const ext = lastDot !== -1 ? existingFileUrl.substring(lastDot) : '';
|
||||
const cleanName = (name || 'document')
|
||||
.replace(/[^a-zA-Z0-9_\-]/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
try {
|
||||
const resp = await fetch(getSecureFileUrl(existingFileUrl));
|
||||
if (resp.ok) {
|
||||
const blob = await resp.blob();
|
||||
dataToSend.append('file', blob, `${cleanName}${ext}`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Could not re-fetch existing file:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return dataToSend;
|
||||
};
|
||||
|
||||
const handleSave = async ({ name, group_ids, formMasterData, id }) => {
|
||||
const hasFileField = (formMasterData?.fields || []).some(
|
||||
(field) => field.type === 'file'
|
||||
);
|
||||
const hasUploadedDocument =
|
||||
uploadedFile instanceof File || Boolean(existingFileUrl);
|
||||
|
||||
if (hasFileField && !hasUploadedDocument) {
|
||||
showNotification(
|
||||
'Un document PDF doit être uploadé si le formulaire contient un champ fichier.',
|
||||
'error',
|
||||
'Erreur'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataToSend = await buildFormData(name, group_ids, formMasterData);
|
||||
if (isEditing) {
|
||||
await editRegistrationSchoolFileMaster(id || formId, dataToSend, csrfToken);
|
||||
showNotification(
|
||||
`Le formulaire "${name}" a été modifié avec succès.`,
|
||||
'success',
|
||||
'Succès'
|
||||
);
|
||||
} else {
|
||||
await createRegistrationSchoolFileMaster(dataToSend, csrfToken);
|
||||
showNotification(
|
||||
`Le formulaire "${name}" a été créé avec succès.`,
|
||||
'success',
|
||||
'Succès'
|
||||
);
|
||||
}
|
||||
router.push(FE_ADMIN_STRUCTURE_URL);
|
||||
} catch (err) {
|
||||
logger.error('Error saving form:', err);
|
||||
showNotification('Erreur lors de la sauvegarde du formulaire', 'error', 'Erreur');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<p className="text-gray-500">Chargement...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen bg-neutral">
|
||||
{/* Header sticky */}
|
||||
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-4 py-3 flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push(FE_ADMIN_STRUCTURE_URL)}
|
||||
className="flex items-center gap-2 text-primary hover:text-secondary font-label font-medium transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Retour
|
||||
</button>
|
||||
<h1 className="font-headline text-lg font-headline font-semibold text-gray-800">
|
||||
{isEditing ? 'Modifier le formulaire' : 'Créer un formulaire personnalisé'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="max-w-5xl mx-auto px-4 py-6 space-y-4">
|
||||
{/* FormTemplateBuilder */}
|
||||
<FormTemplateBuilder
|
||||
onSave={handleSave}
|
||||
initialData={initialData}
|
||||
groups={groups}
|
||||
isEditing={isEditing}
|
||||
masterFile={previewFileUrl}
|
||||
onMasterFileUpload={(file) => setUploadedFile(file)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Users, Layers, CheckCircle, Clock, XCircle } from 'lucide-react';
|
||||
import {
|
||||
Users,
|
||||
Layers,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
ClipboardList,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import Table from '@/components/Table';
|
||||
import Popup from '@/components/Popup';
|
||||
import { fetchClasse } from '@/app/actions/schoolAction';
|
||||
import {
|
||||
fetchClasse,
|
||||
fetchSpecialities,
|
||||
fetchEvaluations,
|
||||
createEvaluation,
|
||||
updateEvaluation,
|
||||
deleteEvaluation,
|
||||
fetchStudentEvaluations,
|
||||
saveStudentEvaluations,
|
||||
deleteStudentEvaluation,
|
||||
} from '@/app/actions/schoolAction';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import logger from '@/utils/logger';
|
||||
import { useClasses } from '@/context/ClassesContext';
|
||||
@ -17,10 +35,16 @@ import {
|
||||
editAbsences,
|
||||
deleteAbsences,
|
||||
} from '@/app/actions/subscriptionAction';
|
||||
import {
|
||||
EvaluationForm,
|
||||
EvaluationList,
|
||||
EvaluationGradeTable,
|
||||
} from '@/components/Evaluation';
|
||||
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export default function Page() {
|
||||
const searchParams = useSearchParams();
|
||||
@ -38,8 +62,54 @@ export default function Page() {
|
||||
const [fetchedAbsences, setFetchedAbsences] = useState({}); // Absences récupérées depuis le backend
|
||||
const [formAbsences, setFormAbsences] = useState({}); // Absences modifiées localement
|
||||
|
||||
// Tab system
|
||||
const [activeTab, setActiveTab] = useState('attendance'); // 'attendance' ou 'evaluations'
|
||||
|
||||
// Evaluation states
|
||||
const [specialities, setSpecialities] = useState([]);
|
||||
const [evaluations, setEvaluations] = useState([]);
|
||||
const [studentEvaluations, setStudentEvaluations] = useState([]);
|
||||
const [showEvaluationForm, setShowEvaluationForm] = useState(false);
|
||||
const [selectedEvaluation, setSelectedEvaluation] = useState(null);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState(null);
|
||||
const [editingEvaluation, setEditingEvaluation] = useState(null);
|
||||
|
||||
const csrfToken = useCsrfToken();
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
|
||||
useEstablishment();
|
||||
|
||||
// Périodes selon la fréquence d'évaluation
|
||||
const getPeriods = () => {
|
||||
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
|
||||
const nextYear = (year + 1).toString();
|
||||
const schoolYear = `${year}-${nextYear}`;
|
||||
|
||||
if (selectedEstablishmentEvaluationFrequency === 1) {
|
||||
return [
|
||||
{ label: 'Trimestre 1', value: `T1_${schoolYear}` },
|
||||
{ label: 'Trimestre 2', value: `T2_${schoolYear}` },
|
||||
{ label: 'Trimestre 3', value: `T3_${schoolYear}` },
|
||||
];
|
||||
}
|
||||
if (selectedEstablishmentEvaluationFrequency === 2) {
|
||||
return [
|
||||
{ label: 'Semestre 1', value: `S1_${schoolYear}` },
|
||||
{ label: 'Semestre 2', value: `S2_${schoolYear}` },
|
||||
];
|
||||
}
|
||||
if (selectedEstablishmentEvaluationFrequency === 3) {
|
||||
return [{ label: 'Année', value: `A_${schoolYear}` }];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// Auto-select current period
|
||||
useEffect(() => {
|
||||
const periods = getPeriods();
|
||||
if (periods.length > 0 && !selectedPeriod) {
|
||||
setSelectedPeriod(periods[0].value);
|
||||
}
|
||||
}, [selectedEstablishmentEvaluationFrequency]);
|
||||
|
||||
// AbsenceMoment constants
|
||||
const AbsenceMoment = {
|
||||
@ -158,6 +228,119 @@ export default function Page() {
|
||||
}
|
||||
}, [filteredStudents, fetchedAbsences]);
|
||||
|
||||
// Load specialities for evaluations (filtered by current school year)
|
||||
useEffect(() => {
|
||||
if (selectedEstablishmentId) {
|
||||
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
|
||||
const currentSchoolYear = `${year}-${year + 1}`;
|
||||
fetchSpecialities(selectedEstablishmentId, currentSchoolYear)
|
||||
.then((data) => setSpecialities(data))
|
||||
.catch((error) =>
|
||||
logger.error('Erreur lors du chargement des matières:', error)
|
||||
);
|
||||
}
|
||||
}, [selectedEstablishmentId]);
|
||||
|
||||
// Load evaluations when tab is active and period is selected
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeTab === 'evaluations' &&
|
||||
selectedEstablishmentId &&
|
||||
schoolClassId &&
|
||||
selectedPeriod
|
||||
) {
|
||||
fetchEvaluations(selectedEstablishmentId, schoolClassId, selectedPeriod)
|
||||
.then((data) => setEvaluations(data))
|
||||
.catch((error) =>
|
||||
logger.error('Erreur lors du chargement des évaluations:', error)
|
||||
);
|
||||
}
|
||||
}, [activeTab, selectedEstablishmentId, schoolClassId, selectedPeriod]);
|
||||
|
||||
// Load student evaluations when grading
|
||||
useEffect(() => {
|
||||
if (selectedEvaluation && schoolClassId) {
|
||||
fetchStudentEvaluations(null, selectedEvaluation.id, null, schoolClassId)
|
||||
.then((data) => setStudentEvaluations(data))
|
||||
.catch((error) =>
|
||||
logger.error('Erreur lors du chargement des notes:', error)
|
||||
);
|
||||
}
|
||||
}, [selectedEvaluation, schoolClassId]);
|
||||
|
||||
// Handlers for evaluations
|
||||
const handleCreateEvaluation = async (data) => {
|
||||
try {
|
||||
await createEvaluation(data, csrfToken);
|
||||
showNotification('Évaluation créée avec succès', 'success', 'Succès');
|
||||
setShowEvaluationForm(false);
|
||||
// Reload evaluations
|
||||
const updatedEvaluations = await fetchEvaluations(
|
||||
selectedEstablishmentId,
|
||||
schoolClassId,
|
||||
selectedPeriod
|
||||
);
|
||||
setEvaluations(updatedEvaluations);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la création:', error);
|
||||
showNotification('Erreur lors de la création', 'error', 'Erreur');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditEvaluation = (evaluation) => {
|
||||
setEditingEvaluation(evaluation);
|
||||
setShowEvaluationForm(true);
|
||||
};
|
||||
|
||||
const handleUpdateEvaluation = async (data) => {
|
||||
try {
|
||||
await updateEvaluation(editingEvaluation.id, data, csrfToken);
|
||||
showNotification('Évaluation modifiée avec succès', 'success', 'Succès');
|
||||
setShowEvaluationForm(false);
|
||||
setEditingEvaluation(null);
|
||||
// Reload evaluations
|
||||
const updatedEvaluations = await fetchEvaluations(
|
||||
selectedEstablishmentId,
|
||||
schoolClassId,
|
||||
selectedPeriod
|
||||
);
|
||||
setEvaluations(updatedEvaluations);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la modification:', error);
|
||||
showNotification('Erreur lors de la modification', 'error', 'Erreur');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEvaluation = async (evaluationId) => {
|
||||
await deleteEvaluation(evaluationId, csrfToken);
|
||||
// Reload evaluations
|
||||
const updatedEvaluations = await fetchEvaluations(
|
||||
selectedEstablishmentId,
|
||||
schoolClassId,
|
||||
selectedPeriod
|
||||
);
|
||||
setEvaluations(updatedEvaluations);
|
||||
};
|
||||
|
||||
const handleSaveGrades = async (gradesData) => {
|
||||
await saveStudentEvaluations(gradesData, csrfToken);
|
||||
// Reload student evaluations
|
||||
const updatedStudentEvaluations = await fetchStudentEvaluations(
|
||||
null,
|
||||
selectedEvaluation.id,
|
||||
null,
|
||||
schoolClassId
|
||||
);
|
||||
setStudentEvaluations(updatedStudentEvaluations);
|
||||
};
|
||||
|
||||
const handleDeleteGrade = async (studentEvalId) => {
|
||||
await deleteStudentEvaluation(studentEvalId, csrfToken);
|
||||
setStudentEvaluations((prev) =>
|
||||
prev.filter((se) => se.id !== studentEvalId)
|
||||
);
|
||||
};
|
||||
|
||||
const handleLevelClick = (label) => {
|
||||
setSelectedLevels(
|
||||
(prev) =>
|
||||
@ -413,14 +596,16 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<h1 className="text-2xl font-bold">{classe?.atmosphere_name}</h1>
|
||||
<h1 className="font-headline text-2xl font-bold">
|
||||
{classe?.atmosphere_name}
|
||||
</h1>
|
||||
|
||||
{/* Section Niveaux et Enseignants */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Section Niveaux */}
|
||||
<div className="bg-white p-4 rounded-lg shadow-md">
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center">
|
||||
<Layers className="w-6 h-6 mr-2" />
|
||||
<div className="bg-white p-4 rounded-md shadow-sm">
|
||||
<h2 className="font-headline text-xl font-semibold mb-4 flex items-center">
|
||||
<Layers className="w-6 h-6 mr-2 text-primary" />
|
||||
Niveaux
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
@ -429,24 +614,24 @@ export default function Page() {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{classe?.levels?.length > 0 ? (
|
||||
getNiveauxLabels(classe.levels).map((label, index) => (
|
||||
<span
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleLevelClick(label)} // Gérer le clic sur un niveau
|
||||
className={`px-4 py-2 rounded-full cursor-pointer border transition-all duration-200 ${
|
||||
onClick={() => handleLevelClick(label)}
|
||||
className={`px-4 py-2 rounded font-label font-medium cursor-pointer border transition-colors min-h-[44px] ${
|
||||
selectedLevels.includes(label)
|
||||
? 'bg-emerald-200 text-emerald-800 border-emerald-300 shadow-md'
|
||||
? 'bg-primary/20 text-secondary border-primary/30 shadow-sm'
|
||||
: 'bg-gray-200 text-gray-800 border-gray-300 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
{selectedLevels.includes(label) ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-600" />
|
||||
<CheckCircle className="w-4 h-4 text-primary" />
|
||||
{label}
|
||||
</span>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className="text-gray-500">Aucun niveau associé</span>
|
||||
@ -455,9 +640,9 @@ export default function Page() {
|
||||
</div>
|
||||
|
||||
{/* Section Enseignants */}
|
||||
<div className="bg-white p-4 rounded-lg shadow-md">
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center">
|
||||
<Users className="w-6 h-6 mr-2" />
|
||||
<div className="bg-white p-4 rounded-md shadow-sm">
|
||||
<h2 className="font-headline text-xl font-semibold mb-4 flex items-center">
|
||||
<Users className="w-6 h-6 mr-2 text-primary" />
|
||||
Enseignants
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">Liste des enseignants</p>
|
||||
@ -465,7 +650,7 @@ export default function Page() {
|
||||
{classe?.teachers_details?.map((teacher) => (
|
||||
<span
|
||||
key={teacher.id}
|
||||
className="px-3 py-1 bg-emerald-200 rounded-full text-emerald-800"
|
||||
className="px-3 py-1 bg-primary/20 rounded text-secondary font-label text-sm"
|
||||
>
|
||||
{teacher.last_name} {teacher.first_name}
|
||||
</span>
|
||||
@ -474,15 +659,50 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="bg-white rounded-md shadow-sm overflow-hidden">
|
||||
<div className="flex border-b border-gray-200">
|
||||
<button
|
||||
onClick={() => setActiveTab('attendance')}
|
||||
className={`flex-1 py-3 px-4 text-center font-label font-medium transition-colors min-h-[44px] ${
|
||||
activeTab === 'attendance'
|
||||
? 'text-primary border-b-2 border-primary bg-primary/5'
|
||||
: 'text-gray-500 hover:text-secondary hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Clock className="w-5 h-5" />
|
||||
Appel du jour
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('evaluations')}
|
||||
className={`flex-1 py-3 px-4 text-center font-label font-medium transition-colors min-h-[44px] ${
|
||||
activeTab === 'evaluations'
|
||||
? 'text-primary border-b-2 border-primary bg-primary/5'
|
||||
: 'text-gray-500 hover:text-secondary hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<ClipboardList className="w-5 h-5" />
|
||||
Évaluations
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content: Attendance */}
|
||||
{activeTab === 'attendance' && (
|
||||
<>
|
||||
{/* Affichage de la date du jour */}
|
||||
<div className="flex justify-between items-center mb-4 bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="flex justify-between items-center mb-4 bg-white p-4 rounded-md shadow-sm">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-emerald-100 text-emerald-600 rounded-full">
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-primary/10 text-primary rounded">
|
||||
<Clock className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-800">
|
||||
<h2 className="font-headline text-lg font-semibold text-gray-800">
|
||||
Appel du jour :{' '}
|
||||
<span className="ml-2 text-emerald-600">{today}</span>
|
||||
<span className="ml-2 text-primary">{today}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
@ -491,14 +711,14 @@ export default function Page() {
|
||||
text="Faire l'appel"
|
||||
onClick={handleToggleAttendanceMode}
|
||||
primary
|
||||
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all"
|
||||
className="px-4 py-2 bg-primary text-white font-label font-medium rounded shadow-sm hover:bg-secondary transition-colors min-h-[44px]"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
text="Valider l'appel"
|
||||
onClick={handleValidateAttendance}
|
||||
primary
|
||||
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all"
|
||||
className="px-4 py-2 bg-primary text-white font-label font-medium rounded shadow-sm hover:bg-secondary transition-colors min-h-[44px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -537,7 +757,9 @@ export default function Page() {
|
||||
<CheckBox
|
||||
item={{ id: row.id }}
|
||||
formData={{
|
||||
attendance: attendance[row.id] ? [row.id] : [],
|
||||
attendance: attendance[row.id]
|
||||
? [row.id]
|
||||
: [],
|
||||
}}
|
||||
handleChange={() =>
|
||||
handleAttendanceChange(row.id)
|
||||
@ -568,10 +790,10 @@ export default function Page() {
|
||||
|
||||
{/* Détails absence/retard */}
|
||||
{!attendance[row.id] && (
|
||||
<div className="w-full bg-emerald-50 border border-emerald-100 rounded-lg p-3 mt-2 shadow-sm">
|
||||
<div className="w-full bg-primary/5 border border-primary/10 rounded-lg p-3 mt-2 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-emerald-500" />
|
||||
<span className="font-semibold text-emerald-700 text-sm">
|
||||
<Clock className="w-4 h-4 text-primary" />
|
||||
<span className="font-semibold text-secondary text-sm">
|
||||
Motif d'absence
|
||||
</span>
|
||||
</div>
|
||||
@ -625,7 +847,9 @@ export default function Page() {
|
||||
type="text"
|
||||
className="border rounded px-2 py-1 text-sm w-full"
|
||||
placeholder="Commentaire"
|
||||
value={formAbsences[row.id]?.commentaire || ''}
|
||||
value={
|
||||
formAbsences[row.id]?.commentaire || ''
|
||||
}
|
||||
onChange={(e) =>
|
||||
setFormAbsences((prev) => ({
|
||||
...prev,
|
||||
@ -642,7 +866,8 @@ export default function Page() {
|
||||
<CheckBox
|
||||
item={{ id: `justified-${row.id}` }}
|
||||
formData={{
|
||||
justified: !!formAbsences[row.id]?.justified,
|
||||
justified:
|
||||
!!formAbsences[row.id]?.justified,
|
||||
}}
|
||||
handleChange={() =>
|
||||
setFormAbsences((prev) => ({
|
||||
@ -673,7 +898,8 @@ export default function Page() {
|
||||
formAbsences[row.id] ||
|
||||
Object.values(fetchedAbsences).find(
|
||||
(absence) =>
|
||||
absence.student === row.id && absence.day === today
|
||||
absence.student === row.id &&
|
||||
absence.day === today
|
||||
);
|
||||
|
||||
if (!absence) {
|
||||
@ -728,6 +954,90 @@ export default function Page() {
|
||||
]}
|
||||
data={filteredStudents} // Utiliser les élèves filtrés
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tab Content: Evaluations */}
|
||||
{activeTab === 'evaluations' && (
|
||||
<div className="space-y-4">
|
||||
{/* Header avec sélecteur de période et bouton d'ajout */}
|
||||
<div className="bg-white p-4 rounded-md shadow-sm border border-gray-200">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<ClipboardList className="w-6 h-6 text-primary" />
|
||||
<h2 className="font-headline text-lg font-semibold text-gray-800">
|
||||
Évaluations de la classe
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<div className="w-48">
|
||||
<SelectChoice
|
||||
name="period"
|
||||
placeHolder="Période"
|
||||
choices={getPeriods()}
|
||||
selected={selectedPeriod || ''}
|
||||
callback={(e) => setSelectedPeriod(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
primary
|
||||
text="Nouvelle évaluation"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={() => setShowEvaluationForm(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formulaire de création/édition d'évaluation */}
|
||||
{showEvaluationForm && (
|
||||
<EvaluationForm
|
||||
specialities={specialities}
|
||||
period={selectedPeriod}
|
||||
schoolClassId={parseInt(schoolClassId)}
|
||||
establishmentId={selectedEstablishmentId}
|
||||
initialValues={editingEvaluation}
|
||||
onSubmit={
|
||||
editingEvaluation
|
||||
? handleUpdateEvaluation
|
||||
: handleCreateEvaluation
|
||||
}
|
||||
onCancel={() => {
|
||||
setShowEvaluationForm(false);
|
||||
setEditingEvaluation(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Liste des évaluations */}
|
||||
<div className="bg-white p-4 rounded-md shadow-sm border border-gray-200">
|
||||
<EvaluationList
|
||||
evaluations={evaluations}
|
||||
onDelete={handleDeleteEvaluation}
|
||||
onEdit={handleEditEvaluation}
|
||||
onGradeStudents={(evaluation) =>
|
||||
setSelectedEvaluation(evaluation)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal de notation */}
|
||||
{selectedEvaluation && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="w-full max-w-4xl max-h-[90vh] overflow-auto">
|
||||
<EvaluationGradeTable
|
||||
evaluation={selectedEvaluation}
|
||||
students={filteredStudents}
|
||||
studentEvaluations={studentEvaluations}
|
||||
onSave={handleSaveGrades}
|
||||
onClose={() => setSelectedEvaluation(null)}
|
||||
onDeleteGrade={handleDeleteGrade}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Popup */}
|
||||
<Popup
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getCurrentSchoolYear } from '@/utils/Date';
|
||||
|
||||
import StructureManagement from '@/components/Structure/Configuration/StructureManagement';
|
||||
import ScheduleManagement from '@/components/Structure/Planning/ScheduleManagement';
|
||||
@ -31,6 +32,7 @@ import { fetchRegistrationSchoolFileMasters } from '@/app/actions/registerFileGr
|
||||
import logger from '@/utils/logger';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { PlanningProvider, PlanningModes } from '@/context/PlanningContext';
|
||||
import { updatePlanning } from '@/app/actions/planningAction';
|
||||
import CompetenciesList from '@/components/Structure/Competencies/CompetenciesList';
|
||||
|
||||
export default function Page() {
|
||||
@ -52,7 +54,14 @@ export default function Page() {
|
||||
);
|
||||
|
||||
const csrfToken = useCsrfToken();
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||
const currentSchoolYear = getCurrentSchoolYear();
|
||||
|
||||
const scheduleClasses = classes.filter(
|
||||
(classe) => classe?.school_year === currentSchoolYear
|
||||
);
|
||||
const scheduleSpecialities = specialities;
|
||||
const scheduleTeachers = teachers;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedEstablishmentId) {
|
||||
@ -259,20 +268,10 @@ export default function Page() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdatePlanning = (url, planningId, updatedData) => {
|
||||
fetch(`${url}/${planningId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: JSON.stringify(updatedData),
|
||||
credentials: 'include',
|
||||
})
|
||||
.then((response) => response.json())
|
||||
const handleUpdatePlanning = (planningId, updatedData) => {
|
||||
updatePlanning(planningId, updatedData, csrfToken)
|
||||
.then((data) => {
|
||||
logger.debug('Planning mis à jour avec succès :', data);
|
||||
//setDatas(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Erreur :', error);
|
||||
@ -308,14 +307,16 @@ export default function Page() {
|
||||
<ClassesProvider>
|
||||
<ScheduleManagement
|
||||
handleUpdatePlanning={handleUpdatePlanning}
|
||||
classes={classes}
|
||||
specialities={specialities}
|
||||
teachers={teachers}
|
||||
classes={scheduleClasses}
|
||||
specialities={scheduleSpecialities}
|
||||
teachers={scheduleTeachers}
|
||||
/>
|
||||
</ClassesProvider>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...(profileRole !== 0
|
||||
? [
|
||||
{
|
||||
id: 'Fees',
|
||||
label: 'Tarifs',
|
||||
@ -345,6 +346,8 @@ export default function Page() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'Files',
|
||||
label: 'Documents',
|
||||
@ -353,6 +356,7 @@ export default function Page() {
|
||||
<FilesGroupsManagement
|
||||
csrfToken={csrfToken}
|
||||
selectedEstablishmentId={selectedEstablishmentId}
|
||||
profileRole={profileRole}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { User, Mail } from 'lucide-react';
|
||||
import { User, Mail, Info } from 'lucide-react';
|
||||
import InputTextIcon from '@/components/Form/InputTextIcon';
|
||||
import ToggleSwitch from '@/components/Form/ToggleSwitch';
|
||||
import Button from '@/components/Form/Button';
|
||||
@ -34,14 +34,32 @@ import {
|
||||
import {
|
||||
fetchRegistrationFileGroups,
|
||||
fetchRegistrationSchoolFileMasters,
|
||||
fetchRegistrationParentFileMasters
|
||||
fetchRegistrationParentFileMasters,
|
||||
} from '@/app/actions/registerFileGroupAction';
|
||||
import { fetchProfiles } from '@/app/actions/authAction';
|
||||
import { useClasses } from '@/context/ClassesContext';
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
import { FE_ADMIN_SUBSCRIPTIONS_URL, BASE_URL } from '@/utils/Url';
|
||||
import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url';
|
||||
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
|
||||
function NoInfoAlert({ message }) {
|
||||
return (
|
||||
<div
|
||||
className="w-full rounded border border-orange-300 bg-orange-50 px-4 py-3 text-orange-800"
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="text-sm leading-6">
|
||||
<span className="font-semibold">Information :</span>{' '}
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CreateSubscriptionPage() {
|
||||
const [formData, setFormData] = useState({
|
||||
studentLastName: '',
|
||||
@ -71,6 +89,8 @@ export default function CreateSubscriptionPage() {
|
||||
const registerFormMoment = searchParams.get('school_year');
|
||||
|
||||
const [students, setStudents] = useState([]);
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
const [studentsPage, setStudentsPage] = useState(1);
|
||||
const [registrationDiscounts, setRegistrationDiscounts] = useState([]);
|
||||
const [tuitionDiscounts, setTuitionDiscounts] = useState([]);
|
||||
const [registrationFees, setRegistrationFees] = useState([]);
|
||||
@ -179,6 +199,10 @@ export default function CreateSubscriptionPage() {
|
||||
formDataRef.current = formData;
|
||||
}, [formData]);
|
||||
|
||||
useEffect(() => {
|
||||
setStudentsPage(1);
|
||||
}, [students]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!formData.guardianEmail) {
|
||||
// Si l'email est vide, réinitialiser existingProfileId et existingProfileInSchool
|
||||
@ -709,6 +733,12 @@ export default function CreateSubscriptionPage() {
|
||||
return finalAmount.toFixed(2);
|
||||
};
|
||||
|
||||
const studentsTotalPages = Math.ceil(students.length / ITEMS_PER_PAGE);
|
||||
const pagedStudents = students.slice(
|
||||
(studentsPage - 1) * ITEMS_PER_PAGE,
|
||||
studentsPage * ITEMS_PER_PAGE
|
||||
);
|
||||
|
||||
if (isLoading === true) {
|
||||
return <Loader />; // Affichez le composant Loader
|
||||
}
|
||||
@ -716,11 +746,11 @@ export default function CreateSubscriptionPage() {
|
||||
return (
|
||||
<div className="mx-auto p-12 space-y-12">
|
||||
{registerFormID ? (
|
||||
<h1 className="text-2xl font-bold">
|
||||
<h1 className="font-headline text-2xl font-bold">
|
||||
Modifier un dossier d'inscription
|
||||
</h1>
|
||||
) : (
|
||||
<h1 className="text-2xl font-bold">
|
||||
<h1 className="font-headline text-2xl font-bold">
|
||||
Créer un dossier d'inscription
|
||||
</h1>
|
||||
)}
|
||||
@ -869,7 +899,7 @@ export default function CreateSubscriptionPage() {
|
||||
{!isNewResponsable && (
|
||||
<div className="mt-4">
|
||||
<Table
|
||||
data={students}
|
||||
data={pagedStudents}
|
||||
columns={[
|
||||
{
|
||||
name: 'photo',
|
||||
@ -877,12 +907,12 @@ export default function CreateSubscriptionPage() {
|
||||
<div className="flex justify-center items-center">
|
||||
{row.photo ? (
|
||||
<a
|
||||
href={`${BASE_URL}${row.photo}`} // Lien vers la photo
|
||||
href={getSecureFileUrl(row.photo)} // Lien vers la photo
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src={`${BASE_URL}${row.photo}`}
|
||||
src={getSecureFileUrl(row.photo)}
|
||||
alt={`${row.first_name} ${row.last_name}`}
|
||||
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
|
||||
/>
|
||||
@ -923,15 +953,19 @@ export default function CreateSubscriptionPage() {
|
||||
}}
|
||||
rowClassName={(row) =>
|
||||
selectedStudent && selectedStudent.id === row.id
|
||||
? 'bg-emerald-600 text-white'
|
||||
? 'bg-primary text-white'
|
||||
: ''
|
||||
}
|
||||
selectedRows={selectedStudent ? [selectedStudent.id] : []} // Assurez-vous que selectedRows est un tableau
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
currentPage={studentsPage}
|
||||
totalPages={studentsTotalPages}
|
||||
onPageChange={setStudentsPage}
|
||||
/>
|
||||
|
||||
{selectedStudent && (
|
||||
<div className="mt-4">
|
||||
<h3 className="font-bold">
|
||||
<h3 className="font-headline font-bold">
|
||||
Responsables associés à {selectedStudent.last_name}{' '}
|
||||
{selectedStudent.first_name} :
|
||||
</h3>
|
||||
@ -986,22 +1020,13 @@ export default function CreateSubscriptionPage() {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
className="bg-orange-100 border border-orange-400 text-orange-700 px-4 py-3 rounded relative"
|
||||
role="alert"
|
||||
>
|
||||
<strong className="font-bold">Information</strong>
|
||||
<span className="block sm:inline">
|
||||
Aucune réduction n'a été créée sur les frais
|
||||
d'inscription.
|
||||
</span>
|
||||
</p>
|
||||
<NoInfoAlert message="Aucune réduction n'a été créée sur les frais d'inscription." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Montant total */}
|
||||
<div className="flex items-center justify-between bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-300 mt-4">
|
||||
<div className="flex items-center justify-between bg-gray-50 p-4 rounded-md shadow-sm border border-gray-300 mt-4">
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
Montant total des frais d'inscription :
|
||||
</span>
|
||||
@ -1011,15 +1036,7 @@ export default function CreateSubscriptionPage() {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p
|
||||
className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
|
||||
role="alert"
|
||||
>
|
||||
<strong className="font-bold">Attention!</strong>
|
||||
<span className="block sm:inline">
|
||||
Aucun frais d'inscription n'a été créé.
|
||||
</span>
|
||||
</p>
|
||||
<NoInfoAlert message="Aucun frais d'inscription n'a été créé." />
|
||||
)}
|
||||
</SectionTitle>
|
||||
|
||||
@ -1050,22 +1067,13 @@ export default function CreateSubscriptionPage() {
|
||||
handleDiscountSelection={handleTuitionDiscountSelection}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
className="bg-orange-100 border border-orange-400 text-orange-700 px-4 py-3 rounded relative"
|
||||
role="alert"
|
||||
>
|
||||
<strong className="font-bold">Information</strong>
|
||||
<span className="block sm:inline">
|
||||
Aucune réduction n'a été créée sur les frais de
|
||||
scolarité.
|
||||
</span>
|
||||
</p>
|
||||
<NoInfoAlert message="Aucune réduction n'a été créée sur les frais de scolarité." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Montant total */}
|
||||
<div className="flex items-center justify-between bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-300 mt-4">
|
||||
<div className="flex items-center justify-between bg-gray-50 p-4 rounded-md shadow-sm border border-gray-300 mt-4">
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
Montant total des frais de scolarité :
|
||||
</span>
|
||||
@ -1075,15 +1083,7 @@ export default function CreateSubscriptionPage() {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p
|
||||
className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
|
||||
role="alert"
|
||||
>
|
||||
<strong className="font-bold">Attention!</strong>
|
||||
<span className="block sm:inline">
|
||||
Aucun frais de scolarité n'a été créé.
|
||||
</span>
|
||||
</p>
|
||||
<NoInfoAlert message="Aucun frais de scolarité n'a été créé." />
|
||||
)}
|
||||
</SectionTitle>
|
||||
|
||||
@ -1136,7 +1136,7 @@ export default function CreateSubscriptionPage() {
|
||||
className={`px-6 py-2 rounded-md shadow ${
|
||||
isSubmitDisabled()
|
||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||
: 'bg-primary text-white hover:bg-primary'
|
||||
}`}
|
||||
primary
|
||||
disabled={isSubmitDisabled()}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Table from '@/components/Table';
|
||||
import Tab from '@/components/Tab';
|
||||
import SidebarTabs from '@/components/SidebarTabs';
|
||||
import Textarea from '@/components/Textarea';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import StatusLabel from '@/components/StatusLabel';
|
||||
import Popup from '@/components/Popup';
|
||||
@ -17,6 +18,8 @@ import {
|
||||
Plus,
|
||||
Upload,
|
||||
Eye,
|
||||
XCircle,
|
||||
Download,
|
||||
} from 'lucide-react';
|
||||
import Modal from '@/components/Modal';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
@ -34,8 +37,8 @@ import {
|
||||
FE_ADMIN_SUBSCRIPTIONS_EDIT_URL,
|
||||
FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL,
|
||||
FE_ADMIN_SUBSCRIPTIONS_CREATE_URL,
|
||||
BASE_URL,
|
||||
} from '@/utils/Url';
|
||||
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||
|
||||
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
@ -52,7 +55,9 @@ import {
|
||||
HISTORICAL_FILTER,
|
||||
} from '@/utils/constants';
|
||||
import AlertMessage from '@/components/AlertMessage';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
import { exportToCSV } from '@/utils/exportCSV';
|
||||
|
||||
export default function Page({ params: { locale } }) {
|
||||
const t = useTranslations('subscriptions');
|
||||
@ -83,12 +88,9 @@ export default function Page({ params: { locale } }) {
|
||||
const [totalHistorical, setTotalHistorical] = useState(0);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10); // Définir le nombre d'éléments par page
|
||||
|
||||
const [student, setStudent] = useState('');
|
||||
const [classes, setClasses] = useState([]);
|
||||
const [reloadFetch, setReloadFetch] = useState(false);
|
||||
|
||||
const [isOpenAddGuardian, setIsOpenAddGuardian] = useState(false);
|
||||
|
||||
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
||||
const [selectedRegisterForm, setSelectedRegisterForm] = useState([]);
|
||||
|
||||
@ -99,9 +101,54 @@ export default function Page({ params: { locale } }) {
|
||||
const [isSepaUploadModalOpen, setIsSepaUploadModalOpen] = useState(false);
|
||||
const [selectedRowForUpload, setSelectedRowForUpload] = useState(null);
|
||||
|
||||
// Refus popup state
|
||||
const [isRefusePopupOpen, setIsRefusePopupOpen] = useState(false);
|
||||
const [refuseReason, setRefuseReason] = useState('');
|
||||
const [rowToRefuse, setRowToRefuse] = useState(null);
|
||||
// Ouvre la popup de refus
|
||||
const openRefusePopup = (row) => {
|
||||
setRowToRefuse(row);
|
||||
setRefuseReason('');
|
||||
setIsRefusePopupOpen(true);
|
||||
};
|
||||
|
||||
// Valide le refus
|
||||
const handleRefuse = () => {
|
||||
if (!refuseReason.trim()) {
|
||||
showNotification(
|
||||
'Merci de préciser la raison du refus.',
|
||||
'error',
|
||||
'Erreur'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
'data',
|
||||
JSON.stringify({
|
||||
status: RegistrationFormStatus.STATUS_ARCHIVED,
|
||||
notes: refuseReason,
|
||||
})
|
||||
);
|
||||
|
||||
editRegisterForm(rowToRefuse.student.id, formData, csrfToken)
|
||||
.then(() => {
|
||||
showNotification(
|
||||
'Le dossier a été refusé et archivé.',
|
||||
'success',
|
||||
'Succès'
|
||||
);
|
||||
setReloadFetch(true);
|
||||
setIsRefusePopupOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
showNotification('Erreur lors du refus du dossier.', 'error', 'Erreur');
|
||||
});
|
||||
};
|
||||
|
||||
const csrfToken = useCsrfToken();
|
||||
const router = useRouter();
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const openSepaUploadModal = (row) => {
|
||||
@ -119,6 +166,92 @@ export default function Page({ params: { locale } }) {
|
||||
setIsFilesModalOpen(true);
|
||||
};
|
||||
|
||||
// Export CSV
|
||||
const handleExportCSV = () => {
|
||||
const dataToExport =
|
||||
activeTab === CURRENT_YEAR_FILTER
|
||||
? registrationFormsDataCurrentYear
|
||||
: activeTab === NEXT_YEAR_FILTER
|
||||
? registrationFormsDataNextYear
|
||||
: registrationFormsDataHistorical;
|
||||
|
||||
const exportColumns = [
|
||||
{
|
||||
key: 'student',
|
||||
label: 'Nom',
|
||||
transform: (value) => value?.last_name || '',
|
||||
},
|
||||
{
|
||||
key: 'student',
|
||||
label: 'Prénom',
|
||||
transform: (value) => value?.first_name || '',
|
||||
},
|
||||
{
|
||||
key: 'student',
|
||||
label: 'Date de naissance',
|
||||
transform: (value) => value?.birth_date || '',
|
||||
},
|
||||
{
|
||||
key: 'student',
|
||||
label: 'Email contact',
|
||||
transform: (value) =>
|
||||
value?.guardians?.[0]?.associated_profile_email || '',
|
||||
},
|
||||
{
|
||||
key: 'student',
|
||||
label: 'Téléphone contact',
|
||||
transform: (value) => value?.guardians?.[0]?.phone || '',
|
||||
},
|
||||
{
|
||||
key: 'student',
|
||||
label: 'Nom responsable 1',
|
||||
transform: (value) => value?.guardians?.[0]?.last_name || '',
|
||||
},
|
||||
{
|
||||
key: 'student',
|
||||
label: 'Prénom responsable 1',
|
||||
transform: (value) => value?.guardians?.[0]?.first_name || '',
|
||||
},
|
||||
{
|
||||
key: 'student',
|
||||
label: 'Nom responsable 2',
|
||||
transform: (value) => value?.guardians?.[1]?.last_name || '',
|
||||
},
|
||||
{
|
||||
key: 'student',
|
||||
label: 'Prénom responsable 2',
|
||||
transform: (value) => value?.guardians?.[1]?.first_name || '',
|
||||
},
|
||||
{ key: 'school_year', label: 'Année scolaire' },
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Statut',
|
||||
transform: (value) => {
|
||||
const statusMap = {
|
||||
0: 'En attente',
|
||||
1: 'En cours',
|
||||
2: 'Envoyé',
|
||||
3: 'À relancer',
|
||||
4: 'À valider',
|
||||
5: 'Validé',
|
||||
6: 'Archivé',
|
||||
};
|
||||
return statusMap[value] || value;
|
||||
},
|
||||
},
|
||||
{ key: 'formatted_last_update', label: 'Dernière mise à jour' },
|
||||
];
|
||||
|
||||
const yearLabel =
|
||||
activeTab === CURRENT_YEAR_FILTER
|
||||
? currentSchoolYear
|
||||
: activeTab === NEXT_YEAR_FILTER
|
||||
? nextSchoolYear
|
||||
: 'historique';
|
||||
const filename = `inscriptions_${yearLabel}_${new Date().toISOString().split('T')[0]}`;
|
||||
exportToCSV(dataToExport, exportColumns, filename);
|
||||
};
|
||||
|
||||
const requestErrorHandler = (err) => {
|
||||
logger.error('Error fetching data:', err);
|
||||
};
|
||||
@ -417,7 +550,7 @@ export default function Page({ params: { locale } }) {
|
||||
{
|
||||
icon: (
|
||||
<span title="Envoyer le dossier">
|
||||
<Send className="w-5 h-5 text-green-500 hover:text-green-700" />
|
||||
<Send className="w-5 h-5 text-primary hover:text-secondary" />
|
||||
</span>
|
||||
),
|
||||
onClick: () =>
|
||||
@ -447,7 +580,7 @@ export default function Page({ params: { locale } }) {
|
||||
{
|
||||
icon: (
|
||||
<span title="Renvoyer le dossier">
|
||||
<Send className="w-5 h-5 text-green-500 hover:text-green-700" />
|
||||
<Send className="w-5 h-5 text-primary hover:text-secondary" />
|
||||
</span>
|
||||
),
|
||||
onClick: () =>
|
||||
@ -486,14 +619,22 @@ export default function Page({ params: { locale } }) {
|
||||
{
|
||||
icon: (
|
||||
<span title="Valider le dossier">
|
||||
<CheckCircle className="w-5 h-5 text-green-500 hover:text-green-700" />
|
||||
<CheckCircle className="w-5 h-5 text-primary hover:text-secondary" />
|
||||
</span>
|
||||
),
|
||||
onClick: () => {
|
||||
const url = `${FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL}?studentId=${row.student.id}&firstName=${row.student.first_name}&lastName=${row.student.last_name}&level=${row.student.level}&sepa_file=${row.sepa_file}&student_file=${row.registration_file}`;
|
||||
const url = `${FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL}?studentId=${row.student.id}&firstName=${row.student.first_name}&lastName=${row.student.last_name}&email=${row.student.guardians[0].associated_profile_email}&level=${row.student.level}&sepa_file=${row.sepa_file}&student_file=${row.registration_file}`;
|
||||
router.push(`${url}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<span title="Refuser le dossier">
|
||||
<XCircle className="w-5 h-5 text-red-500 hover:text-red-700" />
|
||||
</span>
|
||||
),
|
||||
onClick: () => openRefusePopup(row),
|
||||
},
|
||||
],
|
||||
// Etat "A relancer" - NON TESTE
|
||||
[RegistrationFormStatus.STATUS_TO_FOLLOW_UP]: [
|
||||
@ -593,7 +734,7 @@ export default function Page({ params: { locale } }) {
|
||||
{
|
||||
icon: (
|
||||
<span title="Uploader un mandat SEPA">
|
||||
<Upload className="w-5 h-5 text-emerald-500 hover:text-emerald-700" />
|
||||
<Upload className="w-5 h-5 text-primary hover:text-secondary" />
|
||||
</span>
|
||||
),
|
||||
onClick: () => openSepaUploadModal(row),
|
||||
@ -630,12 +771,12 @@ export default function Page({ params: { locale } }) {
|
||||
<div className="flex justify-center items-center">
|
||||
{row.student.photo ? (
|
||||
<a
|
||||
href={`${BASE_URL}${row.student.photo}`} // Lien vers la photo
|
||||
href={getSecureFileUrl(row.student.photo)} // Lien vers la photo
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src={`${BASE_URL}${row.student.photo}`}
|
||||
src={getSecureFileUrl(row.student.photo)}
|
||||
alt={`${row.student.first_name} ${row.student.last_name}`}
|
||||
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
|
||||
/>
|
||||
@ -703,90 +844,51 @@ export default function Page({ params: { locale } }) {
|
||||
},
|
||||
];
|
||||
|
||||
let emptyMessage;
|
||||
if (activeTab === CURRENT_YEAR_FILTER && searchTerm === '') {
|
||||
emptyMessage = (
|
||||
<AlertMessage
|
||||
type="warning"
|
||||
title="Aucun dossier d'inscription pour l'année en cours"
|
||||
message="Veuillez procéder à la création d'un nouveau dossier d'inscription pour l'année scolaire en cours."
|
||||
/>
|
||||
);
|
||||
} else if (activeTab === NEXT_YEAR_FILTER && searchTerm === '') {
|
||||
emptyMessage = (
|
||||
<AlertMessage
|
||||
type="info"
|
||||
title="Aucun dossier d'inscription pour l'année prochaine"
|
||||
message="Aucun dossier n'a encore été créé pour la prochaine année scolaire."
|
||||
/>
|
||||
);
|
||||
} else if (activeTab === HISTORICAL_FILTER && searchTerm === '') {
|
||||
emptyMessage = (
|
||||
<AlertMessage
|
||||
type="info"
|
||||
title="Aucun dossier d'inscription historique"
|
||||
message="Aucun dossier archivé n'est disponible pour les années précédentes."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const getEmptyMessageForTab = (tabFilter) => {
|
||||
if (searchTerm !== '') {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<div className="flex items-center gap-8">
|
||||
{/* Tab pour l'année scolaire en cours */}
|
||||
<Tab
|
||||
text={
|
||||
<>
|
||||
{currentSchoolYear}
|
||||
<span className="ml-2 text-sm text-gray-400">
|
||||
({totalCurrentYear})
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
active={activeTab === CURRENT_YEAR_FILTER}
|
||||
onClick={() => setActiveTab(CURRENT_YEAR_FILTER)}
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title="Aucun dossier trouvé"
|
||||
description="Modifiez votre recherche pour trouver un dossier d'inscription."
|
||||
/>
|
||||
|
||||
{/* Tab pour l'année scolaire prochaine */}
|
||||
<Tab
|
||||
text={
|
||||
<>
|
||||
{nextSchoolYear}
|
||||
<span className="ml-2 text-sm text-gray-400">
|
||||
({totalNextYear})
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
active={activeTab === NEXT_YEAR_FILTER}
|
||||
onClick={() => setActiveTab(NEXT_YEAR_FILTER)}
|
||||
if (tabFilter === CURRENT_YEAR_FILTER) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="Aucun dossier d'inscription pour l'année en cours"
|
||||
description="Commencez par créer un dossier d'inscription pour l'année scolaire en cours."
|
||||
actionLabel="Créer un dossier"
|
||||
actionIcon={Plus}
|
||||
onAction={() => router.push(FE_ADMIN_SUBSCRIPTIONS_CREATE_URL)}
|
||||
/>
|
||||
|
||||
{/* Tab pour l'historique */}
|
||||
<Tab
|
||||
text={
|
||||
<>
|
||||
{t('historical')}
|
||||
<span className="ml-2 text-sm text-gray-400">
|
||||
({totalHistorical})
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
active={activeTab === HISTORICAL_FILTER}
|
||||
onClick={() => setActiveTab(HISTORICAL_FILTER)}
|
||||
if (tabFilter === NEXT_YEAR_FILTER) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="Aucun dossier pour l'année prochaine"
|
||||
description="Aucun dossier n'a encore été créé pour la prochaine année scolaire."
|
||||
actionLabel="Créer un dossier"
|
||||
actionIcon={Plus}
|
||||
onAction={() => router.push(FE_ADMIN_SUBSCRIPTIONS_CREATE_URL)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Archive}
|
||||
title="Aucun dossier archivé"
|
||||
description="Aucun dossier archivé n'est disponible pour les années précédentes."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
<div className="border-b border-gray-200 mb-6 w-full">
|
||||
{activeTab === CURRENT_YEAR_FILTER ||
|
||||
activeTab === NEXT_YEAR_FILTER ||
|
||||
activeTab === HISTORICAL_FILTER ? (
|
||||
<React.Fragment>
|
||||
const renderTabContent = (data, currentPage, totalPages, tabFilter) => (
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-4 w-full">
|
||||
<div className="relative flex-grow">
|
||||
<Search
|
||||
@ -801,51 +903,80 @@ export default function Page({ params: { locale } }) {
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`;
|
||||
router.push(url);
|
||||
}}
|
||||
className="flex items-center bg-emerald-600 text-white p-2 rounded-full shadow hover:bg-emerald-900 transition duration-200 ml-4"
|
||||
onClick={handleExportCSV}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-secondary bg-primary/10 rounded hover:bg-primary/20 transition-colors"
|
||||
title="Exporter en CSV"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Exporter
|
||||
</button>
|
||||
{profileRole !== 0 && (
|
||||
<button
|
||||
onClick={() => router.push(FE_ADMIN_SUBSCRIPTIONS_CREATE_URL)}
|
||||
className="flex items-center bg-primary text-white p-2 rounded-full shadow hover:bg-secondary transition duration-200"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<DjangoCSRFToken csrfToken={csrfToken} />
|
||||
<Table
|
||||
key={`${currentSchoolYearPage}-${searchTerm}`}
|
||||
data={
|
||||
activeTab === CURRENT_YEAR_FILTER
|
||||
? registrationFormsDataCurrentYear
|
||||
: activeTab === NEXT_YEAR_FILTER
|
||||
? registrationFormsDataNextYear
|
||||
: registrationFormsDataHistorical
|
||||
}
|
||||
key={`${tabFilter}-${currentPage}-${searchTerm}`}
|
||||
data={data}
|
||||
columns={columns}
|
||||
itemsPerPage={itemsPerPage}
|
||||
currentPage={
|
||||
activeTab === CURRENT_YEAR_FILTER
|
||||
? currentSchoolYearPage
|
||||
: activeTab === NEXT_YEAR_FILTER
|
||||
? currentSchoolNextYearPage
|
||||
: currentSchoolHistoricalYearPage
|
||||
}
|
||||
totalPages={
|
||||
activeTab === CURRENT_YEAR_FILTER
|
||||
? totalCurrentSchoolYearPages
|
||||
: activeTab === NEXT_YEAR_FILTER
|
||||
? totalNextSchoolYearPages
|
||||
: totalHistoricalPages
|
||||
}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
emptyMessage={emptyMessage}
|
||||
emptyMessage={getEmptyMessageForTab(tabFilter)}
|
||||
/>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<SidebarTabs
|
||||
tabs={[
|
||||
{
|
||||
id: CURRENT_YEAR_FILTER,
|
||||
label: `${currentSchoolYear}${totalCurrentYear > 0 ? ` (${totalCurrentYear})` : ''}`,
|
||||
content: renderTabContent(
|
||||
registrationFormsDataCurrentYear,
|
||||
currentSchoolYearPage,
|
||||
totalCurrentSchoolYearPages,
|
||||
CURRENT_YEAR_FILTER
|
||||
),
|
||||
},
|
||||
{
|
||||
id: NEXT_YEAR_FILTER,
|
||||
label: `${nextSchoolYear}${totalNextYear > 0 ? ` (${totalNextYear})` : ''}`,
|
||||
content: renderTabContent(
|
||||
registrationFormsDataNextYear,
|
||||
currentSchoolNextYearPage,
|
||||
totalNextSchoolYearPages,
|
||||
NEXT_YEAR_FILTER
|
||||
),
|
||||
},
|
||||
{
|
||||
id: HISTORICAL_FILTER,
|
||||
label: `${t('historical')}${totalHistorical > 0 ? ` (${totalHistorical})` : ''}`,
|
||||
content: renderTabContent(
|
||||
registrationFormsDataHistorical,
|
||||
currentSchoolHistoricalYearPage,
|
||||
totalHistoricalPages,
|
||||
HISTORICAL_FILTER
|
||||
),
|
||||
},
|
||||
]}
|
||||
onTabChange={(newTab) => setActiveTab(newTab)}
|
||||
/>
|
||||
<Popup
|
||||
isOpen={confirmPopupVisible}
|
||||
message={confirmPopupMessage}
|
||||
@ -853,6 +984,27 @@ export default function Page({ params: { locale } }) {
|
||||
onCancel={() => setConfirmPopupVisible(false)}
|
||||
/>
|
||||
|
||||
{/* Popup de refus de dossier */}
|
||||
<Popup
|
||||
isOpen={isRefusePopupOpen}
|
||||
message={
|
||||
<div>
|
||||
<div className="mb-2 font-semibold">
|
||||
Veuillez indiquer la raison du refus :
|
||||
</div>
|
||||
<Textarea
|
||||
value={refuseReason}
|
||||
onChange={(e) => setRefuseReason(e.target.value)}
|
||||
placeholder="Ex : Réception de dossier trop tardive"
|
||||
rows={3}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onConfirm={handleRefuse}
|
||||
onCancel={() => setIsRefusePopupOpen(false)}
|
||||
/>
|
||||
|
||||
{isSepaUploadModalOpen && (
|
||||
<Modal
|
||||
isOpen={isSepaUploadModalOpen}
|
||||
|
||||
@ -10,8 +10,10 @@ import Loader from '@/components/Loader';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
import { editRegistrationSchoolFileTemplates, editRegistrationParentFileTemplates } from '@/app/actions/registerFileGroupAction';
|
||||
|
||||
export default function Page() {
|
||||
const [isLoadingRefuse, setIsLoadingRefuse] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@ -20,6 +22,7 @@ export default function Page() {
|
||||
const studentId = searchParams.get('studentId');
|
||||
const firstName = searchParams.get('firstName');
|
||||
const lastName = searchParams.get('lastName');
|
||||
const email = searchParams.get('email');
|
||||
const level = searchParams.get('level');
|
||||
const sepa_file =
|
||||
searchParams.get('sepa_file') === 'null'
|
||||
@ -84,6 +87,45 @@ export default function Page() {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const handleRefuseRF = (data) => {
|
||||
const formData = new FormData();
|
||||
formData.append('data', JSON.stringify(data));
|
||||
editRegisterForm(studentId, formData, csrfToken)
|
||||
.then((response) => {
|
||||
logger.debug('RF refusé et archivé:', response);
|
||||
showNotification('Le dossier a été refusé et archivé.', 'success', 'Succès');
|
||||
router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
|
||||
setIsLoadingRefuse(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
showNotification('Erreur lors du refus du dossier.', 'error', 'Erreur');
|
||||
setIsLoadingRefuse(false);
|
||||
logger.error('Erreur lors du refus du RF:', error);
|
||||
});
|
||||
};
|
||||
|
||||
// Validation/refus d'un document individuel (hors fiche élève)
|
||||
const handleValidateOrRefuseDoc = ({ templateId, type, validated, csrfToken }) => {
|
||||
if (!templateId) return;
|
||||
let editFn = null;
|
||||
if (type === 'school') {
|
||||
editFn = editRegistrationSchoolFileTemplates;
|
||||
} else if (type === 'parent') {
|
||||
editFn = editRegistrationParentFileTemplates;
|
||||
}
|
||||
if (!editFn) return;
|
||||
const updateData = new FormData();
|
||||
updateData.append('data', JSON.stringify({ isValidated: validated }));
|
||||
editFn(templateId, updateData, csrfToken)
|
||||
.then((response) => {
|
||||
logger.debug(`Document ${validated ? 'validé' : 'refusé'} (type: ${type}, id: ${templateId})`, response);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Erreur lors de la validation/refus du document:', error);
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
@ -93,10 +135,15 @@ export default function Page() {
|
||||
studentId={studentId}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
email={email}
|
||||
sepa_file={sepa_file}
|
||||
student_file={student_file}
|
||||
onAccept={handleAcceptRF}
|
||||
classes={classes}
|
||||
onRefuse={handleRefuseRF}
|
||||
isLoadingRefuse={isLoadingRefuse}
|
||||
handleValidateOrRefuseDoc={handleValidateOrRefuseDoc}
|
||||
csrfToken={csrfToken}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user