2 Commits

Author SHA1 Message Date
98bb6f50a8 chore: Config IMAP dans le Front 2025-05-27 20:01:00 +02:00
39d6a8e909 chore: Synchronisation IMAP 2025-05-27 19:59:53 +02:00
158 changed files with 2166 additions and 16190 deletions

View File

@ -35,13 +35,6 @@ Corriger ou améliorer le projet N3WT-SCHOOL de manière minimaliste et fonction
## Exigences qualité ## Exigences qualité
Pour le front-end, les exigences de qualité sont les suivantes :
- **Linting** : Utiliser ESLint pour le code JavaScript/TypeScript
- **Formatage** : Utiliser Prettier pour le formatage du code
- **Tests** : Utiliser Jest pour les tests unitaires et d'intégration
- Référence : [frontend guideline](./instructions/frontend.instruction.md)
### Tests ### Tests
- Tests unitaires obligatoires pour chaque nouvelle fonctionnalité - Tests unitaires obligatoires pour chaque nouvelle fonctionnalité

12
.vscode/tasks.json vendored
View File

@ -2,12 +2,12 @@
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{ {
"label": "Start Frontend Dev Server", "type": "npm",
"type": "shell", "script": "dev",
"command": "npm run dev", "path": "Front-End",
"group": "build", "problemMatcher": [],
"isBackground": true, "label": "npm: dev - Front-End",
"problemMatcher": [] "detail": "next dev"
} }
] ]
} }

2
Back-End/.gitignore vendored
View File

@ -4,4 +4,4 @@ documents
data data
*.dmp *.dmp
staticfiles staticfiles
/*/Configuration/application*.json /*/Configuration/application.json

View File

@ -1,75 +0,0 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
import django.contrib.auth.models
import django.contrib.auth.validators
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('Establishment', '0001_initial'),
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='ProfileRole',
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)),
('updated_date', models.DateTimeField(auto_now=True)),
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='profile_roles', to='Establishment.establishment')),
],
),
migrations.CreateModel(
name='Directeur',
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)),
('profile_role', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='directeur_profile', to='Auth.profilerole')),
],
),
migrations.CreateModel(
name='Profile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('email', models.EmailField(default='', max_length=255, unique=True, validators=[django.core.validators.EmailValidator()])),
('roleIndexLoginDefault', models.IntegerField(default=0)),
('code', models.CharField(blank=True, default='', max_length=200)),
('datePeremption', models.CharField(blank=True, default='', max_length=200)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.AddField(
model_name='profilerole',
name='profile',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='roles', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -2,6 +2,7 @@ from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.core.validators import EmailValidator from django.core.validators import EmailValidator
from django_mailbox.models import Mailbox
class Profile(AbstractUser): class Profile(AbstractUser):
email = models.EmailField(max_length=255, unique=True, default="", validators=[EmailValidator()]) email = models.EmailField(max_length=255, unique=True, default="", validators=[EmailValidator()])
@ -11,9 +12,13 @@ class Profile(AbstractUser):
roleIndexLoginDefault = models.IntegerField(default=0) roleIndexLoginDefault = models.IntegerField(default=0)
code = models.CharField(max_length=200, default="", blank=True) code = models.CharField(max_length=200, default="", blank=True)
datePeremption = models.CharField(max_length=200, default="", blank=True) datePeremption = models.CharField(max_length=200, default="", blank=True)
mailbox = models.OneToOneField(
def __str__(self): Mailbox,
return self.email on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='profile'
)
class ProfileRole(models.Model): class ProfileRole(models.Model):
class RoleType(models.IntegerChoices): class RoleType(models.IntegerChoices):
@ -22,7 +27,7 @@ class ProfileRole(models.Model):
PROFIL_ADMIN = 1, _('ADMIN') PROFIL_ADMIN = 1, _('ADMIN')
PROFIL_PARENT = 2, _('PARENT') PROFIL_PARENT = 2, _('PARENT')
profile = models.ForeignKey('Profile', on_delete=models.CASCADE, related_name='roles') profile = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name='roles')
role_type = models.IntegerField(choices=RoleType.choices, default=RoleType.PROFIL_UNDEFINED) role_type = models.IntegerField(choices=RoleType.choices, default=RoleType.PROFIL_UNDEFINED)
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='profile_roles') establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='profile_roles')
is_active = models.BooleanField(default=False) is_active = models.BooleanField(default=False)
@ -30,11 +35,3 @@ class ProfileRole(models.Model):
def __str__(self): def __str__(self):
return f"{self.profile.email} - {self.get_role_type_display()}" return f"{self.profile.email} - {self.get_role_type_display()}"
class Directeur(models.Model):
profile_role = models.OneToOneField("ProfileRole", on_delete=models.CASCADE, related_name='directeur_profile')
last_name = models.CharField(max_length=100)
first_name = models.CharField(max_length=100)
def __str__(self):
return f"{self.first_name} {self.last_name} ({self.profile_role.profile.email})"

View File

@ -0,0 +1,61 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{% static 'css/main.css' %}">
<title>Monteschool</title>
</head>
<body>
<div class="sidebar">
</div>
<div class="container">
<div class="logo-circular centered">
<img src="{% static 'img/logo_min.svg' %}" alt="">
</div>
<h1 class="login-heading">Authentification</h1>
<form class="centered login-form" method="post">
{% csrf_token %}
<div class="input-group">
<label for="userInput">{{ form.email.label }}</label>
<div class="input-wrapper">
<span class="icon-ctn">
<i class="icon user"></i>
</span>
<input type="text" id="userInput" placeholder='Identifiant' name="email">
</div>
</div>
<div class="input-group">
<label for="userInput">{{ form.password.label }}</label>
<div class="input-wrapper">
<span class="icon-ctn">
<i class="icon key"></i>
</span>
<input type="password" id="userInput" placeholder="Mot de passe" name="password">
</div>
<p style="color:#FF0000">{{ message }}</p>
{% if form.errors %}
{% for field in form %}
{% for error in field.errors %}
<p style="color:#FF0000">{{ error }}</p>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<p style="color:#FF0000">{{ error }}</p>
{% endfor %}
{% endif %}
<label><a class="right" href='/reset/{{code}}'>Mot de passe oublié ?</a></label>
</div>
<div class="form-group-submit">
<button href="" class="btn primary" type="submit" name="connect">Se Connecter</button>
<br>
<h2>Pas de compte ?</h2>
<br>
<button href="" class="btn " name="register">S'inscrire</button>
</div>
</form>
</div>
</body>
</html>

View File

@ -0,0 +1,64 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{% static 'css/main.css' %}">
<title>Monteschool</title>
</head>
<body>
<div class="container negative full-size">
<div class="logo-circular centered">
<img src="{% static 'img/logo_min.svg' %}" alt="">
</div>
<h1 class="login-heading">Nouveau Mot de Passe</h1>
<form class="negative centered login-form" method="post">
{% csrf_token %}
<div class="input-group" hidden>
<label for="userInput">Identifiant</label>
<div class="input-wrapper">
<span class="icon-ctn">
<i class="icon user"></i>
</span>
<input type="text" id="userInput" placeholder='Identifiant' value='{{ identifiant }}' name="email">
</div>
</div>
<div class="input-group">
<label for="password">{{ form.password1.label }}</label>
<div class="input-wrapper">
<span class="icon-ctn">
<i class="icon key"></i>
</span>
<input type="password" id="password" placeholder="{{ form.password1.label }}" name="password1">
</div>
</div>
<div class="input-group">
<label for="confirmPassword">{{ form.password2.label }}</label>
<div class="input-wrapper">
<span class="icon-ctn">
<i class="icon key"></i>
</span>
<input type="password" id="confirmPassword" placeholder="{{ form.password2.label }}" name="password2">
</div>
</div>
<p style="color:#FF0000">{{ message }}</p>
{% if form.errors %}
{% for field in form %}
{% for error in field.errors %}
<p style="color:#FF0000">{{ error }}</p>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<p style="color:#FF0000">{{ error }}</p>
{% endfor %}
{% endif %}
<div class="form-group-submit negative">
<button href="" class="btn primary" type="submit" name="save">Enregistrer</button>
<br>
<button href="" class="btn" type="submit" name="cancel">Annuler</button>
</div>
</form>
</div>
</body>
</html>

View File

@ -0,0 +1,37 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{% static 'css/main.css' %}">
<title>Monteschool</title>
</head>
<body>
<div class="container negative full-size">
<div class="logo-circular centered">
<img src="{% static 'img/logo_min.svg' %}" alt="">
</div>
<h1 class="login-heading"> Réinitialiser Mot de Passe</h1>
<form class="negative centered login-form" method="post">
{% csrf_token %}
<div class="input-group">
<label for="username">Identifiant</label>
<div class="input-wrapper">
<span class="icon-ctn">
<i class="icon user"></i>
</span>
<input type="text" id="username" placeholder="Identifiant" name="email">
</div>
</div>
<p style="color:#FF0000">{{ message }}</p>
<div class="form-group-submit negative">
<button href="" class="btn primary" type="submit" name="reinit">Réinitialiser</button>
<br>
<button href="" class="btn" type="submit" name="cancel">Annuler</button>
</div>
</form>
</div>
</body>
</html>

View File

@ -0,0 +1,64 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{% static 'css/main.css' %}">
<title>Monteschool</title>
</head>
<body>
<div class="container negative full-size">
<div class="logo-circular centered">
<img src="{% static 'img/logo_min.svg' %}" alt="">
</div>
<h1 class="login-heading">S'inscrire</h1>
<form class="negative centered login-form" method="post">
{% csrf_token %}
<div class="input-group">
<label for="username">{{ form.email.label }}</label>
<div class="input-wrapper">
<span class="icon-ctn">
<i class="icon user"></i>
</span>
<input type="text" id="username" placeholder="Identifiant" name="email">
</div>
</div>
<div class="input-group">
<label for="password">{{ form.password1.label }}</label>
<div class="input-wrapper">
<span class="icon-ctn">
<i class="icon key"></i>
</span>
<input type="password" id="password" placeholder="{{ form.password1.label }}" name="password1">
</div>
</div>
<div class="input-group">
<label for="confirmPassword">{{ form.password2.label }}</label>
<div class="input-wrapper">
<span class="icon-ctn">
<i class="icon key"></i>
</span>
<input type="password" id="confirmPassword" placeholder="{{ form.password2.label }}" name="password2">
</div>
</div>
<p style="color:#FF0000">{{ message }}</p>
{% if form.errors %}
{% for field in form %}
{% for error in field.errors %}
<p style="color:#FF0000">{{ error }}</p>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<p style="color:#FF0000">{{ error }}</p>
{% endfor %}
{% endif %}
<div class="form-group-submit negative">
<button href="" class="btn primary" type="submit" name="validate">Enregistrer</button>
<br>
<button href="" class="btn" name="cancel">Annuler</button>
</div>
</form>
</div>
</body>
</html>

View File

@ -223,29 +223,14 @@ def makeToken(user):
""" """
try: try:
# Récupérer tous les rôles de l'utilisateur actifs # Récupérer tous les rôles de l'utilisateur actifs
roles_qs = ProfileRole.objects.filter(profile=user, is_active=True).select_related('establishment') roles = ProfileRole.objects.filter(profile=user, is_active=True).values('role_type', 'establishment__id', 'establishment__name', 'establishment__evaluation_frequency', 'establishment__total_capacity')
roles = []
for role in roles_qs:
logo_url = ""
if role.establishment.logo:
# Construit l'URL complète pour le logo
logo_url = f"{role.establishment.logo.url}"
roles.append({
"role_type": role.role_type,
"establishment__id": role.establishment.id,
"establishment__name": role.establishment.name,
"establishment__evaluation_frequency": role.establishment.evaluation_frequency,
"establishment__total_capacity": role.establishment.total_capacity,
"establishment__api_docuseal": role.establishment.api_docuseal,
"establishment__logo": logo_url,
})
# Générer le JWT avec la bonne syntaxe datetime # Générer le JWT avec la bonne syntaxe datetime
access_payload = { access_payload = {
'user_id': user.id, 'user_id': user.id,
'email': user.email, 'email': user.email,
'roleIndexLoginDefault': user.roleIndexLoginDefault, 'roleIndexLoginDefault':user.roleIndexLoginDefault,
'roles': roles, 'roles': list(roles),
'type': 'access', 'type': 'access',
'exp': datetime.utcnow() + settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'], 'exp': datetime.utcnow() + settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'],
'iat': datetime.utcnow(), 'iat': datetime.utcnow(),
@ -376,7 +361,7 @@ class SubscribeView(APIView):
} }
) )
def post(self, request): def post(self, request):
retourErreur = '' retourErreur = error.returnMessage[error.BAD_URL]
retour = '' retour = ''
newProfilConnection = JSONParser().parse(request) newProfilConnection = JSONParser().parse(request)
establishment_id = newProfilConnection['establishment_id'] establishment_id = newProfilConnection['establishment_id']
@ -452,7 +437,7 @@ class NewPasswordView(APIView):
} }
) )
def post(self, request): def post(self, request):
retourErreur = '' retourErreur = error.returnMessage[error.BAD_URL]
retour = '' retour = ''
newProfilConnection = JSONParser().parse(request) newProfilConnection = JSONParser().parse(request)
@ -502,7 +487,7 @@ class ResetPasswordView(APIView):
} }
) )
def post(self, request, code): def post(self, request, code):
retourErreur = '' retourErreur = error.returnMessage[error.BAD_URL]
retour = '' retour = ''
newProfilConnection = JSONParser().parse(request) newProfilConnection = JSONParser().parse(request)
@ -513,7 +498,7 @@ class ResetPasswordView(APIView):
if profil: if profil:
if datetime.strptime(util.convertToStr(util._now(), '%d-%m-%Y %H:%M'), '%d-%m-%Y %H:%M') > datetime.strptime(profil.datePeremption, '%d-%m-%Y %H:%M'): if datetime.strptime(util.convertToStr(util._now(), '%d-%m-%Y %H:%M'), '%d-%m-%Y %H:%M') > datetime.strptime(profil.datePeremption, '%d-%m-%Y %H:%M'):
retourErreur = error.returnMessage[error.EXPIRED_URL] retourErreur = error.returnMessage[error.EXPIRED_URL] % (_uuid)
elif validationOk: elif validationOk:
retour = error.returnMessage[error.PASSWORD_CHANGED] retour = error.returnMessage[error.PASSWORD_CHANGED]

View File

@ -1,63 +0,0 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Cycle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('number', models.IntegerField(unique=True)),
('label', models.CharField(max_length=50)),
],
),
migrations.CreateModel(
name='Domain',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('cycle', models.IntegerField(choices=[(1, 'Cycle 1'), (2, 'Cycle 2'), (3, 'Cycle 3'), (4, 'Cycle 4')])),
],
),
migrations.CreateModel(
name='PaymentModeType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50, unique=True)),
('label', models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name='PaymentPlanType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50, unique=True)),
('label', models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name='Category',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='categories', to='Common.domain')),
],
),
migrations.CreateModel(
name='Level',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='levels', to='Common.cycle')),
],
),
]

View File

@ -1,4 +1,5 @@
from django.conf import settings from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from rest_framework.decorators import api_view from rest_framework.decorators import api_view
from rest_framework.response import Response from rest_framework.response import Response
@ -6,67 +7,49 @@ from rest_framework import status
import jwt import jwt
import datetime import datetime
import requests import requests
from Establishment.models import Establishment
@csrf_exempt @csrf_exempt
@api_view(['POST']) @api_view(['POST'])
def generate_jwt_token(request): def generate_jwt_token(request):
# Récupérer l'établissement concerné (par ID ou autre info transmise) # Vérifier la clé API
establishment_id = request.data.get('establishment_id')
if not establishment_id:
return Response({'error': 'establishment_id requis'}, status=status.HTTP_400_BAD_REQUEST)
try:
establishment = Establishment.objects.get(id=establishment_id)
except Establishment.DoesNotExist:
return Response({'error': "Établissement introuvable"}, status=status.HTTP_404_NOT_FOUND)
# Vérifier la clé API reçue dans le header
api_key = request.headers.get('X-Auth-Token') api_key = request.headers.get('X-Auth-Token')
if not api_key or not establishment.api_docuseal or api_key != establishment.api_docuseal: if not api_key or api_key != settings.DOCUSEAL_JWT["API_KEY"]:
return Response({'error': 'Clé API invalide'}, status=status.HTTP_401_UNAUTHORIZED) return Response({'error': 'Invalid API key'}, status=status.HTTP_401_UNAUTHORIZED)
# Récupérer les données de la requête # Récupérer les données de la requête
user_email = request.data.get('user_email') user_email = request.data.get('user_email')
documents_urls = request.data.get('documents_urls', []) documents_urls = request.data.get('documents_urls', [])
template_id = request.data.get('id') id = request.data.get('id') # Récupérer le id
# Vérifier les données requises
if not user_email: if not user_email:
return Response({'error': 'User email is required'}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': 'User email is required'}, status=status.HTTP_400_BAD_REQUEST)
# Utiliser la clé API de l'établissement comme secret JWT # Utiliser la configuration JWT de DocuSeal depuis les settings
jwt_secret = establishment.api_docuseal jwt_secret = settings.DOCUSEAL_JWT['API_KEY']
jwt_algorithm = settings.DOCUSEAL_JWT['ALGORITHM'] jwt_algorithm = settings.DOCUSEAL_JWT['ALGORITHM']
expiration_delta = settings.DOCUSEAL_JWT['EXPIRATION_DELTA'] expiration_delta = settings.DOCUSEAL_JWT['EXPIRATION_DELTA']
# Définir le payload
payload = { payload = {
'user_email': user_email, 'user_email': user_email,
'documents_urls': documents_urls, 'documents_urls': documents_urls,
'template_id': template_id, 'template_id': id, # Ajouter le id au payload
'exp': datetime.datetime.utcnow() + expiration_delta 'exp': datetime.datetime.utcnow() + expiration_delta # Temps d'expiration du token
} }
# Générer le token JWT
token = jwt.encode(payload, jwt_secret, algorithm=jwt_algorithm) token = jwt.encode(payload, jwt_secret, algorithm=jwt_algorithm)
return Response({'token': token}, status=status.HTTP_200_OK) return Response({'token': token}, status=status.HTTP_200_OK)
@csrf_exempt @csrf_exempt
@api_view(['POST']) @api_view(['POST'])
def clone_template(request): def clone_template(request):
# Récupérer l'établissement concerné # Vérifier la clé API
establishment_id = request.data.get('establishment_id')
print(f"establishment_id : {establishment_id}")
if not establishment_id:
return Response({'error': 'establishment_id requis'}, status=status.HTTP_400_BAD_REQUEST)
try:
establishment = Establishment.objects.get(id=establishment_id)
except Establishment.DoesNotExist:
return Response({'error': "Établissement introuvable"}, status=status.HTTP_404_NOT_FOUND)
# Vérifier la clé API reçue dans le header
api_key = request.headers.get('X-Auth-Token') api_key = request.headers.get('X-Auth-Token')
if not api_key or not establishment.api_docuseal or api_key != establishment.api_docuseal: if not api_key or api_key != settings.DOCUSEAL_JWT["API_KEY"]:
return Response({'error': 'Clé API invalide'}, status=status.HTTP_401_UNAUTHORIZED) return Response({'error': 'Invalid API key'}, status=status.HTTP_401_UNAUTHORIZED)
# Récupérer les données de la requête # Récupérer les données de la requête
document_id = request.data.get('templateId') document_id = request.data.get('templateId')
@ -74,7 +57,7 @@ def clone_template(request):
is_required = request.data.get('is_required') is_required = request.data.get('is_required')
# Vérifier les données requises # Vérifier les données requises
if not document_id: if not document_id :
return Response({'error': 'template ID is required'}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': 'template ID is required'}, status=status.HTTP_400_BAD_REQUEST)
# URL de l'API de DocuSeal pour cloner le template # URL de l'API de DocuSeal pour cloner le template
@ -84,7 +67,7 @@ def clone_template(request):
try: try:
response = requests.post(clone_url, headers={ response = requests.post(clone_url, headers={
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Auth-Token': establishment.api_docuseal 'X-Auth-Token': settings.DOCUSEAL_JWT['API_KEY']
}) })
if response.status_code != status.HTTP_200_OK: if response.status_code != status.HTTP_200_OK:
@ -96,15 +79,12 @@ def clone_template(request):
# URL de l'API de DocuSeal pour créer une submission # URL de l'API de DocuSeal pour créer une submission
submission_url = f'https://docuseal.com/api/submissions' submission_url = f'https://docuseal.com/api/submissions'
# Faire la requête pour cloner le template
try: try:
clone_id = data['id'] clone_id = data['id']
response = requests.post(submission_url, json={ response = requests.post(submission_url, json={'template_id':clone_id, 'send_email': False, 'submitters': [{'email': email}]}, headers={
'template_id': clone_id,
'send_email': False,
'submitters': [{'email': email}]
}, headers={
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Auth-Token': establishment.api_docuseal 'X-Auth-Token': settings.DOCUSEAL_JWT['API_KEY']
}) })
if response.status_code != status.HTTP_200_OK: if response.status_code != status.HTTP_200_OK:
@ -116,7 +96,7 @@ def clone_template(request):
except requests.RequestException as e: except requests.RequestException as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
else: else :
print(f'NOT REQUIRED -> on ne crée pas de submission') print(f'NOT REQUIRED -> on ne crée pas de submission')
return Response(data, status=status.HTTP_200_OK) return Response(data, status=status.HTTP_200_OK)
@ -126,28 +106,18 @@ def clone_template(request):
@csrf_exempt @csrf_exempt
@api_view(['DELETE']) @api_view(['DELETE'])
def remove_template(request, id): def remove_template(request, id):
# Récupérer l'établissement concerné # Vérifier la clé API
establishment_id = request.GET.get('establishment_id')
if not establishment_id:
return Response({'error': 'establishment_id requis'}, status=status.HTTP_400_BAD_REQUEST)
try:
establishment = Establishment.objects.get(id=establishment_id)
except Establishment.DoesNotExist:
return Response({'error': "Établissement introuvable"}, status=status.HTTP_404_NOT_FOUND)
# Vérifier la clé API reçue dans le header
api_key = request.headers.get('X-Auth-Token') api_key = request.headers.get('X-Auth-Token')
if not api_key or not establishment.api_docuseal or api_key != establishment.api_docuseal: if not api_key or api_key != settings.DOCUSEAL_JWT["API_KEY"]:
return Response({'error': 'Clé API invalide'}, status=status.HTTP_401_UNAUTHORIZED) return Response({'error': 'Invalid API key'}, status=status.HTTP_401_UNAUTHORIZED)
# URL de l'API de DocuSeal pour supprimer le template
# URL de l'API de DocuSeal pour cloner le template
clone_url = f'https://docuseal.com/api/templates/{id}' clone_url = f'https://docuseal.com/api/templates/{id}'
# Faire la requête pour cloner le template
try: try:
response = requests.delete(clone_url, headers={ response = requests.delete(clone_url, headers={
'X-Auth-Token': establishment.api_docuseal 'X-Auth-Token': settings.DOCUSEAL_JWT['API_KEY']
}) })
if response.status_code != status.HTTP_200_OK: if response.status_code != status.HTTP_200_OK:
@ -162,32 +132,23 @@ def remove_template(request, id):
@csrf_exempt @csrf_exempt
@api_view(['GET']) @api_view(['GET'])
def download_template(request, slug): def download_template(request, slug):
# Récupérer l'établissement concerné # Vérifier la clé API
establishment_id = request.GET.get('establishment_id')
if not establishment_id:
return Response({'error': 'establishment_id requis'}, status=status.HTTP_400_BAD_REQUEST)
try:
establishment = Establishment.objects.get(id=establishment_id)
except Establishment.DoesNotExist:
return Response({'error': "Établissement introuvable"}, status=status.HTTP_404_NOT_FOUND)
# Vérifier la clé API reçue dans le header
api_key = request.headers.get('X-Auth-Token') api_key = request.headers.get('X-Auth-Token')
if not api_key or not establishment.api_docuseal or api_key != establishment.api_docuseal: if not api_key or api_key != settings.DOCUSEAL_JWT["API_KEY"]:
return Response({'error': 'Clé API invalide'}, status=status.HTTP_401_UNAUTHORIZED) return Response({'error': 'Invalid API key'}, status=status.HTTP_401_UNAUTHORIZED)
# Vérifier les données requises # Vérifier les données requises
if not slug: if not slug :
return Response({'error': 'slug is required'}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': 'slug is required'}, status=status.HTTP_400_BAD_REQUEST)
# URL de l'API de DocuSeal pour télécharger le template # URL de l'API de DocuSeal pour cloner le template
download_url = f'https://docuseal.com/submitters/{slug}/download' download_url = f'https://docuseal.com/submitters/{slug}/download'
# Faire la requête pour cloner le template
try: try:
response = requests.get(download_url, headers={ response = requests.get(download_url, headers={
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Auth-Token': establishment.api_docuseal 'X-Auth-Token': settings.DOCUSEAL_JWT['API_KEY']
}) })
if response.status_code != status.HTTP_200_OK: if response.status_code != status.HTTP_200_OK:

View File

@ -1,29 +0,0 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Establishment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('address', models.CharField(max_length=255)),
('total_capacity', models.IntegerField()),
('establishment_type', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(choices=[(1, 'Maternelle'), (2, 'Primaire'), (3, 'Secondaire')]), size=None)),
('evaluation_frequency', models.IntegerField(choices=[(1, 'Trimestre'), (2, 'Semestre'), (3, 'Année')], default=1)),
('licence_code', models.CharField(blank=True, max_length=100)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.1.3 on 2025-05-30 07:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('Establishment', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='establishment',
name='api_docuseal',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 5.1.3 on 2025-05-31 09:56
import Establishment.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('Establishment', '0002_establishment_api_docuseal'),
]
operations = [
migrations.AddField(
model_name='establishment',
name='logo',
field=models.FileField(blank=True, null=True, upload_to=Establishment.models.registration_logo_upload_to),
),
]

View File

@ -2,12 +2,6 @@ from django.db import models
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import os
def registration_logo_upload_to(instance, filename):
ext = os.path.splitext(filename)[1]
return f"logos/school_{instance.pk}/logo{ext}"
class StructureType(models.IntegerChoices): class StructureType(models.IntegerChoices):
MATERNELLE = 1, _('Maternelle') MATERNELLE = 1, _('Maternelle')
PRIMAIRE = 2, _('Primaire') PRIMAIRE = 2, _('Primaire')
@ -19,7 +13,7 @@ class EvaluationFrequency(models.IntegerChoices):
YEAR = 3, _("Année") YEAR = 3, _("Année")
class Establishment(models.Model): class Establishment(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255, unique=True)
address = models.CharField(max_length=255) address = models.CharField(max_length=255)
total_capacity = models.IntegerField() total_capacity = models.IntegerField()
establishment_type = ArrayField(models.IntegerField(choices=StructureType.choices)) establishment_type = ArrayField(models.IntegerField(choices=StructureType.choices))
@ -27,12 +21,6 @@ class Establishment(models.Model):
licence_code = models.CharField(max_length=100, blank=True) licence_code = models.CharField(max_length=100, blank=True)
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
api_docuseal = models.CharField(max_length=255, blank=True, null=True)
logo = models.FileField(
upload_to=registration_logo_upload_to,
null=True,
blank=True
)
def __str__(self): def __str__(self):
return self.name return self.name

View File

@ -1,19 +1,14 @@
from django.http.response import JsonResponse from django.http.response import JsonResponse
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from rest_framework.parsers import JSONParser, MultiPartParser, FormParser from rest_framework.parsers import JSONParser
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework import status from rest_framework import status
from .models import Establishment from .models import Establishment
from .serializers import EstablishmentSerializer from .serializers import EstablishmentSerializer
from N3wtSchool.bdd import delete_object, getAllObjects, getObject from N3wtSchool.bdd import delete_object, getAllObjects
from School.models import EstablishmentCompetency, Competency from School.models import EstablishmentCompetency, Competency
from django.db.models import Q from django.db.models import Q
from Auth.models import Profile, ProfileRole, Directeur
from Settings.models import SMTPSettings
import N3wtSchool.mailManager as mailer
import os
from N3wtSchool import settings
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
@ -25,8 +20,9 @@ class EstablishmentListCreateView(APIView):
def post(self, request): def post(self, request):
establishment_data = JSONParser().parse(request) establishment_data = JSONParser().parse(request)
try: establishment_serializer = EstablishmentSerializer(data=establishment_data)
establishment, data = create_establishment_with_directeur(establishment_data) if establishment_serializer.is_valid():
establishment = establishment_serializer.save()
# Création des EstablishmentCompetency pour chaque compétence existante # Création des EstablishmentCompetency pour chaque compétence existante
competencies = Competency.objects.filter( competencies = Competency.objects.filter(
Q(end_of_cycle=True) | ~Q(level=None) Q(end_of_cycle=True) | ~Q(level=None)
@ -37,15 +33,12 @@ class EstablishmentListCreateView(APIView):
competency=competency, competency=competency,
defaults={'is_required': True} defaults={'is_required': True}
) )
return JsonResponse(data, safe=False, status=status.HTTP_201_CREATED) return JsonResponse(establishment_serializer.data, safe=False, status=status.HTTP_201_CREATED)
except Exception as e: return JsonResponse(establishment_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
return JsonResponse({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
class EstablishmentDetailView(APIView): class EstablishmentDetailView(APIView):
parser_classes = [MultiPartParser, FormParser]
def get(self, request, id=None): def get(self, request, id=None):
try: try:
establishment = Establishment.objects.get(id=id) establishment = Establishment.objects.get(id=id)
@ -55,78 +48,16 @@ class EstablishmentDetailView(APIView):
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
def put(self, request, id): def put(self, request, id):
""" establishment_data = JSONParser().parse(request)
Met à jour un établissement existant.
Accepte les données en multipart/form-data pour permettre l'upload de fichiers (ex : logo).
"""
try: try:
establishment = Establishment.objects.get(id=id) establishment = Establishment.objects.get(id=id)
except Establishment.DoesNotExist: except Establishment.DoesNotExist:
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
establishment_serializer = EstablishmentSerializer(establishment, data=establishment_data, partial=True)
# Utilise request.data pour supporter multipart/form-data (fichiers et champs classiques)
establishment_serializer = EstablishmentSerializer(establishment, data=request.data, partial=True)
if establishment_serializer.is_valid(): if establishment_serializer.is_valid():
establishment_serializer.save() establishment_serializer.save()
return JsonResponse(establishment_serializer.data, safe=False, status=status.HTTP_200_OK) return JsonResponse(establishment_serializer.data, safe=False)
return JsonResponse(establishment_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) return JsonResponse(establishment_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, id): def delete(self, request, id):
return delete_object(Establishment, id) return delete_object(Establishment, id)
def create_establishment_with_directeur(establishment_data):
# Extraction des sous-objets
# school_name = establishment_data.get("name")
directeur_data = establishment_data.pop("directeur", None)
smtp_settings_data = establishment_data.pop("smtp_settings", {})
# Vérification de la présence du directeur
if not directeur_data or not directeur_data.get("email"):
raise ValueError("Le champ 'directeur.email' est obligatoire.")
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!")
# Création ou récupération du profil utilisateur
profile, created = Profile.objects.get_or_create(
email=directeur_email,
defaults={"username": directeur_email}
)
if created or not profile.has_usable_password():
profile.set_password(password)
profile.save()
# Création de l'établissement
establishment_serializer = EstablishmentSerializer(data=establishment_data)
establishment_serializer.is_valid(raise_exception=True)
# base_dir = os.path.join(settings.MEDIA_ROOT, f"logo/school_{school_name}")
# os.makedirs(base_dir, exist_ok=True)
establishment = establishment_serializer.save()
# Création ou récupération du ProfileRole ADMIN pour ce profil et cet établissement
profile_role, _ = ProfileRole.objects.get_or_create(
profile=profile,
establishment=establishment,
role_type=ProfileRole.RoleType.PROFIL_ADMIN,
defaults={"is_active": False}
)
# Création ou mise à jour du Directeur lié à ce ProfileRole
Directeur.objects.update_or_create(
profile_role=profile_role,
defaults={
"last_name": last_name,
"first_name": first_name
}
)
# Création du SMTPSettings rattaché à l'établissement si des données sont fournies
if smtp_settings_data:
smtp_settings_data["establishment"] = establishment
SMTPSettings.objects.create(**smtp_settings_data)
# Envoi du mail
mailer.sendRegistrationDirector(directeur_email, establishment.pk)
return establishment, establishment_serializer.data

View File

@ -1 +0,0 @@
default_app_config = 'GestionEmail.apps.GestionEmailConfig'

View File

@ -1,5 +0,0 @@
from django.apps import AppConfig
class GestionEmailConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'GestionEmail'

View File

@ -1,9 +0,0 @@
from django.urls import path
from .views import (
SendEmailView, search_recipients
)
urlpatterns = [
path('send-email/', SendEmailView.as_view(), name='send_email'),
path('search-recipients/', search_recipients, name='search_recipients'),
]

View File

@ -1,119 +0,0 @@
from django.http.response import JsonResponse
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from django.db.models import Q
from Auth.models import Profile, ProfileRole
import N3wtSchool.mailManager as mailer
from N3wtSchool import bdd
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from rest_framework.exceptions import NotFound
import uuid
import logging
# Ajouter un logger pour debug
logger = logging.getLogger(__name__)
class SendEmailView(APIView):
"""
API pour envoyer des emails aux parents et professeurs.
"""
def post(self, request):
# Ajouter du debug
logger.info(f"Request data received: {request.data}")
logger.info(f"Request content type: {request.content_type}")
data = request.data
recipients = data.get('recipients', [])
cc = data.get('cc', [])
bcc = data.get('bcc', [])
subject = data.get('subject', 'Notification')
message = data.get('message', '')
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"Subject: {subject}")
logger.info(f"Message length: {len(message) if message else 0}")
logger.info(f"Establishment ID: {establishment_id}")
if not recipients or not message:
logger.error("Recipients or message missing")
logger.error(f"Recipients empty: {not recipients}, Message empty: {not message}")
logger.error(f"Recipients value: '{recipients}', Message value: '{message}'")
return Response({'error': 'Les destinataires et le message sont requis.'}, status=status.HTTP_400_BAD_REQUEST)
try:
# Récupérer la connexion SMTP
logger.info("Tentative de récupération de la connexion SMTP...")
connection = mailer.getConnection(establishment_id)
logger.info(f"Connexion SMTP récupérée: {connection}")
# Envoyer l'email
logger.info("Tentative d'envoi de l'email...")
result = mailer.sendMail(
subject=subject,
message=message,
recipients=recipients,
cc=cc,
bcc=bcc,
attachments=[],
connection=connection
)
logger.info(f"Email envoyé avec succès: {result}")
return result
except NotFound as e:
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)
def search_recipients(request):
"""
API pour rechercher des destinataires en fonction d'un terme de recherche et d'un établissement.
"""
query = request.GET.get('q', '').strip() # Récupérer le terme de recherche depuis les paramètres GET
establishment_id = request.GET.get('establishment_id', None) # Récupérer l'ID de l'établissement
if not query:
return JsonResponse([], safe=False) # Retourner une liste vide si aucun terme n'est fourni
if not establishment_id:
return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
# Rechercher dans les champs pertinents (nom, prénom, email) et filtrer par establishment_id
profiles = Profile.objects.filter(
Q(first_name__icontains=query) |
Q(last_name__icontains=query) |
Q(email__icontains=query),
roles__establishment_id=establishment_id, # Utiliser 'roles' au lieu de 'profilerole'
roles__is_active=True # Filtrer uniquement les ProfileRole actifs
).distinct()
# Construire la réponse avec les rôles associés
results = []
for profile in profiles:
profile_roles = ProfileRole.objects.filter(
profile=profile,
establishment_id=establishment_id,
is_active=True # Inclure uniquement les ProfileRole actifs
).values(
'id', 'role_type', 'establishment__name', 'is_active'
)
results.append({
'id': profile.id,
'first_name': profile.first_name,
'last_name': profile.last_name,
'email': profile.email,
'roles': list(profile_roles) # Inclure tous les rôles actifs associés pour cet établissement
})
return JsonResponse(results, safe=False)

View File

@ -1,5 +1,4 @@
from django.apps import AppConfig from django.apps import AppConfig
class GestionMessagerieConfig(AppConfig): class GestionMessagerieConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'GestionMessagerie' name = 'GestionMessagerie'

View File

@ -1,627 +0,0 @@
import json
import logging
from uuid import UUID
from decimal import Decimal
from datetime import datetime
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from django.utils import timezone
from .models import Conversation, ConversationParticipant, Message, UserPresence, MessageRead
from .serializers import MessageSerializer, ConversationSerializer
from Auth.models import Profile
logger = logging.getLogger(__name__)
def serialize_for_websocket(data):
"""
Convertit récursivement les objets non-sérialisables en JSON en types sérialisables
"""
if isinstance(data, dict):
return {key: serialize_for_websocket(value) for key, value in data.items()}
elif isinstance(data, list):
return [serialize_for_websocket(item) for item in data]
elif isinstance(data, UUID):
return str(data)
elif isinstance(data, Decimal):
return float(data)
elif isinstance(data, datetime):
return data.isoformat()
else:
return data
class ChatConsumer(AsyncWebsocketConsumer):
"""Consumer WebSocket pour la messagerie instantanée"""
async def connect(self):
self.user_id = self.scope['url_route']['kwargs']['user_id']
self.user_group_name = f'user_{self.user_id}'
# Vérifier si l'utilisateur est authentifié
user = self.scope.get('user')
if not user or user.is_anonymous:
logger.warning(f"Tentative de connexion WebSocket non authentifiée pour user_id: {self.user_id}")
await self.close()
return
# Vérifier que l'utilisateur connecté correspond à l'user_id de l'URL
if str(user.id) != str(self.user_id):
logger.warning(f"Tentative d'accès WebSocket avec user_id incorrect: {self.user_id} vs {user.id}")
await self.close()
return
self.user = user
# Rejoindre le groupe utilisateur
await self.channel_layer.group_add(
self.user_group_name,
self.channel_name
)
# Rejoindre les groupes des conversations de l'utilisateur
conversations = await self.get_user_conversations(self.user_id)
for conversation in conversations:
await self.channel_layer.group_add(
f'conversation_{conversation.id}',
self.channel_name
)
# Mettre à jour le statut de présence
presence = await self.update_user_presence(self.user_id, 'online')
# Notifier les autres utilisateurs du changement de statut
if presence:
await self.broadcast_presence_update(self.user_id, 'online')
# Envoyer les statuts de présence existants des autres utilisateurs connectés
await self.send_existing_user_presences()
await self.accept()
logger.info(f"User {self.user_id} connected to chat")
async def send_existing_user_presences(self):
"""Envoyer les statuts de présence existants des autres utilisateurs connectés"""
try:
# Obtenir toutes les conversations de cet utilisateur
conversations = await self.get_user_conversations(self.user_id)
# Créer un set pour éviter les doublons d'utilisateurs
other_users = set()
# Pour chaque conversation, récupérer les participants
for conversation in conversations:
participants = await self.get_conversation_participants(conversation.id)
for participant in participants:
if participant.id != self.user_id:
other_users.add(participant.id)
# Envoyer le statut de présence pour chaque utilisateur
for user_id in other_users:
presence = await self.get_user_presence(user_id)
if presence:
await self.send(text_data=json.dumps({
'type': 'presence_update',
'user_id': str(user_id),
'status': presence.status
}))
except Exception as e:
logger.error(f"Error sending existing user presences: {str(e)}")
async def disconnect(self, close_code):
# Quitter tous les groupes
await self.channel_layer.group_discard(
self.user_group_name,
self.channel_name
)
if hasattr(self, 'user'):
conversations = await self.get_user_conversations(self.user_id)
for conversation in conversations:
await self.channel_layer.group_discard(
f'conversation_{conversation.id}',
self.channel_name
)
# Mettre à jour le statut de présence
presence = await self.update_user_presence(self.user_id, 'offline')
# Notifier les autres utilisateurs du changement de statut
if presence:
await self.broadcast_presence_update(self.user_id, 'offline')
logger.info(f"User {self.user_id} disconnected from chat")
async def receive(self, text_data):
"""Recevoir et traiter les messages du client"""
try:
text_data_json = json.loads(text_data)
message_type = text_data_json.get('type')
if message_type == 'send_message':
await self.handle_send_message(text_data_json)
elif message_type == 'typing_start':
await self.handle_typing_start(text_data_json)
elif message_type == 'typing_stop':
await self.handle_typing_stop(text_data_json)
elif message_type == 'mark_as_read':
await self.handle_mark_as_read(text_data_json)
elif message_type == 'join_conversation':
await self.handle_join_conversation(text_data_json)
elif message_type == 'leave_conversation':
await self.handle_leave_conversation(text_data_json)
elif message_type == 'presence_update':
await self.handle_presence_update(text_data_json)
else:
logger.warning(f"Unknown message type: {message_type}")
await self.send(text_data=json.dumps({
'type': 'error',
'message': f'Unknown message type: {message_type}'
}))
except json.JSONDecodeError:
await self.send(text_data=json.dumps({
'type': 'error',
'message': 'Invalid JSON format'
}))
except Exception as e:
logger.error(f"Error in receive: {str(e)}")
await self.send(text_data=json.dumps({
'type': 'error',
'message': 'Internal server error'
}))
async def handle_send_message(self, data):
"""Gérer l'envoi d'un nouveau message"""
conversation_id = data.get('conversation_id')
content = data.get('content', '').strip()
message_type = data.get('message_type', 'text')
attachment = data.get('attachment')
# Vérifier qu'on a soit du contenu, soit un fichier
if not conversation_id or (not content and not attachment):
await self.send(text_data=json.dumps({
'type': 'error',
'message': 'Conversation ID and content or attachment are required'
}))
return
# Vérifier que l'utilisateur peut envoyer dans cette conversation
can_send = await self.can_user_send_message(self.user_id, conversation_id)
if not can_send:
await self.send(text_data=json.dumps({
'type': 'error',
'message': 'You cannot send messages to this conversation'
}))
return
# Créer le message avec ou sans fichier
message = await self.create_message(conversation_id, self.user_id, content, message_type, attachment)
if not message:
await self.send(text_data=json.dumps({
'type': 'error',
'message': 'Failed to create message'
}))
return
# Sérialiser le message
message_data = await self.serialize_message(message)
# Auto-marquer comme lu pour les utilisateurs connectés (présents dans la conversation)
await self.auto_mark_read_for_online_users(message, conversation_id)
# Envoyer le message à tous les participants de la conversation
await self.channel_layer.group_send(
f'conversation_{conversation_id}',
{
'type': 'chat_message',
'message': message_data
}
)
async def handle_typing_start(self, data):
"""Gérer le début de frappe"""
conversation_id = data.get('conversation_id')
if conversation_id:
await self.update_typing_status(self.user_id, conversation_id, True)
# Récupérer le nom de l'utilisateur
user_name = await self.get_user_display_name(self.user_id)
await self.channel_layer.group_send(
f'conversation_{conversation_id}',
{
'type': 'typing_status',
'user_id': str(self.user_id),
'user_name': user_name,
'is_typing': True,
'conversation_id': str(conversation_id)
}
)
async def handle_typing_stop(self, data):
"""Gérer l'arrêt de frappe"""
conversation_id = data.get('conversation_id')
if conversation_id:
await self.update_typing_status(self.user_id, conversation_id, False)
# Récupérer le nom de l'utilisateur
user_name = await self.get_user_display_name(self.user_id)
await self.channel_layer.group_send(
f'conversation_{conversation_id}',
{
'type': 'typing_status',
'user_id': str(self.user_id),
'user_name': user_name,
'is_typing': False,
'conversation_id': str(conversation_id)
}
)
async def handle_mark_as_read(self, data):
"""Marquer les messages comme lus"""
conversation_id = data.get('conversation_id')
if conversation_id:
await self.mark_conversation_as_read(self.user_id, conversation_id)
await self.channel_layer.group_send(
f'conversation_{conversation_id}',
{
'type': 'messages_read',
'user_id': str(self.user_id),
'conversation_id': str(conversation_id)
}
)
async def handle_join_conversation(self, data):
"""Rejoindre une conversation"""
conversation_id = data.get('conversation_id')
if conversation_id:
await self.channel_layer.group_add(
f'conversation_{conversation_id}',
self.channel_name
)
async def handle_leave_conversation(self, data):
"""Quitter une conversation"""
conversation_id = data.get('conversation_id')
if conversation_id:
await self.channel_layer.group_discard(
f'conversation_{conversation_id}',
self.channel_name
)
async def handle_presence_update(self, data):
"""Gérer les mises à jour de présence"""
status = data.get('status', 'online')
if status in ['online', 'offline', 'away']:
await self.update_user_presence(self.user_id, status)
await self.broadcast_presence_update(self.user_id, status)
# Méthodes pour recevoir les messages des groupes
async def chat_message(self, event):
"""Envoyer un message de chat au WebSocket"""
message_data = serialize_for_websocket(event['message'])
await self.send(text_data=json.dumps({
'type': 'new_message',
'message': message_data
}))
async def typing_status(self, event):
"""Envoyer le statut de frappe"""
# Ne pas envoyer à l'expéditeur
if str(event['user_id']) != str(self.user_id):
await self.send(text_data=json.dumps({
'type': 'typing_status',
'user_id': str(event['user_id']),
'user_name': event.get('user_name', ''),
'is_typing': event['is_typing'],
'conversation_id': str(event['conversation_id'])
}))
async def messages_read(self, event):
"""Notifier que des messages ont été lus"""
if str(event['user_id']) != str(self.user_id):
await self.send(text_data=json.dumps({
'type': 'messages_read',
'user_id': str(event['user_id']),
'conversation_id': str(event['conversation_id'])
}))
async def user_presence_update(self, event):
"""Notifier d'un changement de présence"""
await self.send(text_data=json.dumps({
'type': 'presence_update',
'user_id': str(event['user_id']),
'status': event['status']
}))
async def new_conversation_notification(self, event):
"""Notifier d'une nouvelle conversation"""
conversation = serialize_for_websocket(event['conversation'])
conversation_id = conversation['id']
# Rejoindre automatiquement le groupe de la nouvelle conversation
await self.channel_layer.group_add(
f'conversation_{conversation_id}',
self.channel_name
)
# Envoyer la notification au client
await self.send(text_data=json.dumps({
'type': 'new_conversation',
'conversation': conversation
}))
# Diffuser les présences des participants de cette nouvelle conversation
try:
participants = await self.get_conversation_participants(conversation_id)
for participant in participants:
# Ne pas diffuser sa propre présence à soi-même
if participant.id != self.user_id:
presence = await self.get_user_presence(participant.id)
if presence:
await self.send(text_data=json.dumps({
'type': 'presence_update',
'user_id': str(participant.id),
'status': presence.status
}))
except Exception as e:
logger.error(f"Error sending presence updates for new conversation: {str(e)}")
async def broadcast_presence_update(self, user_id, status):
"""Diffuser un changement de statut de présence à tous les utilisateurs connectés"""
try:
# Obtenir tous les utilisateurs qui ont des conversations avec cet utilisateur
user_conversations = await self.get_user_conversations(user_id)
# Créer un set pour éviter les doublons d'utilisateurs
notified_users = set()
# Pour chaque conversation, notifier tous les participants
for conversation in user_conversations:
participants = await self.get_conversation_participants(conversation.id)
for participant in participants:
if participant.id != user_id and participant.id not in notified_users:
notified_users.add(participant.id)
# Envoyer la notification au groupe utilisateur
await self.channel_layer.group_send(
f'user_{participant.id}',
{
'type': 'user_presence_update',
'user_id': user_id,
'status': status
}
)
logger.info(f"Broadcasted presence update for user {user_id} ({status}) to {len(notified_users)} users")
except Exception as e:
logger.error(f"Error broadcasting presence update: {str(e)}")
# Méthodes d'accès aux données (database_sync_to_async)
@database_sync_to_async
def get_user(self, user_id):
try:
return Profile.objects.get(id=user_id)
except Profile.DoesNotExist:
return None
@database_sync_to_async
def get_user_display_name(self, user_id):
"""Obtenir le nom d'affichage d'un utilisateur"""
try:
user = Profile.objects.get(id=user_id)
if user.first_name and user.last_name:
return f"{user.first_name} {user.last_name}"
elif user.first_name:
return user.first_name
elif user.last_name:
return user.last_name
else:
return user.email or f"Utilisateur {user_id}"
except Profile.DoesNotExist:
return f"Utilisateur {user_id}"
@database_sync_to_async
def get_user_conversations(self, user_id):
return list(Conversation.objects.filter(
participants__participant_id=user_id,
participants__is_active=True,
is_active=True
).distinct())
@database_sync_to_async
def get_conversation_participants(self, conversation_id):
"""Obtenir tous les participants d'une conversation"""
return list(Profile.objects.filter(
conversation_participants__conversation_id=conversation_id,
conversation_participants__is_active=True
))
@database_sync_to_async
def get_conversations_data(self, user_id):
try:
user = Profile.objects.get(id=user_id)
conversations = Conversation.objects.filter(
participants__participant=user,
participants__is_active=True,
is_active=True
).distinct()
serializer = ConversationSerializer(conversations, many=True, context={'user': user})
return serializer.data
except Exception as e:
logger.error(f"Error getting conversations data: {str(e)}")
return []
@database_sync_to_async
def can_user_send_message(self, user_id, conversation_id):
return ConversationParticipant.objects.filter(
conversation_id=conversation_id,
participant_id=user_id,
is_active=True
).exists()
@database_sync_to_async
def create_message(self, conversation_id, sender_id, content, message_type, attachment=None):
try:
conversation = Conversation.objects.get(id=conversation_id)
sender = Profile.objects.get(id=sender_id)
message_data = {
'conversation': conversation,
'sender': sender,
'content': content,
'message_type': message_type
}
# Ajouter les informations du fichier si présent
if attachment:
message_data.update({
'file_url': attachment.get('fileUrl'),
'file_name': attachment.get('fileName'),
'file_size': attachment.get('fileSize'),
'file_type': attachment.get('fileType'),
})
# Si c'est un fichier, s'assurer que le type de message est correct
if attachment.get('fileType', '').startswith('image/'):
message_data['message_type'] = 'image'
else:
message_data['message_type'] = 'file'
message = Message.objects.create(**message_data)
# Mettre à jour l'activité de la conversation
conversation.last_activity = message.created_at
conversation.save(update_fields=['last_activity'])
return message
except Exception as e:
logger.error(f"Error creating message: {str(e)}")
return None
@database_sync_to_async
def serialize_message(self, message):
serializer = MessageSerializer(message)
return serialize_for_websocket(serializer.data)
@database_sync_to_async
def get_user_presence(self, user_id):
"""Récupérer la présence d'un utilisateur"""
try:
return UserPresence.objects.get(user_id=user_id)
except UserPresence.DoesNotExist:
return None
@database_sync_to_async
def update_user_presence(self, user_id, status):
try:
user = Profile.objects.get(id=user_id)
presence, created = UserPresence.objects.get_or_create(user=user)
old_status = presence.status
presence.status = status
presence.save()
# Si le statut a changé, notifier les autres utilisateurs
if old_status != status or created:
logger.info(f"User {user_id} presence changed from {old_status} to {status}")
return presence
except Exception as e:
logger.error(f"Error updating user presence: {str(e)}")
return None
@database_sync_to_async
def update_typing_status(self, user_id, conversation_id, is_typing):
try:
user = Profile.objects.get(id=user_id)
presence, created = UserPresence.objects.get_or_create(user=user)
if is_typing:
conversation = Conversation.objects.get(id=conversation_id)
presence.is_typing_in = conversation
else:
presence.is_typing_in = None
presence.save()
except Exception as e:
logger.error(f"Error updating typing status: {str(e)}")
@database_sync_to_async
def mark_conversation_as_read(self, user_id, conversation_id):
"""Marquer tous les messages non lus d'une conversation comme lus"""
try:
# Mettre à jour le last_read_at du participant
participant = ConversationParticipant.objects.get(
conversation_id=conversation_id,
participant_id=user_id
)
current_time = timezone.now()
participant.last_read_at = current_time
participant.save(update_fields=['last_read_at'])
# Créer des enregistrements MessageRead pour tous les messages non lus
# que l'utilisateur n'a pas encore explicitement lus
unread_messages = Message.objects.filter(
conversation_id=conversation_id,
created_at__lte=current_time,
is_deleted=False
).exclude(
sender_id=user_id # Exclure ses propres messages
).exclude(
read_by__participant_id=user_id # Exclure les messages déjà marqués comme lus
)
# Créer les enregistrements MessageRead en batch
message_reads = [
MessageRead(message=message, participant_id=user_id, read_at=current_time)
for message in unread_messages
]
if message_reads:
MessageRead.objects.bulk_create(message_reads, ignore_conflicts=True)
logger.info(f"Marked {len(message_reads)} messages as read for user {user_id} in conversation {conversation_id}")
except Exception as e:
logger.error(f"Error marking conversation as read: {str(e)}")
@database_sync_to_async
def auto_mark_read_for_online_users(self, message, conversation_id):
"""Auto-marquer comme lu pour les utilisateurs en ligne dans la conversation"""
try:
# Obtenir tous les participants de la conversation (synchrone)
participants = ConversationParticipant.objects.filter(
conversation_id=conversation_id,
is_active=True
).exclude(participant_id=message.sender.id)
# Obtenir l'heure de création du message
message_time = message.created_at
# Préparer les enregistrements MessageRead à créer
message_reads = []
for participant_obj in participants:
participant = participant_obj.participant
# Vérifier si l'utilisateur est en ligne (synchrone)
try:
presence = UserPresence.objects.filter(user=participant).first()
if presence and presence.status == 'online':
# Vérifier qu'il n'existe pas déjà un enregistrement MessageRead
if not MessageRead.objects.filter(message=message, participant=participant).exists():
message_reads.append(MessageRead(
message=message,
participant=participant,
read_at=message_time
))
except:
# En cas d'erreur de présence, ne pas marquer comme lu
continue
# Créer les enregistrements MessageRead en batch
if message_reads:
MessageRead.objects.bulk_create(message_reads, ignore_conflicts=True)
logger.info(f"Auto-marked {len(message_reads)} messages as read for online users in conversation {conversation_id}")
except Exception as e:
logger.error(f"Error in auto_mark_read_for_online_users: {str(e)}")

View File

@ -1,108 +0,0 @@
import jwt
import logging
from urllib.parse import parse_qs
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from channels.middleware import BaseMiddleware
from channels.db import database_sync_to_async
from Auth.models import Profile
logger = logging.getLogger(__name__)
@database_sync_to_async
def get_user(user_id):
"""Récupérer l'utilisateur de manière asynchrone"""
try:
return Profile.objects.get(id=user_id)
except Profile.DoesNotExist:
return AnonymousUser()
class JWTAuthMiddleware(BaseMiddleware):
"""Middleware pour l'authentification JWT dans les WebSockets"""
def __init__(self, inner):
super().__init__(inner)
def _check_cors_origin(self, scope):
"""Vérifier si l'origine est autorisée pour les WebSockets"""
origin = None
# Récupérer l'origine depuis les headers
for name, value in scope.get('headers', []):
if name == b'origin':
origin = value.decode('latin1')
break
if not origin:
logger.warning("Aucune origine trouvée dans les headers WebSocket")
return False
# Récupérer les origines autorisées depuis la configuration CORS
allowed_origins = getattr(settings, 'CORS_ALLOWED_ORIGINS', [])
# Si CORS_ORIGIN_ALLOW_ALL est True, autoriser toutes les origines
if getattr(settings, 'CORS_ORIGIN_ALLOW_ALL', False):
logger.info(f"Origine WebSocket autorisée (CORS_ORIGIN_ALLOW_ALL): {origin}")
return True
# Vérifier si l'origine est dans la liste des origines autorisées
if origin in allowed_origins:
logger.info(f"Origine WebSocket autorisée: {origin}")
return True
logger.warning(f"Origine WebSocket non autorisée: {origin}. Origines autorisées: {allowed_origins}")
return False
async def __call__(self, scope, receive, send):
# Vérifier les CORS pour les WebSockets
if not self._check_cors_origin(scope):
logger.error("Connexion WebSocket refusée: origine non autorisée")
# Fermer la connexion WebSocket avec un code d'erreur
await send({
'type': 'websocket.close',
'code': 1008 # Policy Violation
})
return
# Extraire le token de l'URL
query_string = parse_qs(scope['query_string'].decode())
token = query_string.get('token')
if token:
token = token[0]
try:
# Décoder le token JWT
payload = jwt.decode(
token,
settings.SIMPLE_JWT['SIGNING_KEY'],
algorithms=[settings.SIMPLE_JWT['ALGORITHM']]
)
# Vérifier que c'est un token d'accès
if payload.get('type') != 'access':
logger.warning(f"Token type invalide: {payload.get('type')}")
scope['user'] = AnonymousUser()
else:
# Récupérer l'utilisateur
user_id = payload.get('user_id')
user = await get_user(user_id)
scope['user'] = user
logger.info(f"Utilisateur authentifié via JWT: {user.email if hasattr(user, 'email') else 'Unknown'}")
except jwt.ExpiredSignatureError:
logger.warning("Token JWT expiré")
scope['user'] = AnonymousUser()
except jwt.InvalidTokenError as e:
logger.warning(f"Token JWT invalide: {str(e)}")
scope['user'] = AnonymousUser()
except Exception as e:
logger.error(f"Erreur lors de l'authentification JWT: {str(e)}")
scope['user'] = AnonymousUser()
else:
scope['user'] = AnonymousUser()
return await super().__call__(scope, receive, send)
def JWTAuthMiddlewareStack(inner):
"""Stack middleware pour l'authentification JWT"""
return JWTAuthMiddleware(inner)

View File

@ -1,30 +0,0 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Messagerie',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('objet', models.CharField(blank=True, default='', max_length=200)),
('corpus', models.CharField(blank=True, default='', max_length=200)),
('date_envoi', models.DateTimeField(auto_now_add=True)),
('is_read', models.BooleanField(default=False)),
('conversation_id', models.CharField(blank=True, default='', max_length=100)),
('destinataire', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='messages_recus', to=settings.AUTH_USER_MODEL)),
('emetteur', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='messages_envoyes', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -1,87 +0,0 @@
# Generated by Django 5.1.3 on 2025-05-30 07:40
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('GestionMessagerie', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Conversation',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(blank=True, max_length=255, null=True)),
('conversation_type', models.CharField(choices=[('private', 'Privée'), ('group', 'Groupe')], default='private', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('last_activity', models.DateTimeField(default=django.utils.timezone.now)),
('is_active', models.BooleanField(default=True)),
],
),
migrations.CreateModel(
name='Message',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('content', models.TextField()),
('message_type', models.CharField(choices=[('text', 'Texte'), ('file', 'Fichier'), ('image', 'Image'), ('system', 'Système')], default='text', max_length=10)),
('file_url', models.URLField(blank=True, null=True)),
('file_name', models.CharField(blank=True, max_length=255, null=True)),
('file_size', models.BigIntegerField(blank=True, null=True)),
('file_type', models.CharField(blank=True, max_length=100, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_edited', models.BooleanField(default=False)),
('is_deleted', models.BooleanField(default=False)),
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='GestionMessagerie.conversation')),
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['created_at'],
},
),
migrations.CreateModel(
name='UserPresence',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('online', 'En ligne'), ('away', 'Absent'), ('busy', 'Occupé'), ('offline', 'Hors ligne')], default='offline', max_length=10)),
('last_seen', models.DateTimeField(default=django.utils.timezone.now)),
('is_typing_in', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='typing_users', to='GestionMessagerie.conversation')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='presence', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='ConversationParticipant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('joined_at', models.DateTimeField(auto_now_add=True)),
('last_read_at', models.DateTimeField(default=django.utils.timezone.now)),
('is_active', models.BooleanField(default=True)),
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='GestionMessagerie.conversation')),
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conversation_participants', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('conversation', 'participant')},
},
),
migrations.CreateModel(
name='MessageRead',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('read_at', models.DateTimeField(auto_now_add=True)),
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_by', to='GestionMessagerie.message')),
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_messages', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('message', 'participant')},
},
),
]

View File

@ -1,104 +1,6 @@
from django.db import models from django.db import models
from Auth.models import Profile from Auth.models import Profile
from django.utils import timezone
import uuid
class Conversation(models.Model):
"""Modèle pour gérer les conversations entre utilisateurs"""
CONVERSATION_TYPES = [
('private', 'Privée'),
('group', 'Groupe'),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=255, blank=True, null=True) # Nom pour les groupes
conversation_type = models.CharField(max_length=10, choices=CONVERSATION_TYPES, default='private')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
last_activity = models.DateTimeField(default=timezone.now)
is_active = models.BooleanField(default=True)
def __str__(self):
if self.name:
return f'Conversation: {self.name}'
return f'Conversation {self.id}'
def get_participants(self):
return Profile.objects.filter(conversation_participants__conversation=self)
class ConversationParticipant(models.Model):
"""Modèle pour gérer les participants d'une conversation"""
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='participants')
participant = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name='conversation_participants')
joined_at = models.DateTimeField(auto_now_add=True)
last_read_at = models.DateTimeField(default=timezone.now)
is_active = models.BooleanField(default=True)
class Meta:
unique_together = ('conversation', 'participant')
def __str__(self):
return f'{self.participant.email} in {self.conversation.id}'
class Message(models.Model):
"""Modèle pour les messages instantanés"""
MESSAGE_TYPES = [
('text', 'Texte'),
('file', 'Fichier'),
('image', 'Image'),
('system', 'Système'),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='messages')
sender = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name='sent_messages')
content = models.TextField()
message_type = models.CharField(max_length=10, choices=MESSAGE_TYPES, default='text')
file_url = models.URLField(blank=True, null=True) # Pour les fichiers/images
file_name = models.CharField(max_length=255, blank=True, null=True) # Nom original du fichier
file_size = models.BigIntegerField(blank=True, null=True) # Taille en bytes
file_type = models.CharField(max_length=100, blank=True, null=True) # MIME type
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_edited = models.BooleanField(default=False)
is_deleted = models.BooleanField(default=False)
class Meta:
ordering = ['created_at']
def __str__(self):
return f'Message from {self.sender.email} at {self.created_at}'
class MessageRead(models.Model):
"""Modèle pour tracker les messages lus par chaque participant"""
message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='read_by')
participant = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name='read_messages')
read_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('message', 'participant')
def __str__(self):
return f'{self.participant.email} read {self.message.id}'
class UserPresence(models.Model):
"""Modèle pour gérer la présence des utilisateurs"""
PRESENCE_STATUS = [
('online', 'En ligne'),
('away', 'Absent'),
('busy', 'Occupé'),
('offline', 'Hors ligne'),
]
user = models.OneToOneField(Profile, on_delete=models.CASCADE, related_name='presence')
status = models.CharField(max_length=10, choices=PRESENCE_STATUS, default='offline')
last_seen = models.DateTimeField(default=timezone.now)
is_typing_in = models.ForeignKey(Conversation, on_delete=models.SET_NULL, null=True, blank=True, related_name='typing_users')
def __str__(self):
return f'{self.user.email} - {self.status}'
# Ancien modèle conservé pour compatibilité
class Messagerie(models.Model): class Messagerie(models.Model):
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
objet = models.CharField(max_length=200, default="", blank=True) objet = models.CharField(max_length=200, default="", blank=True)

View File

@ -1,7 +0,0 @@
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r'ws/chat/(?P<user_id>\w+)/$', consumers.ChatConsumer.as_asgi()),
re_path(r'ws/chat/conversation/(?P<conversation_id>[\w-]+)/$', consumers.ChatConsumer.as_asgi()),
]

View File

@ -1,259 +1,8 @@
from rest_framework import serializers from rest_framework import serializers
from Auth.models import Profile from Auth.models import Profile
from GestionMessagerie.models import Messagerie, Conversation, ConversationParticipant, Message, MessageRead, UserPresence from GestionMessagerie.models import Messagerie
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
class ProfileSimpleSerializer(serializers.ModelSerializer):
"""Sérialiseur simple pour les profils utilisateur"""
class Meta:
model = Profile
fields = ['id', 'first_name', 'last_name', 'email']
class UserPresenceSerializer(serializers.ModelSerializer):
"""Sérialiseur pour la présence utilisateur"""
user = ProfileSimpleSerializer(read_only=True)
class Meta:
model = UserPresence
fields = ['user', 'status', 'last_seen', 'is_typing_in']
class MessageReadSerializer(serializers.ModelSerializer):
"""Sérialiseur pour les messages lus"""
participant = ProfileSimpleSerializer(read_only=True)
class Meta:
model = MessageRead
fields = ['participant', 'read_at']
class MessageSerializer(serializers.ModelSerializer): class MessageSerializer(serializers.ModelSerializer):
"""Sérialiseur pour les messages instantanés"""
sender = ProfileSimpleSerializer(read_only=True)
read_by = MessageReadSerializer(many=True, read_only=True)
attachment = serializers.SerializerMethodField()
is_read = serializers.SerializerMethodField()
class Meta:
model = Message
fields = ['id', 'conversation', 'sender', 'content', 'message_type', 'file_url',
'file_name', 'file_size', 'file_type', 'attachment',
'created_at', 'updated_at', 'is_edited', 'is_deleted', 'read_by', 'is_read']
read_only_fields = ['id', 'created_at', 'updated_at']
def get_attachment(self, obj):
"""Retourne les informations du fichier attaché sous forme d'objet"""
if obj.file_url:
return {
'fileName': obj.file_name,
'fileSize': obj.file_size,
'fileType': obj.file_type,
'fileUrl': obj.file_url,
}
return None
def get_is_read(self, obj):
"""Détermine si le message est lu par l'utilisateur actuel"""
user = self.context.get('user')
if not user or not user.is_authenticated:
return False
# Si c'est le message de l'utilisateur lui-même, vérifier si quelqu'un d'autre l'a lu
if obj.sender == user:
# Pour les messages envoyés par l'utilisateur, vérifier si au moins un autre participant l'a explicitement lu
# Utiliser le modèle MessageRead pour une vérification précise
from .models import MessageRead
other_participants = obj.conversation.participants.exclude(participant=user).filter(is_active=True)
for participant in other_participants:
# Vérifier si ce participant a explicitement lu ce message
if MessageRead.objects.filter(message=obj, participant=participant.participant).exists():
return True
# Fallback: vérifier last_read_at seulement si l'utilisateur était en ligne récemment
# ou si last_read_at est postérieur à created_at (lecture explicite après réception)
if (participant.last_read_at and
participant.last_read_at > obj.created_at):
# Vérifier la présence de l'utilisateur pour s'assurer qu'il était en ligne
try:
from .models import UserPresence
user_presence = UserPresence.objects.filter(user=participant.participant).first()
# Si l'utilisateur était en ligne récemment (dans les 5 minutes suivant le message)
# ou si last_read_at est bien après created_at (lecture délibérée)
time_diff = participant.last_read_at - obj.created_at
if (user_presence and user_presence.last_seen and
user_presence.last_seen >= obj.created_at) or time_diff.total_seconds() > 10:
return True
except:
# En cas d'erreur, continuer avec la logique conservative
pass
return False
else:
# Pour les messages reçus, vérifier si l'utilisateur actuel l'a lu
# D'abord vérifier dans MessageRead pour une lecture explicite
from .models import MessageRead
if MessageRead.objects.filter(message=obj, participant=user).exists():
return True
# Fallback: vérifier last_read_at du participant
participant = obj.conversation.participants.filter(
participant=user,
is_active=True
).first()
if participant and participant.last_read_at:
# Seulement considérer comme lu si last_read_at est postérieur à created_at
return participant.last_read_at > obj.created_at
return False
class ConversationParticipantSerializer(serializers.ModelSerializer):
"""Sérialiseur pour les participants d'une conversation"""
participant = ProfileSimpleSerializer(read_only=True)
class Meta:
model = ConversationParticipant
fields = ['participant', 'joined_at', 'last_read_at', 'is_active']
class ConversationSerializer(serializers.ModelSerializer):
"""Sérialiseur pour les conversations"""
participants = ConversationParticipantSerializer(many=True, read_only=True)
last_message = serializers.SerializerMethodField()
unread_count = serializers.SerializerMethodField()
interlocuteur = serializers.SerializerMethodField()
class Meta:
model = Conversation
fields = ['id', 'name', 'conversation_type', 'created_at', 'updated_at',
'last_activity', 'is_active', 'participants', 'last_message', 'unread_count', 'interlocuteur']
read_only_fields = ['id', 'created_at', 'updated_at']
def get_last_message(self, obj):
last_message = obj.messages.filter(is_deleted=False).last()
if last_message:
return MessageSerializer(last_message).data
return None
def get_unread_count(self, obj):
user = self.context.get('user')
if not user or not user.is_authenticated:
return 0
participant = obj.participants.filter(participant=user).first()
if not participant:
return 0
# Nouvelle logique : compter les messages qui ne sont PAS dans MessageRead
# et qui ont été créés après last_read_at (ou tous si last_read_at est None)
# Base query : messages de la conversation, excluant les propres messages et les supprimés
# ET ne comptant que les messages textuels
base_query = obj.messages.filter(
is_deleted=False,
message_type='text' # Ne compter que les messages textuels
).exclude(sender=user)
# Si l'utilisateur n'a pas de last_read_at, tous les messages sont non lus
if not participant.last_read_at:
unread_from_timestamp = base_query
else:
# Messages créés après le dernier moment de lecture
unread_from_timestamp = base_query.filter(
created_at__gt=participant.last_read_at
)
# Soustraire les messages explicitement marqués comme lus dans MessageRead
from .models import MessageRead
read_message_ids = MessageRead.objects.filter(
participant=user,
message__conversation=obj
).values_list('message_id', flat=True)
# Compter les messages non lus = messages après last_read_at MOINS ceux explicitement lus
unread_count = unread_from_timestamp.exclude(
id__in=read_message_ids
).count()
return unread_count
def get_interlocuteur(self, obj):
"""Pour les conversations privées, retourne l'autre participant"""
user = self.context.get('user')
if not user or not user.is_authenticated or obj.conversation_type != 'private':
return None
# Trouver l'autre participant (pas l'utilisateur actuel)
other_participant = obj.participants.filter(is_active=True).exclude(participant=user).first()
if other_participant:
return ProfileSimpleSerializer(other_participant.participant).data
return None
class ConversationCreateSerializer(serializers.ModelSerializer):
"""Sérialiseur pour créer une conversation"""
participant_ids = serializers.ListField(
child=serializers.IntegerField(),
write_only=True
)
class Meta:
model = Conversation
fields = ['name', 'conversation_type', 'participant_ids']
def create(self, validated_data):
participant_ids = validated_data.pop('participant_ids')
conversation_type = validated_data.get('conversation_type', 'private')
# Pour les conversations privées, ne pas utiliser de nom spécifique
# Le nom sera géré côté frontend en affichant le nom de l'interlocuteur
if conversation_type == 'private':
validated_data['name'] = None
conversation = super().create(validated_data)
# Ajouter les participants
participants = []
for participant_id in participant_ids:
try:
participant = Profile.objects.get(id=participant_id)
ConversationParticipant.objects.create(
conversation=conversation,
participant=participant
)
participants.append(participant)
except Profile.DoesNotExist:
continue
# Notifier les participants via WebSocket de la nouvelle conversation
try:
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
channel_layer = get_channel_layer()
if channel_layer:
# Envoyer à chaque participant avec le bon contexte
for participant in participants:
# Sérialiser la conversation avec le contexte de ce participant
conversation_data = ConversationSerializer(conversation, context={'user': participant}).data
async_to_sync(channel_layer.group_send)(
f'user_{participant.id}',
{
'type': 'new_conversation_notification',
'conversation': conversation_data
}
)
except Exception as e:
# Log l'erreur mais ne pas interrompre la création de la conversation
import logging
logger = logging.getLogger(__name__)
logger.error(f"Erreur lors de la notification WebSocket de nouvelle conversation: {str(e)}")
return conversation
# Ancien sérialiseur conservé pour compatibilité
class MessageLegacySerializer(serializers.ModelSerializer):
destinataire_profil = serializers.SerializerMethodField() destinataire_profil = serializers.SerializerMethodField()
emetteur_profil = serializers.SerializerMethodField() emetteur_profil = serializers.SerializerMethodField()
class Meta: class Meta:

View File

@ -1,22 +1,15 @@
from django.urls import path from django.urls import path, re_path
from .views import ( from .views import SendEmailView, search_recipients, ConversationListView, ConversationMessagesView, MarkAsReadView
InstantConversationListView, InstantConversationCreateView, InstantConversationDeleteView, from GestionMessagerie.views import MessagerieView, MessageView, MessageSimpleView
InstantMessageListView, InstantMessageCreateView,
InstantMarkAsReadView, FileUploadView,
InstantRecipientSearchView
)
urlpatterns = [ urlpatterns = [
# URLs pour messagerie instantanée re_path(r'^messagerie/(?P<profile_id>[0-9]+)$', MessagerieView.as_view(), name="messagerie"),
path('conversations/', InstantConversationListView.as_view(), name='conversations'), re_path(r'^messages$', MessageView.as_view(), name="messages"),
path('create-conversation/', InstantConversationCreateView.as_view(), name='create_conversation'), re_path(r'^messages/(?P<id>[0-9]+)$', MessageSimpleView.as_view(), name="messages"),
path('send-message/', InstantMessageCreateView.as_view(), name='send_message'), path('send-email/', SendEmailView.as_view(), name='send_email'),
path('conversations/mark-as-read/', InstantMarkAsReadView.as_view(), name='mark_as_read'), path('search-recipients/', search_recipients, name='search_recipients'),
path('search-recipients/', InstantRecipientSearchView.as_view(), name='search_recipients'), # Endpoints pour le chat instantané
path('upload-file/', FileUploadView.as_view(), name='upload_file'), path('conversations/<int:profile_id>/', ConversationListView.as_view(), name='conversations'),
path('conversations/messages/<str:conversation_id>/', ConversationMessagesView.as_view(), name='conversation_messages'),
# URLs avec paramètres - doivent être après les URLs statiques path('conversations/mark-as-read/<str:conversation_id>/', MarkAsReadView.as_view(), name='mark_as_read'),
path('conversations/user/<int:user_id>/', InstantConversationListView.as_view(), name='conversations_by_user'),
path('conversations/<uuid:conversation_id>/', InstantConversationDeleteView.as_view(), name='delete_conversation'),
path('conversations/<uuid:conversation_id>/messages/', InstantMessageListView.as_view(), name='conversation_messages'),
] ]

View File

@ -1,455 +1,211 @@
from django.http.response import JsonResponse
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.parsers import JSONParser
from django.conf import settings
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from rest_framework.parsers import MultiPartParser, FormParser from django.db.models import Q
from django.db import models from .models import Messagerie
from .models import Conversation, ConversationParticipant, Message, UserPresence
from Auth.models import Profile, ProfileRole from Auth.models import Profile, ProfileRole
from GestionMessagerie.serializers import ( from GestionMessagerie.serializers import MessageSerializer
ConversationSerializer, MessageSerializer, from School.models import Teacher
ConversationCreateSerializer, UserPresenceSerializer,
ProfileSimpleSerializer from School.serializers import TeacherSerializer
)
import N3wtSchool.mailManager as mailer
from N3wtSchool import bdd
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi from drf_yasg import openapi
from django.utils import timezone from rest_framework.exceptions import NotFound
import os
import uuid
import logging
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
from django.db.models import Q
logger = logging.getLogger(__name__) class MessagerieView(APIView):
def get(self, request, profile_id):
messagesList = bdd.getObjects(_objectName=Messagerie, _columnName='destinataire__id', _value=profile_id)
messages_serializer = MessageSerializer(messagesList, many=True)
return JsonResponse(messages_serializer.data, safe=False)
# ====================== MESSAGERIE INSTANTANÉE ====================== class MessageView(APIView):
class InstantConversationListView(APIView):
"""
API pour lister les conversations instantanées d'un utilisateur
"""
@swagger_auto_schema(
operation_description="Liste les conversations instantanées d'un utilisateur",
responses={200: ConversationSerializer(many=True)}
)
def get(self, request, user_id=None):
try:
user = Profile.objects.get(id=user_id)
conversations = Conversation.objects.filter(
participants__participant=user,
participants__is_active=True,
is_active=True
).distinct().order_by('-last_activity')
serializer = ConversationSerializer(conversations, many=True, context={'user': user})
return Response(serializer.data, status=status.HTTP_200_OK)
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)
class InstantConversationCreateView(APIView):
"""
API pour créer une nouvelle conversation instantanée
"""
@swagger_auto_schema(
operation_description="Crée une nouvelle conversation instantanée",
request_body=ConversationCreateSerializer,
responses={201: ConversationSerializer}
)
def post(self, request): def post(self, request):
serializer = ConversationCreateSerializer(data=request.data) message_data=JSONParser().parse(request)
if serializer.is_valid(): message_serializer = MessageSerializer(data=message_data)
conversation = serializer.save()
response_serializer = ConversationSerializer(conversation, context={'user': request.user})
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class InstantMessageListView(APIView): if message_serializer.is_valid():
""" message_serializer.save()
API pour lister les messages d'une conversation
"""
@swagger_auto_schema(
operation_description="Liste les messages d'une conversation",
responses={200: MessageSerializer(many=True)}
)
def get(self, request, conversation_id):
try:
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 return JsonResponse('Nouveau Message ajouté', safe=False)
user_id = request.GET.get('user_id')
user = None
if user_id:
try:
user = Profile.objects.get(id=user_id)
except Profile.DoesNotExist:
pass
serializer = MessageSerializer(messages, many=True, context={'user': user}) return JsonResponse(message_serializer.errors, safe=False)
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)
class InstantMessageCreateView(APIView): class MessageSimpleView(APIView):
def get(self, request, id):
message=bdd.getObject(Messagerie, "id", id)
message_serializer=MessageSerializer(message)
return JsonResponse(message_serializer.data, safe=False)
class SendEmailView(APIView):
""" """
API pour envoyer un nouveau message instantané API pour envoyer des emails aux parents et professeurs.
""" """
@swagger_auto_schema(
operation_description="Envoie un nouveau message instantané",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'conversation_id': openapi.Schema(type=openapi.TYPE_STRING, description='ID de la conversation'),
'sender_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID de l\'expéditeur'),
'content': openapi.Schema(type=openapi.TYPE_STRING, description='Contenu du message'),
'message_type': openapi.Schema(type=openapi.TYPE_STRING, description='Type de message', default='text')
},
required=['conversation_id', 'sender_id', 'content']
),
responses={201: MessageSerializer}
)
def post(self, request): def post(self, request):
data = request.data
recipients = data.get('recipients', [])
cc = data.get('cc', [])
bcc = data.get('bcc', [])
subject = data.get('subject', 'Notification')
message = data.get('message', '')
establishment_id = data.get('establishment_id', '')
if not recipients or not message:
return Response({'error': 'Les destinataires et le message sont requis.'}, status=status.HTTP_400_BAD_REQUEST)
try: try:
conversation_id = request.data.get('conversation_id') # Récupérer la connexion SMTP
sender_id = request.data.get('sender_id') connection = mailer.getConnection(establishment_id)
content = request.data.get('content', '').strip()
message_type = request.data.get('message_type', 'text')
if not all([conversation_id, sender_id, content]): # Envoyer l'email
return Response( return mailer.sendMail(
{'error': 'conversation_id, sender_id, and content are required'}, subject=subject,
status=status.HTTP_400_BAD_REQUEST message=message,
recipients=recipients,
cc=cc,
bcc=bcc,
attachments=[],
connection=connection
) )
except NotFound as e:
# Vérifier que la conversation existe return Response({'error': str(e)}, status=status.HTTP_404_NOT_FOUND)
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)
participant = ConversationParticipant.objects.filter(
conversation=conversation,
participant=sender,
is_active=True
).first()
if not participant:
return Response(
{'error': 'You are not a participant in this conversation'},
status=status.HTTP_403_FORBIDDEN
)
# Récupérer les données de fichier si disponibles
file_url = request.data.get('file_url')
file_name = request.data.get('file_name')
file_type = request.data.get('file_type')
file_size = request.data.get('file_size')
# Créer le message
message = Message.objects.create(
conversation=conversation,
sender=sender,
content=content,
message_type=message_type,
file_url=file_url,
file_name=file_name,
file_type=file_type,
file_size=file_size
)
# Mettre à jour l'activité de la conversation
conversation.last_activity = message.created_at
conversation.save(update_fields=['last_activity'])
serializer = MessageSerializer(message)
return Response(serializer.data, status=status.HTTP_201_CREATED)
except Conversation.DoesNotExist:
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: except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class InstantMarkAsReadView(APIView): class ContactsView(APIView):
""" """
API pour marquer une conversation comme lue API pour récupérer les contacts associés à un établissement.
""" """
@swagger_auto_schema( def get(self, request, establishment_id):
operation_description="Marque une conversation comme lue pour un utilisateur",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'user_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID de l\'utilisateur')
},
required=['user_id']
),
responses={200: openapi.Response('Success')}
)
def post(self, request, conversation_id):
try: try:
user_id = request.data.get('user_id') # Récupérer les enseignants associés à l'établissement
if not user_id: teachers = Teacher.objects.filter(profile_role__establishment_id=establishment_id)
return Response({'error': 'user_id is required'}, status=status.HTTP_400_BAD_REQUEST) teachers_serializer = TeacherSerializer(teachers, many=True)
participant = ConversationParticipant.objects.get( # Ajouter un contact pour l'administration
conversation_id=conversation_id, admin_contact = {
participant_id=user_id, "id": "admin",
is_active=True "name": "Administration",
) "email": "admin@etablissement.com",
"profilePic": "https://www.gravatar.com/avatar/admin"
participant.last_read_at = timezone.now()
participant.save(update_fields=['last_read_at'])
return Response({'status': 'success'}, status=status.HTTP_200_OK)
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)
class UserPresenceView(APIView):
"""
API pour gérer la présence des utilisateurs
"""
@swagger_auto_schema(
operation_description="Met à jour le statut de présence d'un utilisateur",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'status': openapi.Schema(type=openapi.TYPE_STRING, description='Statut de présence')
},
required=['status']
),
responses={200: UserPresenceSerializer}
)
def post(self, request, user_id):
try:
user = Profile.objects.get(id=user_id)
status_value = request.data.get('status')
if status_value not in ['online', 'away', 'busy', 'offline']:
return Response({'error': 'Invalid status'}, status=status.HTTP_400_BAD_REQUEST)
presence, created = UserPresence.objects.get_or_create(user=user)
presence.status = status_value
presence.last_seen = timezone.now()
presence.save()
serializer = UserPresenceSerializer(presence)
return Response(serializer.data, status=status.HTTP_200_OK)
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)
@swagger_auto_schema(
operation_description="Récupère le statut de présence d'un utilisateur",
responses={200: UserPresenceSerializer}
)
def get(self, request, user_id):
try:
user = Profile.objects.get(id=user_id)
presence, created = UserPresence.objects.get_or_create(user=user)
if created:
presence.status = 'offline'
presence.save()
serializer = UserPresenceSerializer(presence)
return Response(serializer.data, status=status.HTTP_200_OK)
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)
class FileUploadView(APIView):
"""
API pour l'upload de fichiers dans la messagerie instantanée
"""
parser_classes = (MultiPartParser, FormParser)
@swagger_auto_schema(
operation_description="Upload un fichier pour la messagerie",
manual_parameters=[
openapi.Parameter('file', openapi.IN_FORM, description="Fichier à uploader", type=openapi.TYPE_FILE, required=True),
openapi.Parameter('conversation_id', openapi.IN_FORM, description="ID de la conversation", type=openapi.TYPE_INTEGER, required=True),
openapi.Parameter('sender_id', openapi.IN_FORM, description="ID de l'expéditeur", type=openapi.TYPE_INTEGER, required=True),
],
responses={
200: openapi.Response('Success', openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'fileUrl': openapi.Schema(type=openapi.TYPE_STRING),
'fileName': openapi.Schema(type=openapi.TYPE_STRING),
'fileSize': openapi.Schema(type=openapi.TYPE_INTEGER),
'fileType': openapi.Schema(type=openapi.TYPE_STRING),
} }
)),
400: 'Bad Request',
413: 'File too large',
415: 'Unsupported file type'
}
)
def post(self, request):
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)
# Vérifier que la conversation existe et que l'utilisateur y participe
try:
conversation = Conversation.objects.get(id=conversation_id)
sender = Profile.objects.get(id=sender_id)
# Vérifier que l'expéditeur participe à la conversation
if not ConversationParticipant.objects.filter(
conversation=conversation,
participant=sender,
is_active=True
).exists():
return Response({'error': 'Accès non autorisé à cette conversation'}, status=status.HTTP_403_FORBIDDEN)
except (Conversation.DoesNotExist, Profile.DoesNotExist):
return Response({'error': 'Conversation ou utilisateur introuvable'}, status=status.HTTP_404_NOT_FOUND)
# Valider le type de fichier
allowed_types = [
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain'
]
if file.content_type not in allowed_types:
return Response({'error': 'Type de fichier non autorisé'}, status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE)
# Valider la taille du fichier (10MB max)
max_size = 10 * 1024 * 1024 # 10MB
if file.size > max_size:
return Response({'error': 'Fichier trop volumineux (max 10MB)'}, status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE)
# Générer un nom de fichier unique
file_extension = os.path.splitext(file.name)[1]
unique_filename = f"{uuid.uuid4()}{file_extension}"
# Chemin de stockage : messagerie/conversation_id/
storage_path = f"messagerie/{conversation_id}/{unique_filename}"
# Sauvegarder le fichier
file_path = default_storage.save(storage_path, ContentFile(file.read()))
# Générer l'URL du fichier
file_url = default_storage.url(file_path)
if not file_url.startswith('http'):
# Construire l'URL complète si nécessaire
file_url = request.build_absolute_uri(file_url)
return Response({
'fileUrl': file_url,
'fileName': file.name,
'fileSize': file.size,
'fileType': file.content_type,
'filePath': file_path
}, status=status.HTTP_200_OK)
contacts = [admin_contact] + teachers_serializer.data
return Response(contacts, status=status.HTTP_200_OK)
except Exception as e: except Exception as e:
return Response({'error': f'Erreur lors de l\'upload: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class InstantRecipientSearchView(APIView): def search_recipients(request):
""" """
API pour rechercher des destinataires pour la messagerie instantanée API pour rechercher des destinataires en fonction d'un terme de recherche et d'un établissement.
""" """
@swagger_auto_schema( query = request.GET.get('q', '').strip() # Récupérer le terme de recherche depuis les paramètres GET
operation_description="Recherche des destinataires pour la messagerie instantanée", establishment_id = request.GET.get('establishment_id', None) # Récupérer l'ID de l'établissement
manual_parameters=[
openapi.Parameter('establishment_id', openapi.IN_QUERY, description="ID de l'établissement", type=openapi.TYPE_INTEGER, required=True), if not query:
openapi.Parameter('q', openapi.IN_QUERY, description="Terme de recherche", type=openapi.TYPE_STRING, required=True) return JsonResponse([], safe=False) # Retourner une liste vide si aucun terme n'est fourni
],
responses={200: ProfileSimpleSerializer(many=True)}
)
def get(self, request):
try:
establishment_id = request.query_params.get('establishment_id')
search_query = request.query_params.get('q', '').strip()
if not establishment_id: if not establishment_id:
return Response({'error': 'establishment_id is required'}, status=status.HTTP_400_BAD_REQUEST) return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
# Récupérer les IDs des profils actifs dans l'établissement # Rechercher dans les champs pertinents (nom, prénom, email) et filtrer par establishment_id
profiles = Profile.objects.filter(
Q(first_name__icontains=query) |
Q(last_name__icontains=query) |
Q(email__icontains=query),
roles__establishment_id=establishment_id, # Utiliser 'roles' au lieu de 'profilerole'
roles__is_active=True # Filtrer uniquement les ProfileRole actifs
).distinct()
# Construire la réponse avec les rôles associés
results = []
for profile in profiles:
profile_roles = ProfileRole.objects.filter( profile_roles = ProfileRole.objects.filter(
profile=profile,
establishment_id=establishment_id, establishment_id=establishment_id,
is_active=True is_active=True # Inclure uniquement les ProfileRole actifs
).values_list('profile_id', flat=True) ).values(
'id', 'role_type', 'establishment__name', 'is_active'
# Rechercher les profils correspondants
users = Profile.objects.filter(id__in=profile_roles)
# Appliquer le filtre de recherche si un terme est fourni
if search_query:
users = users.filter(
Q(first_name__icontains=search_query) |
Q(last_name__icontains=search_query) |
Q(email__icontains=search_query)
) )
results.append({
'id': profile.id,
'first_name': profile.first_name,
'last_name': profile.last_name,
'email': profile.email,
'roles': list(profile_roles) # Inclure tous les rôles actifs associés pour cet établissement
})
# Exclure l'utilisateur actuel des résultats return JsonResponse(results, safe=False)
if request.user.is_authenticated:
users = users.exclude(id=request.user.id)
serializer = ProfileSimpleSerializer(users[:10], many=True) # Limiter à 10 résultats class ConversationListView(APIView):
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class InstantConversationDeleteView(APIView):
""" """
API pour supprimer (désactiver) une conversation instantanée Liste les conversations d'un utilisateur (parent ou enseignant).
Retourne la liste des interlocuteurs et le dernier message échangé.
""" """
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Supprime une conversation instantanée (désactivation soft)", operation_description="Liste les conversations d'un utilisateur (parent ou enseignant).",
responses={200: openapi.Schema( responses={200: openapi.Response('Liste des conversations')}
)
def get(self, request, profile_id):
# Récupérer toutes les conversations où l'utilisateur est émetteur ou destinataire
messages = Messagerie.objects.filter(Q(emetteur_id=profile_id) | Q(destinataire_id=profile_id))
# Grouper par conversation_id
conversations = {}
for msg in messages.order_by('-date_envoi'):
conv_id = msg.conversation_id or f"{min(msg.emetteur_id, msg.destinataire_id)}_{max(msg.emetteur_id, msg.destinataire_id)}"
if conv_id not in conversations:
conversations[conv_id] = msg
# Préparer la réponse
data = []
for conv_id, last_msg in conversations.items():
interlocuteur = last_msg.emetteur if last_msg.destinataire_id == int(profile_id) else last_msg.destinataire
data.append({
'conversation_id': conv_id,
'last_message': MessageSerializer(last_msg).data,
'interlocuteur': {
'id': interlocuteur.id,
'first_name': interlocuteur.first_name,
'last_name': interlocuteur.last_name,
'email': interlocuteur.email,
}
})
return Response(data, status=status.HTTP_200_OK)
class ConversationMessagesView(APIView):
"""
Récupère tous les messages d'une conversation donnée.
"""
@swagger_auto_schema(
operation_description="Récupère tous les messages d'une conversation donnée.",
responses={200: openapi.Response('Liste des messages')}
)
def get(self, request, conversation_id):
messages = Messagerie.objects.filter(conversation_id=conversation_id).order_by('date_envoi')
serializer = MessageSerializer(messages, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
class MarkAsReadView(APIView):
"""
Marque tous les messages reçus dans une conversation comme lus pour l'utilisateur connecté.
"""
@swagger_auto_schema(
operation_description="Marque tous les messages reçus dans une conversation comme lus pour l'utilisateur connecté.",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT, type=openapi.TYPE_OBJECT,
properties={ properties={
'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), 'profile_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID du profil utilisateur')
'message': openapi.Schema(type=openapi.TYPE_STRING) },
} required=['profile_id']
)} ),
responses={200: openapi.Response('Statut OK')}
) )
def delete(self, request, conversation_id): def post(self, request, conversation_id):
try: profile_id = request.data.get('profile_id')
# Récupérer la conversation par son ID UUID Messagerie.objects.filter(conversation_id=conversation_id, destinataire_id=profile_id, is_read=False).update(is_read=True)
conversation = Conversation.objects.filter(id=conversation_id).first() return Response({'status': 'ok'}, status=status.HTTP_200_OK)
if not conversation:
return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND)
# Suppression simple : désactiver la conversation
conversation.is_active = False
conversation.save()
return Response({
'success': True,
'message': 'Conversation deleted successfully'
}, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error deleting conversation: {str(e)}")
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@ -1,28 +0,0 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message', models.CharField(max_length=255)),
('is_read', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('typeNotification', models.IntegerField(choices=[(0, 'Aucune notification'), (1, 'Un message a été reçu'), (2, "Le dossier d'inscription a été mis à jour")], default=0)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -8,40 +8,9 @@ https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
""" """
import os import os
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from django.urls import re_path
from django.conf import settings
from django.contrib.staticfiles.handlers import ASGIStaticFilesHandler
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'N3wtSchool.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'N3wtSchool.settings')
# Initialize Django ASGI application early to ensure the AppRegistry application = get_asgi_application()
# is populated before importing code that may import ORM models.
django_asgi_app = get_asgi_application()
# Import consumers after Django is initialized
from GestionMessagerie.consumers import ChatConsumer
from GestionMessagerie.middleware import JWTAuthMiddlewareStack
# WebSocket URL patterns
websocket_urlpatterns = [
re_path(r'ws/chat/(?P<user_id>\w+)/$', ChatConsumer.as_asgi()),
]
# Créer l'application ASGI avec gestion des fichiers statiques
if settings.DEBUG:
# En mode DEBUG, utiliser ASGIStaticFilesHandler pour servir les fichiers statiques
http_application = ASGIStaticFilesHandler(django_asgi_app)
else:
http_application = django_asgi_app
application = ProtocolTypeRouter({
"http": http_application,
"websocket": AllowedHostsOriginValidator(
JWTAuthMiddlewareStack(
URLRouter(websocket_urlpatterns)
)
),
})

View File

@ -1,5 +1,4 @@
from typing import Final from typing import Final
from N3wtSchool import settings
WRONG_ID: Final = 1 WRONG_ID: Final = 1
INCOMPLETE: Final = 2 INCOMPLETE: Final = 2
@ -9,14 +8,11 @@ DIFFERENT_PASWWORD: Final = 5
PROFIL_NOT_EXISTS: Final = 6 PROFIL_NOT_EXISTS: Final = 6
MESSAGE_REINIT_PASSWORD: Final = 7 MESSAGE_REINIT_PASSWORD: Final = 7
EXPIRED_URL: Final = 8 EXPIRED_URL: Final = 8
PASSWORD_CHANGED: Final = 9 PASSWORD_CHANGED: Final = 8
WRONG_MAIL_FORMAT: Final = 10 WRONG_MAIL_FORMAT: Final = 9
PROFIL_INACTIVE: Final = 11 PROFIL_INACTIVE: Final = 10
MESSAGE_ACTIVATION_PROFILE: Final = 12 MESSAGE_ACTIVATION_PROFILE: Final = 11
PROFIL_ACTIVE: Final = 13 PROFIL_ACTIVE: Final = 12
def get_expired_url_message():
return f"L'URL a expiré. Effectuer à nouveau la demande de réinitialisation de mot de passe : {settings.BASE_URL}/password/new"
returnMessage = { returnMessage = {
WRONG_ID:'Identifiants invalides', WRONG_ID:'Identifiants invalides',
@ -26,7 +22,7 @@ returnMessage = {
DIFFERENT_PASWWORD: 'Les mots de passe ne correspondent pas', DIFFERENT_PASWWORD: 'Les mots de passe ne correspondent pas',
PROFIL_NOT_EXISTS: 'Aucun profil associé à cet utilisateur', PROFIL_NOT_EXISTS: 'Aucun profil associé à cet utilisateur',
MESSAGE_REINIT_PASSWORD: 'Un mail a été envoyé à l\'adresse \'%s\'', MESSAGE_REINIT_PASSWORD: 'Un mail a été envoyé à l\'adresse \'%s\'',
EXPIRED_URL: get_expired_url_message(), EXPIRED_URL:'L\'URL a expiré. Effectuer à nouveau la demande de réinitialisation de mot de passe : http://localhost:3000/password/reset?uuid=%s',
PASSWORD_CHANGED: 'Le mot de passe a été réinitialisé', PASSWORD_CHANGED: 'Le mot de passe a été réinitialisé',
WRONG_MAIL_FORMAT: 'L\'adresse mail est mal formatée', WRONG_MAIL_FORMAT: 'L\'adresse mail est mal formatée',
PROFIL_INACTIVE: 'Le profil n\'est pas actif', PROFIL_INACTIVE: 'Le profil n\'est pas actif',

View File

@ -6,58 +6,40 @@ import re
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from rest_framework.exceptions import NotFound from rest_framework.exceptions import NotFound
from Settings.models import SMTPSettings from Settings.models import MailSettings
from Establishment.models import Establishment # Importer le modèle Establishment from Establishment.models import Establishment # Importer le modèle Establishment
import logging
# Ajouter un logger pour debug
logger = logging.getLogger(__name__)
def getConnection(id_establishement): def getConnection(id_establishement):
try: try:
# Récupérer l'instance de l'établissement # Récupérer l'instance de l'établissement
establishment = Establishment.objects.get(id=id_establishement) establishment = Establishment.objects.get(id=id_establishement)
try:
# Récupérer les paramètres SMTP associés à l'établissement # Récupérer les paramètres SMTP associés à l'établissement
smtp_settings = SMTPSettings.objects.get(establishment=establishment) smtp_settings = MailSettings.objects.get(establishment=establishment)
# Créer une connexion SMTP avec les paramètres récupérés # Créer une connexion SMTP avec les paramètres récupérés
connection = get_connection( connection = get_connection(
host=smtp_settings.smtp_server, host=smtp_settings.smtp_server,
port=smtp_settings.smtp_port, port=smtp_settings.smtp_port,
username=smtp_settings.smtp_user, username=smtp_settings.mail_user,
password=smtp_settings.smtp_password, password=smtp_settings.mail_password,
use_tls=smtp_settings.use_tls, use_tls=smtp_settings.use_tls,
use_ssl=smtp_settings.use_ssl use_ssl=smtp_settings.use_ssl
) )
return connection return connection
except SMTPSettings.DoesNotExist:
# Aucun paramètre SMTP spécifique, retournera None
return None
except Establishment.DoesNotExist: except Establishment.DoesNotExist:
raise NotFound(f"Aucun établissement trouvé avec l'ID {id_establishement}") raise NotFound(f"Aucun établissement trouvé avec l'ID {id_establishement}")
except MailSettings.DoesNotExist:
raise NotFound(f"Aucun paramètre SMTP trouvé pour l'établissement {id_establishement}")
def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connection=None): def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connection=None):
try: try:
# S'assurer que recipients, cc, bcc sont des listes
if isinstance(recipients, str):
recipients = [recipients]
if isinstance(cc, str):
cc = [cc]
if isinstance(bcc, str):
bcc = [bcc]
# Récupération robuste du username
username = getattr(connection, 'username', None)
plain_message = strip_tags(message) plain_message = strip_tags(message)
if connection is not None:
from_email = username
else:
from_email = settings.EMAIL_HOST_USER from_email = settings.EMAIL_HOST_USER
if connection is not None:
from_email = connection.username
logger.info(f"From email: {from_email}")
email = EmailMultiAlternatives( email = EmailMultiAlternatives(
subject=subject, subject=subject,
@ -70,18 +52,15 @@ def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connec
) )
email.attach_alternative(message, "text/html") email.attach_alternative(message, "text/html")
# Ajout des pièces jointes
for attachment in attachments: for attachment in attachments:
# attachment doit être un tuple (filename, content, mimetype)
# ex: ("document.pdf", fichier.read(), "application/pdf")
email.attach(*attachment) email.attach(*attachment)
logger.info("Tentative d'envoi de l'email...")
email.send(fail_silently=False) email.send(fail_silently=False)
logger.info("Email envoyé avec succès !")
return Response({'message': 'Email envoyé avec succès.'}, status=status.HTTP_200_OK) return Response({'message': 'Email envoyé avec succès.'}, status=status.HTTP_200_OK)
except Exception as e: except Exception as e:
logger.error(f"Erreur lors de l'envoi de l'email: {str(e)}")
logger.error(f"Type d'erreur: {type(e)}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def envoieReinitMotDePasse(recipients, code): def envoieReinitMotDePasse(recipients, code):
@ -94,28 +73,7 @@ def envoieReinitMotDePasse(recipients, code):
} }
subject = EMAIL_REINIT_SUBJECT subject = EMAIL_REINIT_SUBJECT
html_message = render_to_string('emails/resetPassword.html', context) html_message = render_to_string('emails/resetPassword.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients) sendMail(subject, html_message, recipients)
except Exception as e:
errorMessage = str(e)
return errorMessage
def sendRegistrationDirector(recipients, establishment_id):
errorMessage = ''
try:
# Préparation du contexte pour le template
EMAIL_INSCRIPTION_SUBJECT = '[N3WT-SCHOOL] Bienvenue dans la communauté !'
context = {
'BASE_URL': settings.BASE_URL,
'URL_DJANGO': settings.URL_DJANGO,
'email': recipients,
'establishment': establishment_id
}
subject = EMAIL_INSCRIPTION_SUBJECT
html_message = render_to_string('emails/subscribeDirector.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients)
except Exception as e: except Exception as e:
errorMessage = str(e) errorMessage = str(e)
@ -133,11 +91,10 @@ def sendRegisterForm(recipients, establishment_id):
'email': recipients, 'email': recipients,
'establishment': establishment_id 'establishment': establishment_id
} }
# Récupérer la connexion SMTP
connection = getConnection(establishment_id)
subject = EMAIL_INSCRIPTION_SUBJECT subject = EMAIL_INSCRIPTION_SUBJECT
html_message = render_to_string('emails/inscription.html', context) html_message = render_to_string('emails/inscription.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection) sendMail(subject, html_message, recipients)
except Exception as e: except Exception as e:
@ -156,11 +113,9 @@ def sendMandatSEPA(recipients, establishment_id):
'establishment': establishment_id 'establishment': establishment_id
} }
# Récupérer la connexion SMTP
connection = getConnection(establishment_id)
subject = EMAIL_INSCRIPTION_SUBJECT subject = EMAIL_INSCRIPTION_SUBJECT
html_message = render_to_string('emails/sepa.html', context) html_message = render_to_string('emails/sepa.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection) sendMail(subject, html_message, recipients)
except Exception as e: except Exception as e:
errorMessage = str(e) errorMessage = str(e)
@ -199,3 +154,24 @@ def isValid(message, fiche_inscription):
mailReponsableAVerifier = responsable.mail mailReponsableAVerifier = responsable.mail
return responsableMail == mailReponsableAVerifier and str(idMail) == str(fiche_inscription.eleve.id) return responsableMail == mailReponsableAVerifier and str(idMail) == str(fiche_inscription.eleve.id)
def test_mailbox_uri(uri):
"""
Teste la validité d'une URI IMAP en tentant une connexion réelle.
Retourne True si la connexion réussit, False sinon.
"""
from django_mailbox.models import Mailbox
try:
mailbox = Mailbox(uri=uri)
conn = mailbox.get_connection()
# Essaye de récupérer un message (ou juste la connexion)
# Pour IMAP, get_message() va ouvrir et fermer la connexion
try:
next(conn.get_message())
except StopIteration:
# Aucun message, mais connexion OK
pass
return True
except Exception as e:
print(f"Erreur de connexion IMAP : {e}")
return False

View File

@ -1,11 +1,8 @@
from django.conf import settings
class ContentSecurityPolicyMiddleware: class ContentSecurityPolicyMiddleware:
def __init__(self, get_response): def __init__(self, get_response):
self.get_response = get_response self.get_response = get_response
def __call__(self, request): def __call__(self, request):
response = self.get_response(request) response = self.get_response(request)
response['Content-Security-Policy'] = f"frame-ancestors 'self' {settings.BASE_URL}" response['Content-Security-Policy'] = "frame-ancestors 'self' http://localhost:3000"
return response return response

View File

@ -24,16 +24,18 @@ BASE_DIR = Path(__file__).resolve().parent.parent
MEDIA_URL = '/data/' MEDIA_URL = '/data/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'data') MEDIA_ROOT = os.path.join(BASE_DIR, 'data')
BASE_URL = os.getenv('BASE_URL', 'http://localhost:3000') BASE_URL = os.getenv('CORS_ALLOWED_ORIGINS', 'http://localhost:3000')
LOGIN_REDIRECT_URL = '/Subscriptions/registerForms' LOGIN_REDIRECT_URL = '/Subscriptions/registerForms'
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-afjm6kvigncxzx6jjjf(qb0n(*qvi#je79r=gqflcn007d_ve9'
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv('DJANGO_DEBUG', True) DEBUG = True
ALLOWED_HOSTS = ['*'] ALLOWED_HOSTS = ['*']
@ -44,7 +46,6 @@ INSTALLED_APPS = [
'Subscriptions.apps.GestioninscriptionsConfig', 'Subscriptions.apps.GestioninscriptionsConfig',
'Auth.apps.GestionloginConfig', 'Auth.apps.GestionloginConfig',
'GestionMessagerie.apps.GestionMessagerieConfig', 'GestionMessagerie.apps.GestionMessagerieConfig',
'GestionEmail.apps.GestionEmailConfig',
'GestionNotification.apps.GestionNotificationConfig', 'GestionNotification.apps.GestionNotificationConfig',
'School.apps.SchoolConfig', 'School.apps.SchoolConfig',
'Planning.apps.PlanningConfig', 'Planning.apps.PlanningConfig',
@ -62,14 +63,14 @@ INSTALLED_APPS = [
'N3wtSchool', 'N3wtSchool',
'drf_yasg', 'drf_yasg',
'rest_framework_simplejwt', 'rest_framework_simplejwt',
'channels', 'django_mailbox',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware', # Déplacez ici, avant CorsMiddleware
'corsheaders.middleware.CorsMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
@ -161,16 +162,21 @@ LOGGING = {
"level": os.getenv("GESTION_MESSAGERIE_LOG_LEVEL", "INFO"), "level": os.getenv("GESTION_MESSAGERIE_LOG_LEVEL", "INFO"),
"propagate": False, "propagate": False,
}, },
"GestionEmail": {
"handlers": ["console"],
"level": os.getenv("GESTION_EMAIL_LOG_LEVEL", "INFO"),
"propagate": False,
},
"School": { "School": {
"handlers": ["console"], "handlers": ["console"],
"level": os.getenv("GESTION_ENSEIGNANTS_LOG_LEVEL", "INFO"), "level": os.getenv("GESTION_ENSEIGNANTS_LOG_LEVEL", "INFO"),
"propagate": False, "propagate": False,
}, },
"django_mailbox": {
"handlers": ["console"],
"level": "DEBUG",
"propagate": False,
},
"django_mailbox.models": {
"handlers": ["console"],
"level": "DEBUG",
"propagate": False,
},
}, },
} }
@ -209,6 +215,8 @@ USE_I18N = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.0/howto/static-files/ # https://docs.djangoproject.com/en/5.0/howto/static-files/
DEBUG = True
STATIC_URL = 'static/' STATIC_URL = 'static/'
STATICFILES_DIRS = [ STATICFILES_DIRS = [
@ -228,70 +236,62 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
######################################################################## ########################################################################
DJANGO_SUPERUSER_PASSWORD='admin'
DJANGO_SUPERUSER_USERNAME='admin'
DJANGO_SUPERUSER_EMAIL='admin@n3wtschool.com'
# Configuration de l'email de l'application # Configuration de l'email de l'application
EMAIL_HOST = os.getenv('EMAIL_HOST', 'smtp.example.com') smtp_config_file = 'N3wtSchool/Configuration/application.json'
EMAIL_PORT = os.getenv('EMAIL_PORT', 587)
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '') if os.path.exists(smtp_config_file):
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '') try:
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' with open(smtp_config_file, 'r') as f:
EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'true').lower() == 'true' smtpSettings = json.load(f)
EMAIL_USE_SSL = os.getenv('EMAIL_USE_SSL', 'false').lower() == 'true' EMAIL_HOST = smtpSettings.get('hostSMTP', '')
EMAIL_PORT = smtpSettings.get('portSMTP', 587)
EMAIL_HOST_USER = smtpSettings.get('username', '')
EMAIL_HOST_PASSWORD = smtpSettings.get('password', '')
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_USE_TLS = smtpSettings.get('useTLS', True)
EMAIL_USE_SSL = smtpSettings.get('useSSL', False)
except Exception as e:
logger.error(f"Erreur lors de la lecture du fichier de configuration SMTP : {e}")
else:
logger.error(f"Fichier de configuration SMTP introuvable : {smtp_config_file}")
DOCUMENT_DIR = 'documents' DOCUMENT_DIR = 'documents'
# Configuration CORS temporaire pour debug CORS_ORIGIN_ALLOW_ALL = True
CORS_ALLOW_ALL_HEADERS = True
CORS_ALLOW_CREDENTIALS = True CORS_ALLOW_CREDENTIALS = True
# Configuration CORS spécifique pour la production
CORS_ALLOWED_ORIGINS = os.getenv('CORS_ALLOWED_ORIGINS', 'http://localhost:3000,http://localhost:8080,http://127.0.0.1:3000,http://127.0.0.1:8080').split(',')
CORS_ALLOW_HEADERS = [ CORS_ALLOW_HEADERS = [
'accept',
'accept-encoding',
'authorization',
'content-type', 'content-type',
'dnt', 'authorization',
'origin',
'user-agent',
'x-csrftoken',
'x-requested-with',
'X-Auth-Token', 'X-Auth-Token',
'x-csrftoken'
] ]
# Méthodes HTTP autorisées CORS_ALLOWED_ORIGINS = [
CORS_ALLOWED_METHODS = [ os.getenv('CORS_ALLOWED_ORIGINS', 'http://localhost:3000')
'DELETE',
'GET',
'OPTIONS',
'PATCH',
'POST',
'PUT',
] ]
CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost:3000,http://localhost:8080').split(',') CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost:3000,http://localhost:8080').split(',')
CSRF_COOKIE_HTTPONLY = False CSRF_COOKIE_HTTPONLY = False
CSRF_COOKIE_SECURE = os.getenv('CSRF_COOKIE_SECURE', 'false').lower() == 'true' CSRF_COOKIE_SECURE = False
CSRF_COOKIE_NAME = 'csrftoken' CSRF_COOKIE_NAME = 'csrftoken'
CSRF_COOKIE_DOMAIN = os.getenv('CSRF_COOKIE_DOMAIN', '')
USE_TZ = True USE_TZ = True
TZ_APPLI = 'Europe/Paris' TZ_APPLI = 'Europe/Paris'
DB_NAME = os.getenv('DB_NAME', 'school')
DB_USER = os.getenv('DB_USER', 'postgres')
DB_PASSWORD = os.getenv('DB_PASSWORD', 'postgres')
DB_HOST = os.getenv('DB_HOST', 'database')
DB_PORT = os.getenv('DB_PORT', '5432')
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.postgresql', 'ENGINE': 'django.db.backends.postgresql',
"NAME": DB_NAME, "NAME": "school",
"USER": DB_USER, "USER": "postgres",
"PASSWORD": DB_PASSWORD, "PASSWORD": "postgres",
"HOST": DB_HOST, "HOST": "database",
"PORT": DB_PORT, "PORT": "5432",
} }
} }
@ -314,7 +314,6 @@ REST_FRAMEWORK = {
'PAGE_SIZE': NB_RESULT_SUBSCRIPTIONS_PER_PAGE, 'PAGE_SIZE': NB_RESULT_SUBSCRIPTIONS_PER_PAGE,
'DEFAULT_AUTHENTICATION_CLASSES': ( 'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication', 'rest_framework_simplejwt.authentication.JWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
), ),
} }
@ -326,14 +325,14 @@ CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = 'Europe/Paris' CELERY_TIMEZONE = 'Europe/Paris'
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
URL_DJANGO = os.getenv('URL_DJANGO', 'http://localhost:8080/') URL_DJANGO = 'http://localhost:8080/'
REDIS_HOST = 'redis' REDIS_HOST = 'redis'
REDIS_PORT = 6379 REDIS_PORT = 6379
REDIS_DB = 0 REDIS_DB = 0
REDIS_PASSWORD = None REDIS_PASSWORD = None
SECRET_KEY = os.getenv('SECRET_KEY', 'QWQ8bYlCz1NpQ9G0vR5kxMnvWszfH2y3') SECRET_KEY = 'QWQ8bYlCz1NpQ9G0vR5kxMnvWszfH2y3'
SIMPLE_JWT = { SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15), 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
'REFRESH_TOKEN_LIFETIME': timedelta(days=1), 'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
@ -350,20 +349,10 @@ SIMPLE_JWT = {
} }
# Configuration for DocuSeal JWT # Configuration for DocuSeal JWT
DOCUSEAL_API_KEY="LRvUTQCbMSSpManYKshdQk9Do6rBQgjHyPrbGfxU3Jg"
DOCUSEAL_JWT = { DOCUSEAL_JWT = {
'ALGORITHM': 'HS256', 'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY, 'SIGNING_KEY': SECRET_KEY,
'EXPIRATION_DELTA': timedelta(hours=1) 'EXPIRATION_DELTA': timedelta(hours=1),
} 'API_KEY': DOCUSEAL_API_KEY
# Django Channels Configuration
ASGI_APPLICATION = 'N3wtSchool.asgi.application'
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
"hosts": [('redis', 6379)],
},
},
} }

View File

@ -14,8 +14,9 @@ def setup_periodic_tasks(sender, **kwargs):
# Déclarer la tâche périodique # Déclarer la tâche périodique
PeriodicTask.objects.get_or_create( PeriodicTask.objects.get_or_create(
interval=schedule, # Utiliser l'intervalle défini ci-dessus interval=schedule,
name='Tâche périodique toutes les 5 secondes', name='getMail',
task='Subscriptions.tasks.check_for_signature_deadlines', # Remplacer par le nom de ta tâche task='N3wtSchool.tasks.run_getmail',
kwargs=json.dumps({}) # Si nécessaire, ajoute kwargs=json.dumps({}),
defaults={"enabled": False}
) )

View File

@ -0,0 +1,33 @@
from celery import shared_task
from django_mailbox.models import Message, Mailbox
from django_celery_beat.models import PeriodicTask
import logging
logger = logging.getLogger("django_mailbox")
@shared_task
def run_getmail():
"""
Tâche périodique pour lancer l'import IMAP sur toutes les mailboxes actives
et traiter les nouveaux messages interceptés selon des critères.
"""
logger.info("Début import IMAP")
for mailbox in Mailbox.objects.filter(active=True):
messages = mailbox.get_new_mail()
try:
for message in messages:
logger.info(f"[IMAP] Tentative d'import : {message.subject} de {message.from_address}")
# Filtrage sur le sujet et l'émetteur
if (
message.subject == "n3wt"
and message.from_address == "anthony.casini.30@gmail.com"
):
logger.info(f"[IMAP] Message importé : {message.subject} de {message.from_address}")
else:
# Optionnel : supprimer le message importé qui ne correspond pas
message.delete()
except Exception as e:
logger.error(f"Erreur lors de l'import des messages pour la mailbox {mailbox}: {e}", exc_info=True)
logger.info("Fin import IMAP")

View File

@ -43,7 +43,6 @@ urlpatterns = [
path("Subscriptions/", include(("Subscriptions.urls", 'Subscriptions'), namespace='Subscriptions')), path("Subscriptions/", include(("Subscriptions.urls", 'Subscriptions'), namespace='Subscriptions')),
path("Auth/", include(("Auth.urls", 'Auth'), namespace='Auth')), path("Auth/", include(("Auth.urls", 'Auth'), namespace='Auth')),
path("GestionMessagerie/", include(("GestionMessagerie.urls", 'GestionMessagerie'), namespace='GestionMessagerie')), path("GestionMessagerie/", include(("GestionMessagerie.urls", 'GestionMessagerie'), namespace='GestionMessagerie')),
path("GestionEmail/", include(("GestionEmail.urls", 'GestionEmail'), namespace='GestionEmail')),
path("GestionNotification/", include(("GestionNotification.urls", 'GestionNotification'), namespace='GestionNotification')), path("GestionNotification/", include(("GestionNotification.urls", 'GestionNotification'), namespace='GestionNotification')),
path("School/", include(("School.urls", 'School'), namespace='School')), path("School/", include(("School.urls", 'School'), namespace='School')),
path("DocuSeal/", include(("DocuSeal.urls", 'DocuSeal'), namespace='DocuSeal')), path("DocuSeal/", include(("DocuSeal.urls", 'DocuSeal'), namespace='DocuSeal')),

View File

@ -1,44 +0,0 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('Establishment', '0001_initial'),
('School', '__first__'),
]
operations = [
migrations.CreateModel(
name='Planning',
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, default='', null=True)),
('color', models.CharField(default='#000000', max_length=255)),
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='Establishment.establishment')),
('school_class', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='planning', to='School.schoolclass')),
],
),
migrations.CreateModel(
name='Events',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('description', models.TextField(blank=True, default='', null=True)),
('start', models.DateTimeField()),
('end', models.DateTimeField()),
('recursionType', models.IntegerField(choices=[(0, 'Aucune'), (1, 'Quotidienne'), (2, 'Hebdomadaire'), (3, 'Mensuel'), (4, 'Personnalisé')], default=0)),
('recursionEnd', models.DateTimeField(blank=True, default=None, null=True)),
('color', models.CharField(max_length=255)),
('location', models.CharField(blank=True, default='', max_length=255, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('planning', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='Planning.planning')),
],
),
]

View File

@ -147,7 +147,7 @@ class EventsWithIdView(APIView):
return JsonResponse({'error': 'Event not found'}, status=404) return JsonResponse({'error': 'Event not found'}, status=404)
event.delete() event.delete()
return JsonResponse({'message': 'Event deleted'}, status=200) return JsonResponse({'message': 'Event deleted'}, status=204)
class UpcomingEventsView(APIView): class UpcomingEventsView(APIView):
def get(self, request): def get(self, request):

View File

@ -1,145 +0,0 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
import django.contrib.postgres.fields
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('Auth', '0001_initial'),
('Common', '0001_initial'),
('Establishment', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Competency',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.TextField()),
('end_of_cycle', models.BooleanField(blank=True, default=False, null=True)),
('level', models.CharField(blank=True, max_length=50, null=True)),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='competencies', to='Common.category')),
],
),
migrations.CreateModel(
name='Discount',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(blank=True, max_length=255, null=True)),
('amount', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
('description', models.TextField(blank=True)),
('discount_type', models.IntegerField(choices=[(0, 'Currency'), (1, 'Percent')], default=0)),
('type', models.IntegerField(choices=[(0, 'Registration Fee'), (1, 'Tuition Fee')], default=0)),
('updated_at', models.DateTimeField(auto_now=True)),
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='discounts', to='Establishment.establishment')),
],
),
migrations.CreateModel(
name='EstablishmentCompetency',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('custom_name', models.TextField(blank=True, help_text='Nom de la compétence custom', null=True)),
('is_required', models.BooleanField(default=True)),
('competency', models.ForeignKey(blank=True, help_text='Compétence de référence (optionnelle si custom)', null=True, on_delete=django.db.models.deletion.CASCADE, to='School.competency')),
('custom_category', models.ForeignKey(blank=True, help_text='Catégorie de la compétence custom', null=True, on_delete=django.db.models.deletion.CASCADE, to='Common.category')),
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='Establishment.establishment')),
],
options={
'unique_together': {('establishment', 'competency', 'custom_name', 'custom_category')},
},
),
migrations.AddField(
model_name='competency',
name='establishments',
field=models.ManyToManyField(blank=True, related_name='competencies', through='School.EstablishmentCompetency', to='Establishment.establishment'),
),
migrations.CreateModel(
name='Fee',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(blank=True, max_length=255, null=True)),
('base_amount', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
('description', models.TextField(blank=True)),
('is_active', models.BooleanField(default=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('type', models.IntegerField(choices=[(0, 'Registration Fee'), (1, 'Tuition Fee')], default=0)),
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fees', to='Establishment.establishment')),
],
),
migrations.CreateModel(
name='PaymentMode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('type', models.IntegerField(choices=[(0, 'Registration Fee'), (1, 'Tuition Fee')], default=0)),
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_modes', to='Establishment.establishment')),
('mode', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payment_modes', to='Common.paymentmodetype')),
],
),
migrations.CreateModel(
name='PaymentPlan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('due_dates', django.contrib.postgres.fields.ArrayField(base_field=models.DateField(), blank=True, null=True, size=None)),
('type', models.IntegerField(choices=[(0, 'Registration Fee'), (1, 'Tuition Fee')], default=0)),
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_plans', to='Establishment.establishment')),
('plan_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payment_plans', to='Common.paymentplantype')),
],
),
migrations.CreateModel(
name='SchoolClass',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('atmosphere_name', models.CharField(blank=True, max_length=255, null=True)),
('age_range', models.JSONField(blank=True, null=True)),
('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)),
('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)),
('opening_days', django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), default=list, size=None)),
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='school_classes', to='Establishment.establishment')),
('levels', models.ManyToManyField(blank=True, related_name='school_classes', to='Common.level')),
],
),
migrations.CreateModel(
name='Planning',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('level', models.IntegerField(blank=True, choices=[(1, 'Très Petite Section (TPS)'), (2, 'Petite Section (PS)'), (3, 'Moyenne Section (MS)'), (4, 'Grande Section (GS)'), (5, 'Cours Préparatoire (CP)'), (6, 'Cours Élémentaire 1 (CE1)'), (7, 'Cours Élémentaire 2 (CE2)'), (8, 'Cours Moyen 1 (CM1)'), (9, 'Cours Moyen 2 (CM2)')], null=True)),
('schedule', models.JSONField(default=dict)),
('school_class', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='plannings', to='School.schoolclass')),
],
),
migrations.CreateModel(
name='Speciality',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('updated_date', models.DateTimeField(auto_now=True)),
('color_code', models.CharField(default='#FFFFFF', max_length=7)),
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='specialities', to='Establishment.establishment')),
],
),
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)),
('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')),
],
),
migrations.AddField(
model_name='schoolclass',
name='teachers',
field=models.ManyToManyField(blank=True, to='School.teacher'),
),
]

View File

@ -33,10 +33,6 @@ from N3wtSchool.bdd import delete_object, getAllObjects, getObject
from django.db.models import Q from django.db.models import Q
from collections import defaultdict from collections import defaultdict
from Subscriptions.models import Student, StudentCompetency from Subscriptions.models import Student, StudentCompetency
from Subscriptions.util import getCurrentSchoolYear
import logging
logger = logging.getLogger(__name__)
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
@ -585,6 +581,7 @@ class EstablishmentCompetencyListCreateView(APIView):
try: try:
category = Category.objects.get(id=category_id) category = Category.objects.get(id=category_id)
# Vérifier si une compétence custom du même nom existe déjà pour cet établissement et cette catégorie
ec_exists = EstablishmentCompetency.objects.filter( ec_exists = EstablishmentCompetency.objects.filter(
establishment_id=establishment_id, establishment_id=establishment_id,
competency__isnull=True, competency__isnull=True,
@ -601,31 +598,12 @@ class EstablishmentCompetencyListCreateView(APIView):
custom_category=category, custom_category=category,
is_required=False is_required=False
) )
# Associer à tous les élèves de l'établissement
# Récupérer l'établissement et sa fréquence d'évaluation
establishment = ec.establishment
evaluation_frequency = establishment.evaluation_frequency # 1=Trimestre, 2=Semestre, 3=Année
# Déterminer l'année scolaire courante
school_year = getCurrentSchoolYear()
# Générer les périodes selon la fréquence
periods = []
if evaluation_frequency == 1: # Trimestre
periods = [f"T{i+1}_{school_year}" for i in range(3)]
elif evaluation_frequency == 2: # Semestre
periods = [f"S{i+1}_{school_year}" for i in range(2)]
elif evaluation_frequency == 3: # Année
periods = [f"A_{school_year}"]
# Associer à tous les élèves de l'établissement pour chaque période
students = Student.objects.filter(associated_class__establishment_id=establishment_id) students = Student.objects.filter(associated_class__establishment_id=establishment_id)
for student in students: for student in students:
for period in periods:
StudentCompetency.objects.get_or_create( StudentCompetency.objects.get_or_create(
student=student, student=student,
establishment_competency=ec, establishment_competency=ec
period=period
) )
created.append({ created.append({

View File

@ -1,29 +0,0 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('Establishment', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='SMTPSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('smtp_server', models.CharField(max_length=255)),
('smtp_port', models.PositiveIntegerField()),
('smtp_user', models.CharField(max_length=255)),
('smtp_password', models.CharField(max_length=255)),
('use_tls', models.BooleanField(default=True)),
('use_ssl', models.BooleanField(default=False)),
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='Establishment.establishment')),
],
),
]

View File

@ -4,14 +4,22 @@ from django.utils.translation import gettext_lazy as _
from django.conf import settings from django.conf import settings
from Establishment.models import Establishment from Establishment.models import Establishment
class SMTPSettings(models.Model): class MailSettings(models.Model):
establishment = models.ForeignKey(Establishment, on_delete=models.CASCADE) establishment = models.ForeignKey(Establishment, on_delete=models.CASCADE)
# Paramètres communs (si tu veux un seul user/pass pour SMTP et IMAP)
mail_user = models.CharField(max_length=255)
mail_password = models.CharField(max_length=255)
# SMTP
smtp_server = models.CharField(max_length=255) smtp_server = models.CharField(max_length=255)
smtp_port = models.PositiveIntegerField() smtp_port = models.PositiveIntegerField()
smtp_user = models.CharField(max_length=255)
smtp_password = models.CharField(max_length=255)
use_tls = models.BooleanField(default=True) use_tls = models.BooleanField(default=True)
use_ssl = models.BooleanField(default=False) use_ssl = models.BooleanField(default=False)
# IMAP
imap_server = models.CharField(max_length=255)
imap_port = models.PositiveIntegerField(default=993)
def __str__(self): def __str__(self):
return f"SMTP Settings ({self.smtp_server}:{self.smtp_port})" return f"MailSettings ({self.establishment} - {self.mail_user})"

View File

@ -1,7 +1,7 @@
from rest_framework import serializers from rest_framework import serializers
from .models import SMTPSettings from .models import MailSettings
class SMTPSettingsSerializer(serializers.ModelSerializer): class MailSettingsSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = SMTPSettings model = MailSettings
fields = '__all__' fields = '__all__'

View File

@ -1,6 +1,7 @@
from django.urls import path from django.urls import path
from .views import SMTPSettingsView from .views import MailSettingsView, SyncImapView
urlpatterns = [ urlpatterns = [
path('smtp-settings/', SMTPSettingsView.as_view(), name='smtp_settings'), path('mail-settings/', MailSettingsView.as_view(), name='smtp_settings'),
path('sync-imap/', SyncImapView.as_view(), name='sync-imap'),
] ]

View File

@ -1,12 +1,16 @@
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi from drf_yasg import openapi
from .models import SMTPSettings from .models import MailSettings
from .serializers import SMTPSettingsSerializer from .serializers import MailSettingsSerializer
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from django_mailbox.models import Mailbox
from Auth.models import Profile
from django_celery_beat.models import PeriodicTask
import urllib.parse
class SMTPSettingsView(APIView): class MailSettingsView(APIView):
""" """
API pour gérer les paramètres SMTP. API pour gérer les paramètres SMTP.
""" """
@ -23,7 +27,7 @@ class SMTPSettingsView(APIView):
) )
], ],
responses={ responses={
200: SMTPSettingsSerializer(many=True), 200: MailSettingsSerializer(many=True),
404: openapi.Response(description="Aucun paramètre SMTP trouvé."), 404: openapi.Response(description="Aucun paramètre SMTP trouvé."),
500: openapi.Response(description="Erreur interne du serveur."), 500: openapi.Response(description="Erreur interne du serveur."),
}, },
@ -34,32 +38,48 @@ class SMTPSettingsView(APIView):
try: try:
if establishment_id: if establishment_id:
# Récupérer les paramètres SMTP pour un établissement spécifique # Récupérer les paramètres SMTP pour un établissement spécifique
smtp_settings = SMTPSettings.objects.filter(establishment_id=establishment_id).first() mail_settings = MailSettings.objects.filter(establishment_id=establishment_id).first()
if not smtp_settings: if not mail_settings:
return Response( return Response(
{'error': f"Aucun paramètre SMTP trouvé pour l'établissement {establishment_id}."}, {'error': f"Aucun paramètre SMTP trouvé pour l'établissement {establishment_id}."},
status=status.HTTP_404_NOT_FOUND status=status.HTTP_404_NOT_FOUND
) )
serializer = SMTPSettingsSerializer(smtp_settings) serializer = MailSettingsSerializer(mail_settings)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
else: else:
# Récupérer tous les paramètres SMTP # Récupérer tous les paramètres SMTP
smtp_settings = SMTPSettings.objects.all() mail_settings = MailSettings.objects.all()
if not smtp_settings.exists(): if not mail_settings.exists():
return Response( return Response(
{'error': "Aucun paramètre SMTP trouvé."}, {'error': "Aucun paramètre SMTP trouvé."},
status=status.HTTP_404_NOT_FOUND status=status.HTTP_404_NOT_FOUND
) )
serializer = SMTPSettingsSerializer(smtp_settings, many=True) serializer = MailSettingsSerializer(mail_settings, many=True)
# ...dans une vue ou un serializer...
from N3wtSchool.mailManager import test_mailbox_uri
import urllib.parse
imap_user = "anthony.audrey.34@gmail.com"
imap_password = "cztn wyme odjt lbjt"
imap_server = "imap.gmail.com"
imap_port = 993
encoded_user = urllib.parse.quote(imap_user)
encoded_password = urllib.parse.quote(imap_password)
uri = f"imap+ssl://{encoded_user}:{encoded_password}@{imap_server}:{imap_port}"
if not test_mailbox_uri(uri):
print(f'uri : {uri}')
return Response({'error': "Connexion IMAP impossible. Vérifiez les paramètres."}, status=status.HTTP_400_BAD_REQUEST)
# Ensuite, tu peux créer la Mailbox si la connexion est OK
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e: except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Créer ou mettre à jour les paramètres SMTP pour un établissement spécifique", operation_description="Créer ou mettre à jour les paramètres SMTP pour un établissement spécifique",
request_body=SMTPSettingsSerializer, request_body=MailSettingsSerializer,
responses={ responses={
200: SMTPSettingsSerializer(), 200: MailSettingsSerializer(),
400: openapi.Response(description="Données invalides."), 400: openapi.Response(description="Données invalides."),
500: openapi.Response(description="Erreur interne du serveur."), 500: openapi.Response(description="Erreur interne du serveur."),
}, },
@ -67,15 +87,58 @@ class SMTPSettingsView(APIView):
def post(self, request): def post(self, request):
data = request.data data = request.data
try: try:
smtp_settings = SMTPSettings.objects.first() mail_settings = MailSettings.objects.first()
if smtp_settings: if mail_settings:
serializer = SMTPSettingsSerializer(smtp_settings, data=data) serializer = MailSettingsSerializer(mail_settings, data=data)
else: else:
serializer = SMTPSettingsSerializer(data=data) serializer = MailSettingsSerializer(data=data)
if serializer.is_valid(): if serializer.is_valid():
serializer.save() mail_settings_instance = serializer.save()
# Création de la mailbox pour le profil si profil_id fourni
profile_id = data.get('profile_id')
if profile_id:
try:
profile = Profile.objects.get(id=profile_id)
email = mail_settings_instance.mail_user
imap_server = mail_settings_instance.imap_server
imap_port = mail_settings_instance.imap_port
imap_user = mail_settings_instance.mail_user
imap_password = mail_settings_instance.mail_password
# Encodage du username et du mot de passe pour l'URI IMAP
encoded_user = urllib.parse.quote(imap_user)
encoded_password = urllib.parse.quote(imap_password)
uri = f"imap+ssl://{encoded_user}:{encoded_password}@{imap_server}:{imap_port}"
mailbox, created = Mailbox.objects.get_or_create(
name=email,
defaults={
"uri": uri,
"from_email": email,
"active": True,
}
)
# Associer la mailbox au profil si champ prévu
if hasattr(profile, "mailbox"):
profile.mailbox = mailbox
profile.save()
except Profile.DoesNotExist:
pass # Profil non trouvé, on ignore
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except Exception as e: except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class SyncImapView(APIView):
def post(self, request):
sync_imap = request.data.get("sync_imap", False)
try:
task = PeriodicTask.objects.get(name='getMail')
task.enabled = bool(sync_imap)
task.save()
return Response({"success": True, "enabled": task.enabled})
except PeriodicTask.DoesNotExist:
return Response({"error": "Tâche non trouvée."}, status=status.HTTP_404_NOT_FOUND)

View File

@ -1,213 +0,0 @@
# Generated by Django 5.1.3 on 2025-05-28 11:14
import Subscriptions.models
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('Auth', '__first__'),
('Common', '0001_initial'),
('Establishment', '0001_initial'),
('School', '__first__'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Language',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('label', models.CharField(default='', max_length=200)),
],
),
migrations.CreateModel(
name='Student',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('photo', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_photo_upload_to)),
('last_name', models.CharField(default='', max_length=200)),
('first_name', models.CharField(default='', max_length=200)),
('gender', models.IntegerField(blank=True, choices=[(0, 'Sélection du genre'), (1, 'Garçon'), (2, 'Fille')], default=0)),
('nationality', models.CharField(blank=True, default='', max_length=200)),
('address', models.CharField(blank=True, default='', max_length=200)),
('birth_date', models.DateField(blank=True, null=True)),
('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)),
('associated_class', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='students', to='School.schoolclass')),
],
),
migrations.CreateModel(
name='RegistrationSchoolFileTemplate',
fields=[
('id', models.IntegerField(primary_key=True, serialize=False)),
('slug', models.CharField(default='', max_length=255)),
('name', models.CharField(default='', max_length=255)),
('file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_school_file_upload_to)),
],
),
migrations.CreateModel(
name='Sibling',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('last_name', models.CharField(blank=True, max_length=200, null=True)),
('first_name', models.CharField(blank=True, max_length=200, null=True)),
('birth_date', models.DateField(blank=True, null=True)),
],
),
migrations.CreateModel(
name='Guardian',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('last_name', models.CharField(blank=True, max_length=200, null=True)),
('first_name', models.CharField(blank=True, max_length=200, null=True)),
('birth_date', models.DateField(blank=True, null=True)),
('address', models.CharField(blank=True, default='', max_length=200)),
('phone', models.CharField(blank=True, default='', max_length=200)),
('profession', models.CharField(blank=True, default='', max_length=200)),
('profile_role', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='guardian_profile', to='Auth.profilerole')),
],
),
migrations.CreateModel(
name='RegistrationFileGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='', max_length=255)),
('description', models.TextField(blank=True, null=True)),
('establishment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='file_group', to='Establishment.establishment')),
],
),
migrations.CreateModel(
name='RegistrationForm',
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)),
('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)),
('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')),
('fees', models.ManyToManyField(blank=True, related_name='register_forms', to='School.fee')),
('fileGroup', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='register_forms', to='Subscriptions.registrationfilegroup')),
('registration_payment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='registration_payment_modes_forms', to='School.paymentmode')),
('registration_payment_plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='registration_payment_plans_forms', to='School.paymentplan')),
('tuition_payment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tuition_payment_modes_forms', to='School.paymentmode')),
('tuition_payment_plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tuition_payment_plans_forms', to='School.paymentplan')),
],
),
migrations.AddField(
model_name='student',
name='guardians',
field=models.ManyToManyField(blank=True, to='Subscriptions.guardian'),
),
migrations.AddField(
model_name='student',
name='level',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='students', to='Common.level'),
),
migrations.AddField(
model_name='student',
name='profiles',
field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='student',
name='spoken_languages',
field=models.ManyToManyField(blank=True, to='Subscriptions.language'),
),
migrations.CreateModel(
name='BilanCompetence',
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_bilan_form_upload_to)),
('period', models.CharField(help_text='Période ex: T1-2024_2025, S1-2024_2025, A-2024_2025', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bilans', to='Subscriptions.student')),
],
),
migrations.CreateModel(
name='AbsenceManagement',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('day', models.DateField(blank=True, null=True)),
('moment', models.IntegerField(choices=[(1, 'Morning'), (2, 'Afternoon'), (3, 'Total')], default=3)),
('reason', models.IntegerField(choices=[(1, 'Justified Absence'), (2, 'Unjustified Absence'), (3, 'Justified Late'), (4, 'Unjustified Late')], default=2)),
('commentaire', models.TextField(blank=True, null=True)),
('establishment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='absences', to='Establishment.establishment')),
('student', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='absences', to='Subscriptions.student')),
],
),
migrations.CreateModel(
name='RegistrationParentFileMaster',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='', max_length=255)),
('description', models.CharField(blank=True, null=True)),
('is_required', models.BooleanField(default=False)),
('groups', models.ManyToManyField(blank=True, related_name='parent_file_masters', to='Subscriptions.registrationfilegroup')),
],
),
migrations.CreateModel(
name='RegistrationSchoolFileMaster',
fields=[
('id', models.IntegerField(primary_key=True, serialize=False)),
('name', models.CharField(default='', max_length=255)),
('is_required', models.BooleanField(default=False)),
('groups', models.ManyToManyField(blank=True, related_name='school_file_masters', to='Subscriptions.registrationfilegroup')),
],
),
migrations.AddField(
model_name='student',
name='registration_files',
field=models.ManyToManyField(blank=True, related_name='students', to='Subscriptions.registrationschoolfiletemplate'),
),
migrations.AddField(
model_name='registrationschoolfiletemplate',
name='master',
field=models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='school_file_templates', to='Subscriptions.registrationschoolfilemaster'),
),
migrations.AddField(
model_name='student',
name='siblings',
field=models.ManyToManyField(blank=True, to='Subscriptions.sibling'),
),
migrations.AddField(
model_name='registrationschoolfiletemplate',
name='registration_form',
field=models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='school_file_templates', to='Subscriptions.registrationform'),
),
migrations.CreateModel(
name='RegistrationParentFileTemplate',
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)),
('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')),
],
),
migrations.CreateModel(
name='StudentCompetency',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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)),
('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')),
],
options={
'unique_together': {('student', 'establishment_competency', 'period')},
},
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.1.3 on 2025-05-30 07:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('Subscriptions', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='registrationparentfilemaster',
name='description',
field=models.CharField(blank=True, max_length=500, null=True),
),
]

View File

@ -6,9 +6,6 @@ from django.utils.translation import gettext_lazy as _
from datetime import datetime from datetime import datetime
import os import os
import logging
logger = logging.getLogger("SubscriptionModels")
class Language(models.Model): class Language(models.Model):
""" """
@ -308,7 +305,7 @@ class RegistrationSchoolFileMaster(models.Model):
class RegistrationParentFileMaster(models.Model): class RegistrationParentFileMaster(models.Model):
groups = models.ManyToManyField(RegistrationFileGroup, related_name='parent_file_masters', blank=True) groups = models.ManyToManyField(RegistrationFileGroup, related_name='parent_file_masters', blank=True)
name = models.CharField(max_length=255, default="") name = models.CharField(max_length=255, default="")
description = models.CharField(max_length=500, blank=True, null=True) description = models.CharField(blank=True, null=True)
is_required = models.BooleanField(default=False) is_required = models.BooleanField(default=False)
############################################################ ############################################################
@ -397,7 +394,6 @@ class RegistrationParentFileTemplate(models.Model):
registration_files = RegistrationParentFileTemplate.objects.filter(registration_form=register_form_id) registration_files = RegistrationParentFileTemplate.objects.filter(registration_form=register_form_id)
filenames = [] filenames = []
for reg_file in registration_files: for reg_file in registration_files:
if reg_file.file and hasattr(reg_file.file, 'path'):
filenames.append(reg_file.file.path) filenames.append(reg_file.file.path)
return filenames return filenames

View File

@ -381,20 +381,16 @@ class RegistrationFormSerializer(serializers.ModelSerializer):
class StudentByParentSerializer(serializers.ModelSerializer): class StudentByParentSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False) id = serializers.IntegerField(required=False)
associated_class_name = serializers.SerializerMethodField()
class Meta: class Meta:
model = Student model = Student
fields = ['id', 'last_name', 'first_name', 'level', 'photo', 'associated_class_name'] fields = ['id', 'last_name', 'first_name', 'level', 'photo']
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(StudentByParentSerializer, self).__init__(*args, **kwargs) super(StudentByParentSerializer, self).__init__(*args, **kwargs)
for field in self.fields: for field in self.fields:
self.fields[field].required = False self.fields[field].required = False
def get_associated_class_name(self, obj):
return obj.associated_class.atmosphere_name if obj.associated_class else None
class RegistrationFormByParentSerializer(serializers.ModelSerializer): class RegistrationFormByParentSerializer(serializers.ModelSerializer):
student = StudentByParentSerializer(many=False, required=True) student = StudentByParentSerializer(many=False, required=True)

View File

@ -33,7 +33,6 @@
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<img src="{{URL_DJANGO}}static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
<h1>Confirmation d'inscription</h1> <h1>Confirmation d'inscription</h1>
</div> </div>
<div class="content"> <div class="content">

View File

@ -1,59 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Confirmation de souscription</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;
}
.logo {
width: 120px;
margin-bottom: 10px;
}
.content {
padding: 20px;
}
.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>Confirmation de souscription</h1>
</div>
<div class="content">
<p>Bonjour,</p>
<p>Nous sommes ravis de vous compter parmi les utilisateurs de N3wt School et vous remercions pour votre confiance</p>
<p>Vous trouverez ci-joint le lien vers la page d'authentification : <a href="{{BASE_URL}}/users/login">{{BASE_URL}}/users/login</a></p>
<p>S'il s'agit de votre première connexion, veuillez procéder à l'activation de votre compte à cette url : <a href="{{BASE_URL}}/users/subscribe?establishment_id={{establishment}}">{{BASE_URL}}/users/subscribe</a></p>
<p>votre identifiant est : {{ email }}</p>
<p>Notre équipe est à votre disposition pour vous aider à tirer pleinement parti des fonctionnalités offertes par Newt School.</p>
<p>N'hésitez pas à nous contacter pour toute question ou besoin d'assistance.</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>

View File

@ -1,6 +1,6 @@
<!DOCTYPE html> <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Fiche élève de {{ student.last_name }} {{ student.first_name }}</title> <title>Fiche élève de {{ student.last_name }} {{ student.first_name }}</title>
<style> <style>
@ -11,25 +11,28 @@
body { body {
font-family: 'Arial', sans-serif; font-family: 'Arial', sans-serif;
font-size: 12pt; font-size: 12pt;
color: #222; line-height: 1.6;
background: #fff;
margin: 0; margin: 0;
padding: 0; padding: 0;
color: #333;
background-color: #f9f9f9;
} }
.container { .container {
width: 100%; width: 100%;
padding: 0; padding: 20px;
background: #fff; background-color: #fff;
border-radius: 10px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
} }
.header { .header {
text-align: center; text-align: center;
margin-bottom: 24px; margin-bottom: 30px;
border-bottom: 2px solid #4CAF50; border-bottom: 3px solid #4CAF50;
padding-bottom: 12px; padding-bottom: 15px;
position: relative; position: relative;
} }
.title { .title {
font-size: 22pt; font-size: 20pt;
font-weight: bold; font-weight: bold;
color: #4CAF50; color: #4CAF50;
margin: 0; margin: 0;
@ -38,47 +41,51 @@
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
width: 90px; width: 100px;
height: 90px; height: 100px;
object-fit: cover; object-fit: cover;
border: 1px solid #4CAF50; border: 2px solid #4CAF50;
border-radius: 8px; border-radius: 10px;
} }
.section { .section {
margin-bottom: 32px; /* Espacement augmenté entre les sections */ margin-bottom: 20px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #fefefe;
} }
.section-title { .section-title {
font-size: 15pt; font-size: 18pt;
font-weight: bold; font-weight: bold;
color: #4CAF50; color: #4CAF50;
margin-bottom: 18px; /* Espacement sous le titre de section */ margin-bottom: 10px;
border-bottom: 1px solid #4CAF50; text-align: left;
padding-bottom: 2px; border-bottom: 2px solid #4CAF50;
padding-bottom: 5px;
} }
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin-bottom: 8px; margin-top: 10px;
} }
th, td { td {
border: 1px solid #bbb; padding: 8px;
padding: 6px 8px; vertical-align: top;
text-align: left;
}
th {
background: #f3f3f3;
font-weight: bold;
}
tr:nth-child(even) {
background: #fafafa;
} }
.label-cell { .label-cell {
font-weight: bold; font-weight: bold;
width: 30%; color: #555;
background: #f3f3f3; width: 35%;
text-align: right;
padding-right: 10px;
} }
.value-cell { .value-cell {
width: 70%; color: #333;
width: 65%;
text-align: left;
}
.phone {
white-space: nowrap;
} }
.signature { .signature {
margin-top: 30px; margin-top: 30px;
@ -90,139 +97,137 @@
font-weight: bold; font-weight: bold;
color: #333; color: #333;
} }
.subsection-title {
font-size: 12pt;
color: #333;
margin: 8px 0 4px 0;
font-weight: bold;
}
</style> </style>
</head> </head>
<body> <body>
{% load myTemplateTag %} {% load myTemplateTag %}
<div class="container"> <div class="container">
<!-- Header Section --> <!-- Header Section -->
<div class="header"> <div class="header">
<h1 class="title">Fiche élève de {{ student.last_name }} {{ student.first_name }}</h1> <h1 class="title">Fiche élève de {{ student.last_name }} {{ student.first_name }}</h1>
{% if student.photo %} {% if student.photo %}
<img src="{{ student.get_photo_url }}" alt="Photo de l'élève" class="photo" /> <img
src="{{ student.get_photo_url }}"
alt="Photo de l'élève"
class="photo"
/>
{% else %} {% else %}
<img src="/static/img/default-photo.jpg" alt="Photo par défaut" class="photo" /> <img
src="/static/img/default-photo.jpg"
alt="Photo par défaut"
class="photo"
/>
{% endif %} {% endif %}
</div> </div>
<!-- Élève --> <!-- Student Section -->
<div class="section"> <div class="section">
<div class="section-title">ÉLÈVE</div> <h2 class="section-title">ÉLÈVE</h2>
<table> <table>
<tr> <tr>
<td class="label-cell">Nom</td> <td class="label-cell">Nom :</td>
<td class="value-cell">{{ student.last_name }}</td> <td class="value-cell">{{ student.last_name }}</td>
<td class="label-cell">Prénom</td> <td class="label-cell">Prénom :</td>
<td class="value-cell">{{ student.first_name }}</td> <td class="value-cell">{{ student.first_name }}</td>
</tr> </tr>
<tr> <tr>
<td class="label-cell">Adresse</td> <td class="label-cell">Adresse :</td>
<td class="value-cell" colspan="3">{{ student.address }}</td> <td colspan="3" class="value-cell">{{ student.address }}</td>
</tr> </tr>
<tr> <tr>
<td class="label-cell">Genre</td> <td class="label-cell">Genre :</td>
<td class="value-cell">{{ student|getStudentGender }}</td> <td class="value-cell">{{ student|getStudentGender }}</td>
<td class="label-cell">Né(e) le</td> <td class="label-cell">Né(e) le :</td>
<td class="value-cell">{{ student.birth_date }}</td> <td class="value-cell">{{ student.birth_date }}</td>
</tr> </tr>
<tr> <tr>
<td class="label-cell">À</td> <td class="label-cell">À :</td>
<td class="value-cell">{{ student.birth_place }} ({{ student.birth_postal_code }})</td> <td colspan="3" class="value-cell">{{ student.birth_place }} ({{ student.birth_postal_code }})</td>
<td class="label-cell">Nationalité</td>
<td class="value-cell">{{ student.nationality }}</td>
</tr> </tr>
<tr> <tr>
<td class="label-cell">Niveau</td> <td class="label-cell">Nationalité :</td>
<td class="value-cell">{{ student.nationality }}</td>
<td class="label-cell">Niveau :</td>
<td class="value-cell">{{ student|getStudentLevel }}</td> <td class="value-cell">{{ student|getStudentLevel }}</td>
<td class="label-cell"></td>
<td class="value-cell"></td>
</tr> </tr>
</table> </table>
</div> </div>
<!-- Responsables --> <!-- Guardians Section -->
<div class="section"> <div class="section">
<div class="section-title">RESPONSABLES</div> <h2 class="section-title">RESPONSABLES</h2>
{% for guardian in student.getGuardians %} {% for guardian in student.getGuardians %}
<div> <div class="subsection">
<div class="subsection-title">Responsable {{ forloop.counter }}</div> <h3 class="subsection-title">Responsable {{ forloop.counter }}</h3>
<table> <table>
<tr> <tr>
<td class="label-cell">Nom</td> <td class="label-cell">Nom :</td>
<td class="value-cell">{{ guardian.last_name }}</td> <td class="value-cell">{{ guardian.last_name }}</td>
<td class="label-cell">Prénom</td> <td class="label-cell">Prénom :</td>
<td class="value-cell">{{ guardian.first_name }}</td> <td class="value-cell">{{ guardian.first_name }}</td>
</tr> </tr>
<tr> <tr>
<td class="label-cell">Adresse</td> <td class="label-cell">Adresse :</td>
<td class="value-cell" colspan="3">{{ guardian.address }}</td> <td colspan="3" class="value-cell">{{ guardian.address }}</td>
</tr> </tr>
<tr> <tr>
<td class="label-cell">Email</td> <td class="label-cell">Né(e) le :</td>
<td class="value-cell" 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="value-cell">{{ guardian.birth_date }}</td>
<td class="label-cell">Téléphone</td> <td class="label-cell">Email :</td>
<td class="value-cell">{{ guardian.phone|phone_format }}</td> <td class="value-cell">{{ guardian.email }}</td>
</tr> </tr>
<tr> <tr>
<td class="label-cell">Profession</td> <td class="label-cell">Téléphone :</td>
<td class="value-cell" colspan="3">{{ guardian.profession }}</td> <td class="value-cell phone">{{ guardian.phone|phone_format }}</td>
<td class="label-cell">Profession :</td>
<td class="value-cell">{{ guardian.profession }}</td>
</tr> </tr>
</table> </table>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<!-- Fratrie --> <!-- Siblings Section -->
<div class="section"> <div class="section">
<div class="section-title">FRATRIE</div> <h2 class="section-title">FRATRIE</h2>
{% for sibling in student.getSiblings %} {% for sibling in student.getSiblings %}
<div> <div class="subsection">
<div class="subsection-title">Frère/Soeur {{ forloop.counter }}</div> <h3 class="subsection-title">Frère/Soeur {{ forloop.counter }}</h3>
<table> <table>
<tr> <tr>
<td class="label-cell">Nom</td> <td class="label-cell">Nom :</td>
<td class="value-cell">{{ sibling.last_name }}</td> <td class="value-cell">{{ sibling.last_name }}</td>
<td class="label-cell">Prénom</td> <td class="label-cell">Prénom :</td>
<td class="value-cell">{{ sibling.first_name }}</td> <td class="value-cell">{{ sibling.first_name }}</td>
</tr> </tr>
<tr> <tr>
<td class="label-cell">Né(e) le</td> <td class="label-cell">Né(e) le :</td>
<td class="value-cell" colspan="3">{{ sibling.birth_date }}</td> <td colspan="3" class="value-cell">{{ sibling.birth_date }}</td>
</tr> </tr>
</table> </table>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<!-- Paiement --> <!-- Payment Section -->
<div class="section"> <div class="section">
<div class="section-title">MODALITÉS DE PAIEMENT</div> <h2 class="section-title">MODALITÉS DE PAIEMENT</h2>
<table> <table>
<tr> <tr>
<td class="label-cell">Frais d'inscription</td> <td class="label-cell">Frais d'inscription :</td>
<td class="value-cell">{{ student|getRegistrationPaymentMethod }} en {{ student|getRegistrationPaymentPlan }}</td> <td class="value-cell">{{ student|getRegistrationPaymentMethod }} en {{ student|getRegistrationPaymentPlan }}</td>
</tr> </tr>
<tr> <tr>
<td class="label-cell">Frais de scolarité</td> <td class="label-cell">Frais de scolarité :</td>
<td class="value-cell">{{ student|getTuitionPaymentMethod }} en {{ student|getTuitionPaymentPlan }}</td> <td class="value-cell">{{ student|getTuitionPaymentMethod }} en {{ student|getTuitionPaymentPlan }}</td>
</tr> </tr>
</table> </table>
</div> </div>
<!-- Signature --> <!-- Signature Section -->
<div class="signature"> <div class="signature">
Fait le <span class="signature-text">{{ signatureDate }}</span> à <span class="signature-text">{{ signatureTime }}</span> Fait le <span class="signature-text">{{ signatureDate }}</span> à <span class="signature-text">{{ signatureTime }}</span>
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@ -88,7 +88,6 @@ class RegisterFormView(APIView):
filter = request.GET.get('filter', '').strip() filter = request.GET.get('filter', '').strip()
page_size = request.GET.get('page_size', None) page_size = request.GET.get('page_size', None)
establishment_id = request.GET.get('establishment_id', None) establishment_id = request.GET.get('establishment_id', None)
search = request.GET.get('search', '').strip() # <-- Ajout du paramètre search
# Gestion du page_size # Gestion du page_size
if page_size is not None: if page_size is not None:
@ -114,14 +113,7 @@ class RegisterFormView(APIView):
registerForms_List = None registerForms_List = None
if registerForms_List: if registerForms_List:
registerForms_List = registerForms_List.filter(establishment=establishment_id) registerForms_List = registerForms_List.filter(establishment=establishment_id).order_by('-last_update')
# Ajout du filtre search sur le nom et prénom de l'élève
if search:
registerForms_List = registerForms_List.filter(
Q(student__first_name__icontains=search) |
Q(student__last_name__icontains=search)
)
registerForms_List = registerForms_List.order_by('-last_update')
if not registerForms_List: if not registerForms_List:
return JsonResponse({'error': 'aucune donnée trouvée', 'count': 0}, safe=False) return JsonResponse({'error': 'aucune donnée trouvée', 'count': 0}, safe=False)

View File

@ -12,27 +12,11 @@ from N3wtSchool import bdd
class RegistrationSchoolFileMasterView(APIView): class RegistrationSchoolFileMasterView(APIView):
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Récupère tous les masters de templates d'inscription pour un établissement donné", operation_description="Récupère tous les masters de templates d'inscription",
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)} responses={200: RegistrationSchoolFileMasterSerializer(many=True)}
) )
def get(self, request): def get(self, request):
establishment_id = request.GET.get('establishment_id') masters = RegistrationSchoolFileMaster.objects.all()
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) serializer = RegistrationSchoolFileMasterSerializer(masters, many=True)
return Response(serializer.data) return Response(serializer.data)
@ -102,27 +86,11 @@ class RegistrationSchoolFileMasterSimpleView(APIView):
class RegistrationSchoolFileTemplateView(APIView): class RegistrationSchoolFileTemplateView(APIView):
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Récupère tous les templates d'inscription pour un établissement donné", operation_description="Récupère tous les templates d'inscription",
manual_parameters=[
openapi.Parameter(
'establishment_id',
openapi.IN_QUERY,
description="ID de l'établissement",
type=openapi.TYPE_INTEGER,
required=True
)
],
responses={200: RegistrationSchoolFileTemplateSerializer(many=True)} responses={200: RegistrationSchoolFileTemplateSerializer(many=True)}
) )
def get(self, request): def get(self, request):
establishment_id = request.GET.get('establishment_id') templates = RegistrationSchoolFileTemplate.objects.all()
if not establishment_id:
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
# Filtrer les templates liés à l'établissement via master.groups.establishment
templates = RegistrationSchoolFileTemplate.objects.filter(
master__groups__establishment__id=establishment_id
).distinct()
serializer = RegistrationSchoolFileTemplateSerializer(templates, many=True) serializer = RegistrationSchoolFileTemplateSerializer(templates, many=True)
return Response(serializer.data) return Response(serializer.data)
@ -192,27 +160,11 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
class RegistrationParentFileMasterView(APIView): class RegistrationParentFileMasterView(APIView):
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Récupère tous les fichiers parents pour un établissement donné", operation_description="Récupère tous les fichiers parents",
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)} responses={200: RegistrationParentFileMasterSerializer(many=True)}
) )
def get(self, request): def get(self, request):
establishment_id = request.GET.get('establishment_id') templates = RegistrationParentFileMaster.objects.all()
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) serializer = RegistrationParentFileMasterSerializer(templates, many=True)
return Response(serializer.data) return Response(serializer.data)
@ -282,27 +234,11 @@ class RegistrationParentFileMasterSimpleView(APIView):
class RegistrationParentFileTemplateView(APIView): class RegistrationParentFileTemplateView(APIView):
@swagger_auto_schema( @swagger_auto_schema(
operation_description="Récupère tous les templates parents pour un établissement donné", operation_description="Récupère tous les templates d'inscription",
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)} responses={200: RegistrationParentFileTemplateSerializer(many=True)}
) )
def get(self, request): def get(self, request):
establishment_id = request.GET.get('establishment_id') templates = RegistrationParentFileTemplate.objects.all()
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) serializer = RegistrationParentFileTemplateSerializer(templates, many=True)
return Response(serializer.data) return Response(serializer.data)

View File

@ -4,9 +4,8 @@ from rest_framework import status
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from Subscriptions.models import StudentCompetency, Student from Subscriptions.models import StudentCompetency, Student, BilanCompetence
from Common.models import Domain from Common.models import Domain
from Subscriptions.models import BilanCompetence
from datetime import date from datetime import date
from N3wtSchool.renderers import render_to_pdf from N3wtSchool.renderers import render_to_pdf
from django.core.files import File from django.core.files import File

View File

@ -1 +1 @@
__version__ = "0.0.2" __version__ = "0.0.1"

View File

@ -8,7 +8,6 @@ APPS = [
"Planning", "Planning",
"GestionNotification", "GestionNotification",
"GestionMessagerie", "GestionMessagerie",
"GestionEmail",
"Auth", "Auth",
"School", "School",
"Common" "Common"

BIN
Back-End/db.sqlite3 Normal file

Binary file not shown.

Binary file not shown.

View File

@ -10,21 +10,20 @@ def run_command(command):
print(f"stderr: {stderr.decode()}") print(f"stderr: {stderr.decode()}")
return process.returncode return process.returncode
test_mode = os.getenv('test_mode', 'false').lower() == 'true' test_mode = os.getenv('TEST_MODE', 'False') == 'True'
commands = [ commands = [
["python", "manage.py", "collectstatic", "--noinput"], ["python", "manage.py", "collectstatic", "--noinput"],
#["python", "manage.py", "flush", "--noinput"], ["python", "manage.py", "flush", "--noinput"],
# ["python", "manage.py", "makemigrations", "Common", "--noinput"], ["python", "manage.py", "makemigrations", "Common", "--noinput"],
# ["python", "manage.py", "makemigrations", "Establishment", "--noinput"], ["python", "manage.py", "makemigrations", "Establishment", "--noinput"],
# ["python", "manage.py", "makemigrations", "Settings", "--noinput"], ["python", "manage.py", "makemigrations", "Settings", "--noinput"],
# ["python", "manage.py", "makemigrations", "Subscriptions", "--noinput"], ["python", "manage.py", "makemigrations", "Subscriptions", "--noinput"],
# ["python", "manage.py", "makemigrations", "Planning", "--noinput"], ["python", "manage.py", "makemigrations", "Planning", "--noinput"],
# ["python", "manage.py", "makemigrations", "GestionNotification", "--noinput"], ["python", "manage.py", "makemigrations", "GestionNotification", "--noinput"],
# ["python", "manage.py", "makemigrations", "GestionEmail", "--noinput"], ["python", "manage.py", "makemigrations", "GestionMessagerie", "--noinput"],
# ["python", "manage.py", "makemigrations", "GestionMessagerie", "--noinput"], ["python", "manage.py", "makemigrations", "Auth", "--noinput"],
# ["python", "manage.py", "makemigrations", "Auth", "--noinput"], ["python", "manage.py", "makemigrations", "School", "--noinput"],
# ["python", "manage.py", "makemigrations", "School", "--noinput"],
["python", "manage.py", "migrate", "--noinput"] ["python", "manage.py", "migrate", "--noinput"]
] ]
@ -36,15 +35,14 @@ for command in commands:
if run_command(command) != 0: if run_command(command) != 0:
exit(1) exit(1)
#if test_mode: if test_mode:
# for test_command in test_commands: for test_command in test_commands:
# if run_command(test_command) != 0: if run_command(test_command) != 0:
# exit(1) exit(1)
# Lancer les processus en parallèle # Lancer les processus en parallèle
processes = [ processes = [
subprocess.Popen(["daphne", "-b", "0.0.0.0", "-p", "8080", "N3wtSchool.asgi:application"]), subprocess.Popen(["python", "manage.py", "runserver", "0.0.0.0:8080"]),
subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"]), subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"]),
subprocess.Popen(["celery", "-A", "N3wtSchool", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"]) subprocess.Popen(["celery", "-A", "N3wtSchool", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"])
] ]

View File

@ -1,42 +1,8 @@
<svg width="565" height="609" viewBox="0 0 565 609" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="38" height="120" viewBox="0 0 38 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M170.999 374.501C167.799 383.301 172.332 402.834 174.999 411.501C189.998 452.002 218.216 463.317 223 464C237 466 238.5 464 246.5 459.5C248.1 451.9 238.333 445 234 443C224 439.8 213.833 431 210 427C246.4 422.6 255.833 416.167 256 413.5C246.8 395.9 213.5 396.834 198 399.501C181.2 372.301 172.999 371.501 170.999 374.501Z" fill="#003625"/> <path d="M38 79.0995V40L29.9458 49.0478V72.3137L1.27073 99.134C-2.59496 103.012 3.20408 109.151 7.39397 106.243L38 79.0995Z" fill="#D9006D" fill-opacity="0.8"/>
<path d="M166 84.5L208 94.5L206 112.5C203 121 196.9 138.5 196.5 140.5C196.1 142.5 174 145.333 163 146.5L112 166.5C96.3333 177.833 64.7 200.8 63.5 202C62.3 203.2 42.3333 237.5 32.5 254.5L23 320L21 389.5C29.8333 416.333 47.7 470.6 48.5 473C49.5 476 66.9995 505.5 65.9995 505.5C65.1995 505.5 92.6662 529.5 106.5 541.5C116.666 549.667 137.4 566.2 139 567C140.6 567.8 156.333 574.667 164 578L221 584.5H247.5L291.5 572.5L341 546L388 490.5L397 475L411.5 447.5L414.5 425L406.5 400L386.5 377.5L366 371L367.5 381L375 402L377 419L367.5 440.5L360.5 456.5L337 475C327.166 480.167 307.5 489.9 307.5 487.5C307.5 484.5 269 494 264 494.5C259 495 231 493 227.5 490.5C224.7 488.5 183 471.667 162.5 463.5L104 282L199 314C219.5 315.5 261.2 318.5 264 318.5C266.8 318.5 275.5 315.5 279.5 314L276 307.5L272.5 299L264 289.5C266.833 288.167 273.9 285.5 279.5 285.5C285.1 285.5 281.833 279.5 279.5 276.5L272.5 266.5L236 261L227.5 248.5V227L264 185L279.5 157.5L288.5 142.5L295 130L307.5 120.5L328 112.5L392.5 103.5L457.5 87.5H476L481 84.5V73L476 60.5L470.5 50C467 47.6667 459.5 42.4 457.5 40C455.5 37.6 449.666 33 447 31H435.5L425.5 19.5L416 16L406 11H392.5L377 16H366.5L314 19.5L264 25.5L199 55L166 84.5Z" fill="#10B981"/> <path d="M0 79.0995V40L8.05422 49.0478V72.3137L36.7293 99.134C40.595 103.012 34.7959 109.151 30.606 106.243L0 79.0995Z" fill="#038ECE" fill-opacity="0.8"/>
<path d="M215 304C195.4 294 147.167 248.5 125.5 227L119.5 230.5C116.5 235.333 110.5 245.3 110.5 246.5C110.5 247.7 109.833 257 109.5 261.5C112 266.167 117.5 276.4 119.5 280C122 284.5 131.5 284.5 142 290C152.5 295.5 171 297 173.5 298C176 299 192 311 194.5 313C196.5 314.6 200.667 313.667 202.5 313L215 304Z" fill="#059669"/> <path d="M19 83L12 89.6585L19 96L26 89.6585L19 83Z" fill="#011922"/>
<path d="M33.5 381.5C32 386.167 29 396.9 29 402.5V415V427.5C41.1667 449.167 65.9 493 67.5 495C69.5 497.5 84 521 87.5 524.5C91 528 105 542.5 107.5 546C109.5 548.8 124.667 560.833 132 566.5L184.5 575C281.7 595 343 545 361.5 517.5L359.5 511C275.1 571.8 199.333 562.333 172 550C178.4 550 184.333 546.333 186.5 544.5C83.3 509.3 41.5 421.167 33.5 381.5Z" fill="#059669"/> <path d="M14 28.8736H24V72.1264H14V28.8736Z" fill="#001821"/>
<path d="M346 184C315.2 203.2 291.5 245.333 283.5 264C283.5 259.599 239.5 254.166 217.5 252C232.064 267.745 261.219 267.834 273.898 267.992C273.587 267.653 273.741 267.591 274.5 268C274.304 267.997 274.103 267.995 273.898 267.992C274.473 268.621 276.642 270.201 279.5 271.5C283.9 273.5 279.5 278 274.5 276C272.1 276 265.5 279.333 262.5 281H258C250 283.8 229 272.167 219.5 266C167.1 228.8 137.333 222.5 129 224L145.5 214C149.5 210.4 184.5 230.5 201.5 241C205.9 203.4 251 177.333 273 169C251.4 135 283 116.167 301.5 111C308.7 107.4 404.5 96.1665 451.5 90.9997C464.3 86.5997 465.5 92.833 464.5 96.4997C444.9 129.7 377.334 168.666 346 184Z" fill="white"/> <path d="M24 28.8736C24 31.5652 21.7614 33.7471 19 33.7471C16.2386 33.7471 14 31.5652 14 28.8736C14 26.182 16.2386 24 19 24C21.7614 24 24 26.182 24 28.8736Z" fill="#001821"/>
<path d="M299 33.9991C400.2 9.99913 455.167 48.3325 470 70.4991C471 68.3328 473.1 63.7 473.5 62.5C474 61 467 48.5 466 47C465 45.5 459 43 457.5 42C456 41 449.5 37.5 448 36C446.5 34.5 437 32.4987 436 32.4987C435 32.4987 430 28.4987 428.5 28.4987C427 28.4987 424 22.5 422.5 20.9987C421 19.4974 411.5 15 410.5 14C409.5 13 391.5 13 389 13C387 13 381.167 15.9991 378.5 17.4987C374.833 17.9987 366.9 18.6987 364.5 17.4987C361.5 15.9987 334 17.4987 331.5 17.4987C329 17.4987 300 20.9987 299 20.9987C298 20.9987 273 27.4987 270 28.4987C267.6 29.2987 249.667 31.4987 241 32.4987L188.5 65.9987V70.4991C223.3 46.4995 254 40.499 265 40.4987C250.2 48.8987 242.833 65.332 241 72.4987C255 53.2987 285.5 38.8323 299 33.9991Z" fill="white"/> <path d="M24 72.1264C24 74.818 21.7614 77 19 77C16.2386 77 14 74.818 14 72.1264C14 69.4348 16.2386 67.2529 19 67.2529C21.7614 67.2529 24 69.4348 24 72.1264Z" fill="#001821"/>
<path d="M273 295.001C268.2 296.601 269.667 300.667 271 302.501C259.001 296.9 259 286.168 260.5 281.502C273.5 276.502 274.5 275.501 275 272.502C275.4 270.102 257.833 270.502 249 271.002C239.8 271.802 224.5 259.335 218 253.002C224 253.002 245.833 256.001 256 257.5C264.8 257.5 278.333 260.833 284 262.5C303.2 218.9 334.667 191 348 182.5C447.6 126.099 466.833 98.9995 464 92.5C466 88.5 454.833 90.8333 449 92.5C379 113.7 318.167 118 296.5 117.5L318.5 107C338.899 107 428.333 82.3333 470.5 70C469.7 44.8 439.834 32.5 425.001 29.5C407.001 11.5 385.834 16 377.501 20.5C341.901 19.3 323.334 21 318.5 22C226.1 32.8 185.333 67.1667 176.5 83C206.1 80.2 220.167 85.8333 223.5 89C213.5 90.6 209.667 99.3333 209 103.5C206.2 108.3 201.5 129.5 199.5 139.5L188.501 145.5C96.9026 141.9 49.001 215.668 36.5 253.002C15.7 329.003 27.8333 401.334 36.5 428C74.9 542.799 158.167 574.5 195 576C276.6 586.8 339.333 541.167 360.5 517L358.5 510C389.7 488.4 403.167 450 406 433.5C410.799 391.9 382.666 377.833 368 376C397.999 417.999 367.834 457.833 349.001 472.5C289.226 524.9 200.428 504 163.5 487C43.5 421 68.1667 314.834 95.5 270.002C95.9 203.203 141 199.834 163.5 206.5C140.7 210.9 125 227 120 234.5C97.2 262.5 125.833 278.5 143 283.001C145 281.8 167.5 290.5 178.5 295.001C179.7 293.001 193 304.501 199.5 310.501L202 309.501C207.6 295.503 218.667 301.334 223.5 306L246 348.5C248.8 360.1 242.167 362 238.5 361.5C225.3 361.9 210.667 337.667 205 325.5L206.5 350C207.7 360 202.333 363.167 199.5 363.5C185.9 363.5 180.833 340.167 180 328.5C182 313.7 173.167 311.667 168.5 312.5C156.1 322.899 153 357.5 153 373.5C157 445.899 206.667 474.333 231 479.5C311.8 500.7 354.334 451.333 365.501 424C369.101 377.2 332.334 364.167 313.5 363.5C329.899 351.1 348.667 350.667 356.001 352C422.401 360 433.001 425.667 430.001 457.5C409.201 575.899 293.667 607.833 238.5 609C96.1 601.4 31.1676 490.5 16.5015 436C-33.0985 282.8 46.1681 182.833 92.0015 152C124.001 130.4 159.668 126.333 173.501 127C185.501 127.8 188.501 122 188.501 119C192.901 106.2 165.668 105.667 151.501 107C134.701 109 134.501 98.1667 136.501 92.5C202.101 14.1 314.501 5.49998 362.501 11C404.501 -15 432.667 12.5 441.501 29.5C481.101 33.1 489.667 60 489.001 73C486.201 106.2 465.834 129.5 456.001 137C449.201 145 419.167 165.333 405.001 174.5L344.001 209L344.501 211C356.501 219 371.501 242.334 377.501 253.002C386.301 251.402 402.167 254.335 409.001 256.002C419.001 260.002 427.167 267.002 430.001 270.002V275.002C429.601 276.202 426.501 277.168 425.001 277.502C422.601 278.702 407.334 273.668 400.001 271.002H398.001V272.502L409.001 281.502C415.801 287.102 420.501 295.835 422.001 299.501C424.001 305.102 422.167 308.501 421.001 309.501C418.201 312.702 414.501 311.501 413.001 310.501L386.001 284.502L385.001 284.002L392.001 303.001C394.001 307.001 394.167 315.335 394.001 319.001C391.201 331.001 384.167 327.001 381.001 323.501L365.501 286.501L365.001 310.501C363.801 318.901 358.501 322.335 356.001 323.001C351.201 323.001 349.334 320.335 349.001 319.001C347.401 316.202 346.667 301.168 346.501 294.001C344.101 286.801 334.834 281.001 330.501 279.001C322.501 274.201 302.167 274.667 293.001 275.501C293.001 279.901 292.334 282.334 292.001 283.001L273 295.001Z" fill="#003625"/>
<path d="M255 296.5C239.8 285.3 233.667 289.5 232.5 293C226.1 301.8 235.167 312.667 240.5 317C251.3 327 272.667 334.167 282 336.5C295.6 337.3 296 327.833 294.5 323C290.1 316.2 266.333 302.5 255 296.5Z" fill="#003625"/>
<path d="M283.499 263.5C278.299 258.7 255.999 254.5 245.499 253C241.899 249.401 294.666 208.834 321.499 189L303.499 185.5C331.899 183.5 362.333 153.334 373.999 138.5C417.499 142 460.999 87.5004 462.999 90.5004C464.599 92.9004 464.333 95.1671 463.999 96.0004C448.799 127.2 382.666 165 351.499 180C321.499 194.8 293.666 241.834 283.499 263.5Z" fill="#EEEDE8"/>
<path d="M201.5 147L198.5 139C191.7 143.8 184.5 143.833 182 144.5C141.6 139.3 104.5 164.5 91 176.5C37.8 218.1 21.6667 289.833 21 320.5C36.6 234.1 88.8333 186.5 112 173.5C135.2 156.3 171.667 153 187 153.5C189.8 153.5 197.833 149.167 201.5 147Z" fill="white"/>
<path d="M331.248 39C354.848 39.4 364.414 59.5 366.248 69.5C364.081 68.8333 359.848 67.6 360.248 68C360.648 68.4 359.415 72.1667 358.748 74C352.748 89.2 336.582 93 329.248 93C311.248 92.2 303.081 77.3333 301.248 70C298.448 46 320.081 39.3333 331.248 39Z" fill="#003625"/>
<path d="M310.749 63.4997C316.749 48.2997 331.582 50.1664 338.249 52.9997C325.849 55.7994 323.748 58.4998 324.248 59.5C329.448 70.3 323.748 73.3333 320.249 73.5C311.849 73.5 310.415 66.8331 310.749 63.4997Z" fill="white"/>
<circle cx="226" cy="107" r="10" fill="#003625"/>
<circle cx="226" cy="107" r="10" fill="#003625"/>
<circle cx="432.5" cy="60.5" r="7.5" fill="#003625"/>
<circle cx="432.5" cy="60.5" r="7.5" fill="#003625"/>
<circle cx="209" cy="147" r="10" fill="#003625"/>
<circle cx="209" cy="147" r="10" fill="#003625"/>
<circle cx="214.5" cy="126.5" r="4.5" fill="#003625"/>
<circle cx="214.5" cy="126.5" r="4.5" fill="#003625"/>
<circle cx="220.5" cy="77.5" r="4.5" fill="#003625"/>
<circle cx="220.5" cy="77.5" r="4.5" fill="#003625"/>
<circle cx="228.5" cy="59.5" r="4.5" fill="#003625"/>
<circle cx="228.5" cy="59.5" r="4.5" fill="#003625"/>
<ellipse cx="213" cy="63.5" rx="3" ry="3.5" fill="#003625"/>
<ellipse cx="213" cy="63.5" rx="3" ry="3.5" fill="#003625"/>
<ellipse cx="198.5" cy="74.5" rx="5.5" ry="3.5" fill="#003625"/>
<ellipse cx="198.5" cy="74.5" rx="5.5" ry="3.5" fill="#003625"/>
<circle cx="189.5" cy="161.5" r="3.5" fill="#003625"/>
<circle cx="189.5" cy="161.5" r="3.5" fill="#003625"/>
<circle cx="169.5" cy="159.5" r="3.5" fill="#003625"/>
<circle cx="169.5" cy="159.5" r="3.5" fill="#003625"/>
<circle cx="189.5" cy="161.5" r="4.5" fill="#003625"/>
<circle cx="189.5" cy="161.5" r="4.5" fill="#003625"/>
<circle cx="133.5" cy="173.5" r="3.5" fill="#003625"/>
<circle cx="133.5" cy="173.5" r="3.5" fill="#003625"/>
<circle cx="117.5" cy="187.5" r="3.5" fill="#003625"/>
<circle cx="117.5" cy="187.5" r="3.5" fill="#003625"/>
<circle cx="102.5" cy="186.5" r="3.5" fill="#003625"/>
<circle cx="102.5" cy="186.5" r="3.5" fill="#003625"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 903 B

View File

@ -1,260 +0,0 @@
# Changelog
Toutes les modifications notables apportées à ce projet seront documentées dans ce fichier.
### 0.0.2 (2025-06-01)
### Documentation
* mise à jour de la doc swagger ([11fc446](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/11fc446b904cc64d63154ad5c6711a8296a7fc51))
### Refactorisations
* "registerFilesTemplates" -> "registrerFileTemplate" ([83f4d67](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/83f4d67a6fc3f786803343957b276f8419f3058d))
* adaptation mobile ([4b8f85e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4b8f85e68dc95585d96a4cbad219ad068cbc8acf))
* Affichage des notifications dans la partie "Users" ([af30ae3](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/af30ae33b5660c55fa6824498f4325aab3de3c5a))
* Affichage des notifications dans la partie "Users" ([e509625](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e5096258110faac37b9457705dd1b51bc231983f))
* Augmentation du nombre de données ([95c154a](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/95c154a4a2d746c4350887bb697af142152ed8d7))
* changement de la philosophie de logging ([c7723ec](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c7723eceee650de86eea3263d44d374ad9844282))
* Changement des IconTextInput en TextInput, modification du composant step ([a248898](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a248898203286213c3447333611e1a9981dff64a))
* Composant *InscriptionForm* ([56e2762](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/56e27628f897920659a6ce186539ddec7e94a05a))
* Creation d'un provider et d'un systeme de middleware ([5088479](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/508847940c8c35fd982ab935f4d69371869eed5a))
* Création de composants et uniformisation des modales ([#2](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/2)) ([d51778b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d51778ba54e95283aa6ad7821fda673813c7c7a0))
* Création de nouveaux composants / update formulaire de ([7acae47](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/7acae479da658707fb3e073ebcdfee023d18500b)), closes [#2](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/2)
* Deplacement du JWT dans le back ([eb89a32](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/eb89a324abbdf69091e5c78530ec62f2c2ccbcd1))
* Document Ecole/Parent ([7564865](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/7564865d8f414fbefa0731c4ca472a100efb6036))
* gestion des erreurs ([f3490a4](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f3490a4e9584b959450ca45c8e74e430396425b3))
* Injection des env var dans le frontend ([aae5d27](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/aae5d27d8c556c5687951f3a04e01d42f69f3085))
* je suis une merde ([c4d4542](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c4d45426b520a498f409be8617c7936224195290))
* Mise à jour de la doc swagger / URL ([4c95b6a](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4c95b6a83f15a9989fac5f69a9386664d25ec9f6))
* Modification de l'url de l'api Auth ([9bf9c5f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/9bf9c5f62df1a6482ba27b897da498592b57e04f))
* Modification de la construction docker ([2d128aa](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2d128aaf30e60813c0c5caa244a93ff46e3985f3))
* Partie "School" ([58fe509](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/58fe509734a3b5dc6e0b5c6aa3fd713fd4dc821e))
* Partie FRONT / School ([24352ef](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/24352efad304dee7418dc846681a4b38047431f6))
* Refactoring de la section ClassSection ([1a8ef26](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/1a8ef26f5883abe4855949a54aa50defb98c852d))
* refactoring du FRONT page subscribe ([427b6c7](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/427b6c758892469d07579159511e7ce1ceed20d0))
* Refactorisation du login et de admin/subscription ([41aa9d5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/41aa9d55d388c0ddf189c7b9ab6057487f86484b))
* Remplacement de quelques popup par les notifications ([ce83e02](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/ce83e02f7b3e53ef2b859436432784d6eb69200d))
* Renommage du menu "Eleves" en "Inscriptions" ([692e845](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/692e8454bf9840ada3f8e052d7ef13cbf1b0d9c0))
* Revue de la modale permettant de créer un dossier ([cb3f909](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/cb3f909fa4e7a53148cd13cf190c13b0670d35de))
* Revue de la modale permettant de créer un dossier ([665625e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/665625e0280683fef056e9c950fc6555d889643e))
* SpecialitySection + TeacherSection (en cours) ([72dd769](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/72dd7699d6bd61e17b4c3dc0098ca0989a94b2c8))
* Suppression des paramètres mail mot de passes des settings ([ec2630a](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/ec2630a6e40dedaaa8f41a04b44e5ec1f6b2a1e0))
* Traduction en anglais des modules "GestionInscription" et ([2b414b8](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2b414b83913c2f0f81cf226b78577ad522443d7b))
* Transformation des requetes vers le back en action ajout des ([147a701](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/147a70135d2f10ac16961c098d85da0a1bcafb38))
* Utilisation d'une application "Common" pour tous les modèles ([e65e310](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e65e31014d2b89afe1e5f077e8d4109f07d40d0b))
### Nouvelles fonctionnalités
* A la signature d'un document, on récupère l'URL du PDF [[#22](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/22)] ([2ac4832](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2ac48329851f91f6bb02a44e02ad5a90b4ae504c))
* Affichage d'icones dans le tableau des inscriptions dans la ([9559db5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/9559db59eb418d233682217ef72f315bccc6fe1d))
* Ajout d'un composant permettant de visualiser les fichiers signés ([7f442b9](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/7f442b9cae008dab4f18438f9ee46be21ed037b0))
* Ajout d'un nouveau status avec envoi de mandat SEPA + envoi de ([4c2e2f8](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4c2e2f87565dc6e2be501839c274a5aa6969a9ec))
* Ajout d'un nouvel état dans l'automatique lorsqu'un mandat SEPA ([545349c](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/545349c7db7d0f653f3ae06b10d441ef975b0cc0))
* Ajout d'une colonne dans le tableau des pièces jointes indiquant ([3c0806e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/3c0806e26c116dbccd808bd8c8b170c5c4d9bc5b))
* Ajout d'une fonction de dissociation entre un responsable et un ([3bcc620](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/3bcc620ee103690a2ee5f79e6203aba880bda9b7))
* Ajout d'une fonction de logout ([c2bba1a](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c2bba1abbfafbb7aca1bb07e8019d7fa244a808e))
* Ajout d'une fonction de logout ([0ef6a2b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/0ef6a2b1192dbd3ecc59ce0e8cbba233ccc9c821))
* Ajout de l'emploi du temps sur la page parent ([78d96f8](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/78d96f82f91ed777073250b960eee8f326cccb43))
* Ajout de l'envoie de mail [[#17](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/17)] ([99a882a](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/99a882a64acfd9340d6849edd1766de5173a2341))
* Ajout de l'option d'envoi automatique [[#1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/1)] ([a77dd8e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a77dd8ec64bd78ab9c42aad3f93a181e64719d06))
* Ajout de la configuration des tarifs de l'école [[#18](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/18)] ([5a0e65b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5a0e65bb752a80781517394d7b2a673788f7595e))
* Ajout de la fratrie [[#27](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/27)] ([4a382d5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4a382d523ccfd4cf8fa7e672e9315b86dbdbbb14))
* Ajout de la fratrie / Gestion des index de fratrie / Gestion des ([2ab1684](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2ab1684791377804dd03c8467a94dbc1244e102f))
* Ajout de la gestion des fichier d'inscription [[#1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/1)] ([3c27133](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/3c27133cdb9943c5e20b81c03f9e2fa47077dbbb))
* Ajout de la photo pour le dossier de l'élève + correction ([5851341](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5851341235998647a4142bdf1996ddc9db21762d))
* Ajout de la possibilité de supprimer une association ([c9350a7](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c9350a796b65ea4eef0e38390ab9fb1d88196210))
* Ajout de la sélection des modes de paiements / refactoring de ([5a7661d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5a7661db93454b9a73b9f6bd46646c6135a0f203))
* Ajout des Bundles de fichiers [[#24](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/24)] ([ffc6ce8](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/ffc6ce8de835e9caf547b6c4a893436aa93513ba))
* ajout des documents d'inscription [[#20](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/20)] ([b8ef34a](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/b8ef34a04b14c8a8fb980fcd9255296ceb699ec6))
* Ajout des évenements à venir ([c03fa0b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c03fa0ba42d69918501beb5bb98637a449eb2da0))
* Ajout des frais d'inscription lors de la création d'un RF [[#18](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/18)] ([ece23de](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/ece23deb19483c50d9999541a482e3378db19d23))
* Ajout des frais de scolarité dans le dossier d'inscription [[#18](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/18)] ([0c2e0b9](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/0c2e0b92f43f223adc22db36ecad7fd864737a98))
* Ajout des modes de paiements + création d'une commande dans le ([0c5e3aa](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/0c5e3aa0988a16b6f9f8c0b411c2c1b443c972a7))
* Ajout des payementPlans dans le formulaire / ajout de la photo ([d37aed5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d37aed5f6496b8c8ca5519689dfc811d9626e09e))
* Ajout du logo de l'école ([6a0b90e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/6a0b90e98fbcc707756ae7fbbff921e480f2c695))
* Ajout du logo N3wt dans les mails ([8a71fa1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8a71fa1830d0c0fb11467208bc98dc4f71598199))
* Ajout du suivi de version dans le footer du Front ([fb7fbaf](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/fb7fbaf8394ebf41e6f3f31897e6d009c537a481))
* Amélioration de la fiche élève pour y ajouter la fratrie et les ([256f995](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/256f995698e79572eb3d51ea60b96b6fad47d553))
* Amélioration du dashboard ([eb48523](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/eb48523f7d466698faa268b8b25e6f1ed90bdfd7))
* Amorçage de la gestion des absences [[#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)] ([cb4fe74](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/cb4fe74a9e316a92c6b5e1d2550aaf2b1036a744))
* Aussi pour la table des parents tant qu'à faire ([a3182c0](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a3182c0ba7c6ea9f99a4fe34a4a00079b4676d59))
* **backend:** Ajout du logger django [[#7](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/7)] ([b8511f9](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/b8511f94b633b9bf5bd764b3706c53b74b3a6648))
* Bilan de compétence d'un élève [[#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)] ([5760c89](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5760c89105f38f4481e2cc6fa788bb0c39e8caa8))
* Champ de recherche de l'élève [[#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)] ([eb7805e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/eb7805e54e41f6eaefad81fea1616f0613365e8c))
* Configuration des compétences par cycle [[#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)] ([4e5aab6](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4e5aab6db74a8d1dfdfb4928f60ad47da52c89e8))
* Configuration et gestion du planning [[#2](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/2)] ([830d9a4](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/830d9a48c003e1cca469b1cf4082305e16685181))
* Création d'un annuaire / mise à jour du subscribe ([6bd5704](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/6bd5704983282264bc50c73677495740f7d7e8a9))
* Création d'un profile selector [[#37](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/37),[#38](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/38)] ([89b01b7](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/89b01b79db884c393db29332b95f570e47d20ed1))
* création d'une tooltip pour les informations supplémentaires de ([9197615](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/91976157e44e319b23fd35fa89859164bab71202))
* création de 4 JSON de compétences en attendant de les mettre en ([69405c5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/69405c577e7af3d07654fca96015d21f475e700d))
* Création de clones lors de la création de RF [[#22](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/22)] ([d1a0067](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d1a0067f7b7125e453ff6fc75efead881a7af37d))
* Création nouveau style / pagination profils annuaires ([760ee00](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/760ee0009e983776dfd500df7465ae66593dc85d))
* Dockerisation d'un serveur docuseal + initialisation d'un compte ([8897d52](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8897d523dc23fd2d89a0ec66b5cc7fa15b69db5b))
* Envoie d'un mail de bienvue au directeur ([5be5f9f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5be5f9f70d4fcf56da29afb19187806ff2e6e428))
* Evolution des modèles pour intégrer un planning et du m2m ([85d4c00](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/85d4c007cb2091ae1911ca1998f1b830470b8310))
* Formulaire de création RF sur une seule pag ([76f9a7d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/76f9a7dd14d7065f4add01718fda499fbb9183c7))
* Génération d'une page de suivi pédagogique + fix utilisation ([2a6b3bd](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2a6b3bdf63ddc13509b66690ea5d76eac77d1090))
* Génération du bilan de compétence en PDF [[#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)] ([0fe6c76](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/0fe6c761892097d043902f4f051b9fdb5fef29d0))
* Gestion de la création d'un nouveau guardian, de l'association ([fb73f9e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/fb73f9e9a86430d7498aa8a10e5abc46325b7b2c))
* Gestion de la mise à jour des profiles / roles / lors de l'édition ([dfd707d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/dfd707d7a0c7f4514f5583f07803d20e3c2d6bd7))
* Gestion de la sauvegarde du fichier d'inscription / affichage du ([d6edf25](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d6edf250bbc1cc1a9862e26174bc24ca4f9ee4c1))
* Gestion de la validation du dossier d'inscription ([b23264c](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/b23264c0d4008a4317c009a73ae11f57ee6917e2))
* Gestion des absences du jour [[#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)] ([030d19d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/030d19d411af8f0d87a3cb72cb401d9dd5fa96ce))
* Gestion des documents nécessitant des signatures électroniques et ([e3879f5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e3879f516b81b7e4b784049668b2507f12e8155f)), closes [#22](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/22)
* Gestion des documents parent ([59aee80](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/59aee80c2e7592a7cdb119d1d30a5ad2c8bb20b0))
* Gestion des documents signés durant l'inscription / possibilité de ([905b95f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/905b95f3a364f0d1ce8348d086870045d942bf92))
* gestion des no data dans les table [[#33](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/33)] ([2888f8d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2888f8dcce8d593df8f81a635eaac94af4603829))
* Gestion des pièces à fournir par les parents (configuration école) ([a65bd47](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a65bd47905cc33c44416c1def0413579b96d820d))
* Gestion des profils ADMIN/ECOLE (création des enseignants) ([e0bfd3e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e0bfd3e11579c512aa6ad63c73e00e40be4eaf06))
* Gestion des profils des enseignants / Visualisation d'une classe [[#4](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/4)] ([81d1dfa](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/81d1dfa9a70d0cd8d80e7d951a74c9355bba5238))
* Gestion des rattachements de Guardian à des RF déjà existants ([7d1b9c5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/7d1b9c5657439d2fff287f60b9aba79a5dfdf089))
* Gestion du planning [3] ([58144ba](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/58144ba0d0f1b53e9313f4cd4d3fbc3e6bfdd274))
* Gestion multi-profil multi-école ([1617829](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/16178296ec4dd4843be26b6e09b9c0f080df7ee4))
* Harmonisation des fees / ajout de type de réduction / mise à jour ([5462306](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5462306a6020493cf747ea3bb8edb3240c36286f)), closes [#18](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/18)
* Merge remote-tracking branch 'origin/WIP_style' into develop ([f887ae1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f887ae18862b740fa904d8ca04a3932eec455908))
* Messagerie WIP [[#17](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/17)] ([23a593d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/23a593dbc77b6f544a17de5a451ff60316f50292))
* Mise à jour des Dockerfile préparation d'un environnement de démo [[#12](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/12)] ([32a77c7](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/32a77c780abe8c0aa9846843ac81d13e4b8cf73a))
* Mise à jour des Teacher ([173ac47](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/173ac47fb26ba2f101802571621fc4112adb1a9f))
* Mise à jour du modèle (possibilité d'associer une réduciton à un ([8d1a41e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8d1a41e2693c3704b68e8d75bd32c4a89a6389e5)), closes [#18](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/18)
* mise en place de la messagerie [[#17](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/17)] ([d37145b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d37145b73e2012f21a329ee97a565189233ca0f8))
* Mise en place des actions pour chaque state du RF, possibilité ([8fc9478](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8fc947878665ca04e1697fa6df140e0d80c5a672))
* Mise en place des paiements en plusieurs fois - partie BACK [[#25](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/25)] ([274db24](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/274db249aa25f2a0281638c318a68cf88a721a45))
* Mise en place des paiements en plusieurs fois (partie BACK) [[#25](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/25)] ([23203c0](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/23203c0397f6247d32462cceca33d964898223a9))
* Mise en place du Backend-messagerie [[#17](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/17)] ([c6bc0d0](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c6bc0d0b515a5be7b0bf930ff628a5e9b5ebbb33))
* Nommage des templates / Intégration dans formulaire d'inscription ([eb81bbb](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/eb81bbba9265b9f3a71e500737436ee5301b7a5e)), closes [#22](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/22)
* Ordonnancement de l'inscription sur plusieurs pages + contrôle des ([daad12c](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/daad12cf40ce3b628581892f9a894a0841baa5e3))
* Oubli fichier [[#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)] ([d7fca9e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d7fca9e942412a8d2fe379f38052f3b41ed9c0f9))
* passage des mail au format HTML ([b97cf6e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/b97cf6e02b92ba6750662bbf9e9c3af6ad19ab38))
* Passage par une variable d'environnement pour les CORS et CSRF ([f9e870e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f9e870e11fe3041be53f4c357427d8060f50199f))
* Peuplement de la BDD avec les JSON d'entrée [[#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)] ([c6d7528](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c6d75281a1fab0d9dc27d4da80f91c6fffb1bc0e))
* planning events ([c9b0f0d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c9b0f0d77a5ec61a239deb71959738f3b0e82d37))
* Pre cablage du dashboard [#] ([1911f79](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/1911f79f4578f8bc3b455308182c46d2d59e5580))
* Préparation de la gestion des compétences en énumérant les élèves ([1c75927](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/1c75927bbab497cfc86fc3a9aea11d436318be69)), closes [#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)
* Preparation des modèles Settings pour l'enregistrement SMTP [[#17](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/17)] ([eda6f58](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/eda6f587fb21bf041784209228518c8a6f03b1b5))
* preparation du dockerfile pour le frontend [[#13](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/13)] ([9716373](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/9716373fa2d754177d4e71082b9079b71daab971))
* Rattachement d'un dossier de compétences à une période scolaire ([7de839e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/7de839ee5c9b09f7874575bdaf57436ec11b293f)), closes [#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)
* Refactoring de la fonction de création de profil sur guardian côté ([753a8d6](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/753a8d647ec3e45c8aabecba6d38b1a19741e0c0))
* Sauvegarde des compétences d'un élève [[#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)] ([0513603](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/05136035ab1d811c904e35d99ddb884c68b7fd74))
* Sauvegarde des fichiers migration ([017c029](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/017c0290dd1fab8afa3a05541e57a321733ff5c9))
* Signatures électroniques docuseal [[#22](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/22)] ([c8c8941](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c8c8941ec875b541cfb55c3504a0e951f36163ef))
* Sortie des calculs des montants totaux de la partie configuration + revue du rendu [[#18](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/18)] ([799e1c6](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/799e1c6717fceec4b29edfbdd0af52268b7e8fce))
* Suite de la gestion des sessions ([8ea68bb](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8ea68bbad0646d99209d1821a2b71364630005b3))
* Suppression de l'ancienne POPUP de RF ([5927e48](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5927e48e6544e8819a29766562107834d44e7a5d))
* Suppression des localStorage ([023b46e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/023b46e16e8da56971a8c55c0930e6ab4fbf53ec))
* Suppression des templates docuseal [[#22](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/22)] ([081dc06](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/081dc060014ee15ca6881fc83b779679a271326d))
* Upload du SEPA par les parents / Création d'un composant header ([8417d3e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8417d3eb141b116e2e9f8c6038831ce1bbe30e2a))
* Utilisation d'une clef API Docuseal par établissement ([23ab7d0](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/23ab7d04ef0c940b7008e8bc7d4b43b373d16d40))
* Utilisation de l'établissement en variable de session / gestion de ([f2ad1de](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f2ad1de5a4215395f3aa7a0e04ac2eb3edc5ec51))
* Utilisation des nouvelles alertes dans la page admin de la gestion ([67193a8](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/67193a8b3601784f37563094f3fdede943523b53))
* Validation du dossier d'inscription en affectant l'élève à une ([0f49236](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/0f49236965f575f6af17837b9860fa4481227785))
### Corrections de bugs
* correction des redirections vers la login page ([2e0fe86](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2e0fe86c71e9f02e8ee77ccbd80533a63a31ef63))
* Ajout d'un champ is_required pour les documents parents facultatifs ([5866427](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5866427544e24c8e79cb773d38bda683f63f4531))
* Ajout d'un message de confirmation lors de la suppression d'un ([9248480](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/92484804f6483eab401612315b5513cc78e6a726))
* ajout de credential include dans get CSRF ([c161fa7](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c161fa7e7568437ba501a565ad53192b9cb3b6f3))
* Ajout de l'établissement dans la requête KPI récupérant les ([ada2a44](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/ada2a44c3ec9ba45462bd7e78984dfa38008e231))
* Ajout des niveaux scolaires dans le back [[#27](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/27)] ([05542df](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/05542dfc40649fd194ee551f0298f1535753f219))
* ajout des urls prod et demo ([b780e8b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/b780e8b4ff4b5e6bbbccf1c77a56136c0c4affcb)), closes [#1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/1) [#123](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/123)
* Ajout du % ou € en mode édition de réduction ([f2628bb](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f2628bb45a14da42d014e42b1521820ffeedfb33))
* Ajout du controle sur le format des dates ([e538ac3](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e538ac3d56294d4e647a38d730168ea567c76f04))
* Ajout du mode Visu ([e1c6073](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e1c607308c12cf75695e9d4593dc27ebe74e6a4f))
* ajustement du handlePhoneChange [[#41](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/41)] ([31fdc61](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/31fdc612b10843ce694b55696f67bd2a80d56769))
* Application des périodes à un studentCompetency lors de la création ([d65b171](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d65b171da8a310acca15936a39e44239763c88b9))
* application des recommandations linter es pour générer un build de prod ([d1aa8b5](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d1aa8b54fb71bb946e95a19105f51f7f29c75fda))
* Application du formattage sur les fichiers modifiés ([001a5bc](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/001a5bc83c0bf54061b2b04967da3fc11e2cd8dc))
* boucle inifinie dans UseEffect ([f3c4284](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f3c428477879729d36760bb61dac015311c84fec))
* Bug lorsqu'on déselectionne un paiementPlan ([d64500f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d64500f4022710423c77d023476065816ecd061d))
* build error ([65d5b8c](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/65d5b8c424bf0e0a9da1b39500c8252f683725c7))
* Calcul du montant total des tarif par RF + affichage des tarifs ([c269b89](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c269b89d3d58cc65f254b75f6d713c4fd15f6320)), closes [#26](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/26)
* calcul nombre de pages dans chaque tab ([5440f5c](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5440f5cbdbca8b9435a17914c7e7c4ecc34e6bb3))
* Champs requis sur les teachers and classes ([42b4c99](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/42b4c99be86f050ccd76302caf725af5df413d17))
* Changement d'icone associé aux documents soumis à validation ([500b6e9](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/500b6e9af7ac76dafa35bd830cd0767cece47d27))
* code mort ([4fc061f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4fc061fc255b4174f794ac58da1b6849419e9f1a))
* Condition de validation d'ajout d'un nouveau document parent / ([9e69790](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/9e69790683fd83b0e48a9f70150661cb06a7b556))
* conflits + closeModal lors de la création d'un RF ([1617b13](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/1617b132c4f67fdbaf261808a0a9596b7a72a4dc))
* coquille ([c9c7e77](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c9c7e7715efde8766c3b2ad2c355dc9a9960b19f))
* coquille dans les imports ([4ecf25a](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4ecf25a6ab90a57da0013f6ed603d6cd5bd4eeeb))
* Correction de l'affichage des numéros de téléphone [[#41](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/41)] ([4f774c1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4f774c18e47fc57e081022a03ea352638e7211d2))
* correction de l'ouverture du dashbord [[#39](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/39)] ([a157d53](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a157d53932d5576fc9768f5c063cf9aafa214d43))
* Correction de la désactivation des spécialités lorsqu'on ([afc1632](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/afc1632797c0d35df7da03432eba9ab0f1875f55)), closes [#2](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/2)
* Correction dépendances circulaires ([fc9a1ed](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/fc9a1ed252e1e115e4a2f7c4a3a04ee6757be683))
* Correction des Protected Routes avec multi role ([dd0884b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/dd0884bbce6b6549f0f3fca991045f7170889710))
* correction des refresh des protected routes [[#36](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/36)] ([839a262](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/839a26257b659a86903d3f982548884cc87366b9))
* Correction du Establishment context au refresh ([43e301e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/43e301ed641a742323a98c430e30e134babc4aa4))
* correction fileGroup lors de l'enregistrement d'un nouveau responsable ([dce2114](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/dce2114a7940310e2c4241c2cdbd7e3fd060fb60))
* Correction option fusion ([e61cd51](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e61cd51ce2b0665c18f9497e6d2b1f7b8196723e))
* Correction sur le calcul du nombre total de pages [[#1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/1)] ([5946cbd](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5946cbdee661527317dac66f99f0abce021c835a))
* correction titre mail reset mdp ([cac1519](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/cac1519bf311b660831222d76d4d5165ee4f4d7e))
* Correction URL ([170f7c4](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/170f7c4fa80e1cd40079ac861e7e633c62f143df))
* Corrrection typo dans description des tableaux frais/réduction ([175932f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/175932ffa3fb2747cafd158b8142df9b7010a3d4))
* csrf ([59a0d40](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/59a0d401301fe77226fd5f294a3cd7e589d46fad))
* Division par 0 ([a42cf34](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a42cf348a0a3cc43c6c6b643b1da158690d67cb8))
* double confirmation sur les popup ([677cec1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/677cec1ec2f7a3582327f4747d088c6bccbd2560))
* entrypoint access right ([a041ffa](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a041ffaee75b74e0d559fb14bc79fbcfae98da14))
* faire plaisir à LSO ([9374b00](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/9374b001c9944cb6af1e10451f0e5f236a7890e8))
* formulaire sur toute la larguer + initiation à un autre style de bg ([4fd40ac](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4fd40ac5fc90ea1ddda9d73ea290b588074c6e2f))
* Fusion documents ([857b8b2](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/857b8b26c3722171007399dc66cd9980b33151c5))
* Generation d'une fiche d'élève avec le nouveau modèle PayementMode ([4f40d1f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4f40d1f29d7abd1e0c6bf889b10f811f184ff10d))
* Génération uniquement des compétences évaluées dans le PDF ([eca8d7a](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/eca8d7a8d59f39313123166859f4c4bf548d150e))
* gestion des codes retours ([7f35527](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/7f3552764979e098ca2e8c3547354c8ae6feaa23))
* Gestion des listes d'inscription "vides" [[#1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/1)] ([edc9724](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/edc97242f2219f48233441b8c7ec97ef9551c60c))
* gestion du jour d'échéance ([2576d21](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2576d2173460267664d927bd093580a21c18725b))
* import du Loader ([e2a39ff](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e2a39ff74dd9671bb1d00de2b6cec1cd3e4ff614))
* inject env var ([fc337b1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/fc337b1e0b4605f3490435f4819b01d38f921156))
* Limite du nombre de responsables légaux à 2 [[#27](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/27)] ([1ced4a1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/1ced4a10696057b8df114dc95adf9868e8d7aa43))
* Link documents with establishments ([2f6d30b](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/2f6d30b85b90508cae49081a82eadea5039f60b2))
* load the school image eorrectly ([6bc2405](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/6bc24055cd5a79d59d2b56c7e767ac1b30d99fff))
* Lors de la création d'un clone, on prend le nom de l'élève et pas ([db8e1d8](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/db8e1d8ab320222370c64d7b7fde3e43c59921e8))
* Messages de retour reset/new password ([4a6b7ce](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/4a6b7ce379747565c77205728e7b0d9c8a7c9585))
* Mise à jour correcte du fichier après avoir été signé ([5ea3cbb](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/5ea3cbb0790840d823e799cc64766a99ef5591a9))
* Mise à jour des upcomming events ([f93c428](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f93c42825964d91d11af26541eecb9ba5f01e801))
* mise à jour settings pour la prod / correction CORS ([25e2799](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/25e2799c0f0e46b1a6d78bcc849cc777e67a01f1))
* Mise en page des inscriptions (boutons ajout / barre de recherche) ([cf14431](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/cf144310a13fa4cbd01a292002d8a9963acc4598))
* Modèle créé 2 fois par erreur ([49907d7](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/49907d7ec8847017115191e937ba9f68350c92bd))
* Modification d'un guardian sans changer d'adresse mail (même ([95b449d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/95b449ddfde4160a55237e0c50e6bed604dcdfe5))
* Ne pas dissocier de responsable s'il n'y en a pas d'autre rattaché ([ac0672f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/ac0672f3349aa2ac62db0e3927658a3f2d66cebf))
* Ne pas retourner d'erreur si pas de dossier d'inscription ([be27fe1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/be27fe1232c3808b0a65d5f1b265ef454eb35e74))
* Nouvelle amélioration ([8b3f963](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8b3f9637a91fe817c87427015a89ba3e469d525d))
* On attend que la session soit mise à jour pour intiialiser le ([ccecd78](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/ccecd78704c3e6db58724401b92dd065a7e733ab))
* On commence à la page 1 ([3c62cc9](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/3c62cc9ad2cfa691ee798d27ee6b377676e50bb7))
* On empêche la sauvegarde d'un document à signer tant qu'aucun ([be013f0](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/be013f07864345024320114bd734508a033fd5db))
* On ne peut sélectionner que les élèves inscrits [[#16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/16)] ([56c223f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/56c223f3cc0498b4a6619d68f0185c36482c4ec9))
* Ordre des guardians lors de leur création / déselection correcte si ([3b667d3](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/3b667d3b150684a685d7d76cf06d050049ee07cd))
* pagination annuaire ([980f169](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/980f169c1d1a46f0d47f4b9ff65fa940ac610023))
* PieChart ([fe2d4d4](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/fe2d4d45137df3b1ead4d21c29722fec0bd0fbab))
* Positionnement de la variable isSepa ([82573f1](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/82573f1b2333a01675cebf575f33ab77e70e138b))
* Possibilité d'ajouter un 2ème guardian, même si son mail est ([8cf2290](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8cf22905e533a23ee679107cc0bcae1198badb4a))
* Récupération d'un template donné ([9b13d52](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/9b13d52e8d8926bcc6f756ff4d2c9d278a0cc387))
* Refresh par profil role ([24069b8](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/24069b894ef2009d9fe0ad884e7a39c29a5a9504))
* refresh token ([053d524](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/053d524a513adfec8bd9b3467fc358c257776a85))
* régression CORS_ALLOWED_ORIGINS ([a69498d](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/a69498dd06649b601a16a509c7a80c9f67c7872e))
* régression lors de l'uniformisation des modales ([00f7bfd](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/00f7bfde4abd10d080dc2035d3607d6c35e7db14))
* Remise du message de confirmation supprimé par erreur ([efcc5e6](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/efcc5e66722c829dcdf522a6903c616901a14604))
* Remise en état du bouton Submit ([e9650c9](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e9650c992e6c8339d7acde4000bf4f3dd8e98bac))
* Remise en place de l'API_KEY docuseal dans le back ([6d80594](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/6d805940fe5524cae1864c0beebcd136bda84eda))
* remove lint error ([aef6c19](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/aef6c193b1eaaedbb0642ce7929b2cfe8f47d682))
* Remplacement des enum par des modèles pour les payementModes et les ([7fe5346](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/7fe53465acc2df53351f713ccacd12223d6eff1a))
* restore du start.py suite à des tests ([de5f7cd](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/de5f7cd41e52b27ee3d8f47cf47fbfdad78216ac))
* right ([05f1f16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/05f1f16727c2510385425b23fa6ab98fa62d07be))
* Scroll de l'emploi du temps élève ([f38a441](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f38a4414c28ec52981201c15b7eda0dccc1f932f))
* searchTerm inscription ([8f0cf16](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/8f0cf16f707ac1cd51f0d619fa1c5ea0ba023f68))
* Session storage selectedEstablishmentEvaluationFrequency et selectedEstablishmentTotalCapacity ([e30753f](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e30753f1d6d2911d51bb9dfbf32fbae6f2b62b5d))
* Suite du commit précédent ([cd9c10a](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/cd9c10a88af2a05350570c424fb284280c0f65ee))
* Suppression d'un profil uniquement s'il ne contient aucun guardian ([330018e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/330018edfdbdc071c15838bc22b8a4e726773204))
* Suppression de la top bar admin [[#34](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/34)] ([3990d75](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/3990d75e521bea007a8f479924507498d9586a71))
* Suppression de print inutiles ([43874f8](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/43874f8b9e76b3e9f131f240ee895d958cd73fab))
* Suppression event planning ([c117f96](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/c117f96e528244ee68ce69b7685880e171976e32))
* Unicité des fees + utilisation de l'establishmentID [[#44](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/44)] ([d37e6c3](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/d37e6c384d0ef16053ed9fcc1e979f7f902cc8d8))
* Uniformisation des Modales et Popup [[#35](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/issues/35)] ([f252efd](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/f252efdef4fce1f2d19ac7ca1eb9c049706c0d9f))
* Utilisation des bonnes colonnes pour les fees et discounts selon si ([9f1f97e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/9f1f97e0c56771208305b28a740504c220287053))
* Utilisation du signal "post-migrate" pour créer la spécialité par ([e1202c6](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/e1202c6e6d4061552fa7d530e3e09b11384843c3))
* Variables booléennes par défaut ([6bedf71](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/6bedf715ccf9bb9bae4f92d735e3d7b714c96849))
* variables csrf ([789816e](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/789816e9868685e2ae08b536b6b6ada1a6a64595))
* warning sur ouverture modale de fichiers ([889a3a4](https://git.v0id.ovh:5022/n3wt-innov/n3wt-school/commit/889a3a48c5c2a3f6cb65de8ede0efbe639408011))

5
Front-End/.env Normal file
View File

@ -0,0 +1,5 @@
NEXT_PUBLIC_API_URL=http://localhost:8080
NEXT_PUBLIC_USE_FAKE_DATA='false'
AUTH_SECRET='false'
NEXTAUTH_URL=http://localhost:3000
DOCUSEAL_API_KEY="LRvUTQCbMSSpManYKshdQk9Do6rBQgjHyPrbGfxU3Jg"

View File

@ -1,340 +0,0 @@
# API Messagerie Instantanée - Guide Développeur
## Vue d'ensemble
Cette documentation technique présente l'implémentation du système de messagerie instantanée, incluant les APIs WebSocket et REST, l'architecture des composants React et les fonctions utilitaires.
## API WebSocket
### Connexion
**URL de connexion :**
```javascript
// Développement
ws://localhost:8000/ws/chat/{userId}/
// Production
wss://[domaine]/ws/chat/{userId}/
```
### Messages WebSocket
#### Messages entrants (serveur → client)
```javascript
// Liste des conversations
{
"type": "conversations_list",
"conversations": [...]
}
// Nouveau message reçu
{
"type": "new_message",
"message": {
"id": 123,
"conversation_id": 456,
"sender_id": 789,
"content": "Contenu du message",
"timestamp": "2024-01-01T12:00:00Z"
}
}
// Utilisateur en train d'écrire
{
"type": "typing_start",
"conversation_id": 456,
"user_id": 789
}
// Utilisateur a arrêté d'écrire
{
"type": "typing_stop",
"conversation_id": 456,
"user_id": 789
}
```
#### Messages sortants (client → serveur)
```javascript
// Envoyer un message
{
"type": "chat_message",
"conversation_id": 456,
"message": "Contenu du message"
}
// Signaler début de frappe
{
"type": "typing_start",
"conversation_id": 456
}
// Signaler fin de frappe
{
"type": "typing_stop",
"conversation_id": 456
}
// Marquer comme lu
{
"type": "mark_as_read",
"conversation_id": 456
}
// Rejoindre une conversation
{
"type": "join_conversation",
"conversation_id": 456
}
```
## API REST
### Endpoints disponibles
```javascript
// Récupérer les conversations
GET /api/messagerie/conversations/{userId}/
Response: Array<Conversation>
// Récupérer les messages d'une conversation
GET /api/messagerie/messages/{conversationId}/
Response: Array<Message>
// Rechercher des destinataires
GET /api/messagerie/search/{establishmentId}/?q={query}
Response: Array<User>
// Créer une conversation
POST /api/messagerie/conversations/create/
Body: { "participants": [userId1, userId2] }
Response: Conversation
// Envoyer un email (séparé de la messagerie instantanée)
POST /api/email/send/
Body: { "recipients": [...], "subject": "...", "content": "..." }
```
## Composants React
### InstantChat
**Props :**
```javascript
{
userProfileId: number, // ID de l'utilisateur connecté
establishmentId: number // ID de l'établissement
}
```
**États principaux :**
- `conversations` : Liste des conversations
- `selectedConversation` : Conversation active
- `messages` : Messages de la conversation active
- `searchQuery` : Terme de recherche
- `searchResults` : Résultats de recherche de contacts
### useWebSocket Hook
**Paramètres :**
```javascript
useWebSocket(
userProfileId, // ID utilisateur
onMessage, // Callback pour messages reçus
onConnectionChange // Callback changement de connexion
);
```
**Valeurs retournées :**
```javascript
{
isConnected: boolean,
connectionStatus: string,
sendChatMessage: (conversationId, content) => boolean,
sendTypingStart: (conversationId) => void,
sendTypingStop: (conversationId) => void,
markAsRead: (conversationId) => void,
joinConversation: (conversationId) => void,
reconnect: () => void
}
```
## Actions Redux/State
### messagerieAction.js
```javascript
// Récupérer les conversations
fetchConversations(userId): Promise<Array<Conversation>>
// Récupérer les messages
fetchMessages(conversationId): Promise<Array<Message>>
// Rechercher des destinataires
searchMessagerieRecipients(establishmentId, query): Promise<Array<User>>
// Créer une conversation
createConversation(participants): Promise<Conversation>
```
### emailAction.js
```javascript
// Envoyer un email
sendEmail(recipients, subject, content, csrfToken): Promise<Response>
// Rechercher des destinataires email
searchEmailRecipients(establishmentId, query): Promise<Array<User>>
```
## Modèles de Données
### Conversation
```javascript
{
conversation_id: number,
participants: Array<User>,
last_message: Message,
created_at: string,
updated_at: string
}
```
### Message
```javascript
{
id: number,
conversation_id: number,
sender_id: number,
content: string,
timestamp: string,
is_read: boolean
}
```
### User
```javascript
{
id: number,
first_name: string,
last_name: string,
email: string,
role: string
}
```
## Gestion des Erreurs
### WebSocket
```javascript
// Reconnexion automatique
const reconnectWebSocket = () => {
setConnectionStatus('reconnecting');
// Logique de reconnexion avec backoff exponentiel
};
// Gestion des erreurs de connexion
wsRef.current.onerror = (error) => {
logger.error('Erreur WebSocket:', error);
setIsConnected(false);
};
```
### API REST
```javascript
// Wrapper avec gestion d'erreur
const apiCall = async (url, options) => {
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`Erreur ${response.status}`);
}
return await response.json();
} catch (error) {
logger.error('Erreur API:', error);
throw error;
}
};
```
## Configuration des Tests
### Jest Setup
```javascript
// jest.setup.js
global.WebSocket = class MockWebSocket {
// Mock complet du WebSocket pour les tests
};
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve([]),
})
);
```
### Tests des Composants
```javascript
// Exemple de test
test('renders InstantChat component', async () => {
await act(async () => {
render(<InstantChat userProfileId={1} establishmentId={123} />);
});
expect(screen.getByText('Messages')).toBeInTheDocument();
});
```
## Intégration Backend
### Consumer Django
```python
# consumers.py
class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
# Logique de connexion
async def chat_message(self, event):
# Traitement des messages
```
### URLs Configuration
```python
# routing.py
websocket_urlpatterns = [
re_path(r'ws/chat/(?P<user_id>\w+)/$', ChatConsumer.as_asgi()),
]
```
## Optimisations
### Performance
- Pagination des messages anciens (load on scroll)
- Debounce pour la recherche de contacts (300ms)
- Memoization des composants avec React.memo
- Lazy loading des conversations
### UX
- Reconnexion automatique avec feedback visuel
- Sauvegarde locale des messages en cours de frappe
- Indicateurs de livraison des messages
- Scrolling automatique vers les nouveaux messages

View File

@ -1,126 +0,0 @@
# Système de Messagerie Instantanée
## Présentation
Le système de messagerie instantanée de N3WT-SCHOOL permet aux utilisateurs de l'établissement (administrateurs, professeurs, parents, étudiants) de communiquer en temps réel via une interface chat moderne et intuitive.
## Fonctionnalités
### Chat en Temps Réel
- Envoi et réception de messages instantanés
- Notification de statut de frappe (utilisateur en train d'écrire)
- Indicateur de statut de connexion WebSocket
- Reconnexion automatique en cas de perte de connexion
### Gestion des Conversations
- Liste des conversations existantes
- Création de nouvelles conversations
- Recherche de destinataires par nom ou email
- Compteur de messages non lus
### Interface Utilisateur
- Interface moderne en deux panneaux (conversations + chat)
- Bulles de messages différenciées (expéditeur/destinataire)
- Indicateurs visuels de statut de connexion
- Recherche temps réel de contacts
## Utilisation
### Accès au Chat
Le système de messagerie est accessible via les pages suivantes :
- **Parents** : `/[locale]/parents/messagerie`
- **Administrateurs** : Intégré dans le panneau d'administration
### Créer une Conversation
1. Cliquer sur le bouton "+" en haut à droite de la liste des conversations
2. Rechercher un contact en tapant son nom ou email
3. Sélectionner le destinataire dans les résultats
4. La conversation se crée automatiquement
### Envoyer un Message
1. Sélectionner une conversation dans la liste de gauche
2. Taper le message dans le champ de saisie en bas
3. Appuyer sur Entrée ou cliquer sur le bouton d'envoi
## Architecture Technique
### Frontend (React/Next.js)
**Composants principaux :**
- `InstantChat` : Composant principal du chat
- `ConnectionStatus` : Affichage du statut de connexion
- `ConversationItem` : Élément de liste de conversation
- `MessageBubble` : Bulle de message individuelle
- `MessageInput` : Zone de saisie de message
- `TypingIndicator` : Indicateur de frappe
**Hook personnalisé :**
- `useWebSocket` : Gestion de la connexion WebSocket et des événements
### Backend (Django)
**Module GestionMessagerie :**
- `consumers.py` : Consumer WebSocket pour la messagerie temps réel
- `routing.py` : Configuration des routes WebSocket
- `urls.py` : URLs API REST pour les conversations et messages
**Module GestionEmail :**
- `views.py` : Vues pour l'envoi d'emails classiques
- `urls.py` : URLs pour les fonctions email
### Communication
- **WebSocket** : Communication bidirectionnelle temps réel
- **REST API** : Chargement initial des données et recherche
- **Channels** : Gestion des groupes de conversation Django
## Configuration
### URLs WebSocket
Les URLs sont configurées automatiquement selon l'environnement :
- **Développement** : `ws://localhost:8000/ws/chat/`
- **Production** : `wss://[domaine]/ws/chat/`
### Variables d'Environnement
Le système utilise les configurations standard de l'application pour :
- Base de données (conversations, messages, utilisateurs)
- Authentification (sessions Django)
- Établissements (filtrage par établissement)
## Sécurité
- Authentification requise pour accéder au chat
- Filtrage des conversations par établissement
- Validation côté serveur de tous les messages
- Gestion des permissions selon le rôle utilisateur
## Tests
Le système dispose de tests unitaires Jest couvrant :
- Rendu des composants
- Gestion des connexions WebSocket
- Recherche de contacts
- Envoi de messages
- Indicateurs de frappe
Exécution des tests :
```bash
npm test
```

View File

@ -1,33 +0,0 @@
const nextJest = require('next/jest');
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files
dir: './',
});
// Add any custom config to be passed to Jest
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.{js,jsx}',
'!src/**/*.stories.{js,jsx}',
'!src/pages/_app.js',
'!src/pages/_document.js',
],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
};
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig);

View File

@ -1,95 +0,0 @@
import '@testing-library/jest-dom';
// Supprimer les avertissements React act() en environnement de test
global.IS_REACT_ACT_ENVIRONMENT = true;
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
observe() {
return null;
}
disconnect() {
return null;
}
unobserve() {
return null;
}
};
// Mock WebSocket
global.WebSocket = class WebSocket {
constructor(url) {
this.url = url;
this.readyState = WebSocket.CONNECTING;
setTimeout(() => {
this.readyState = WebSocket.OPEN;
if (this.onopen) this.onopen();
}, 10);
}
send(data) {
// Mock send
}
close() {
this.readyState = WebSocket.CLOSED;
if (this.onclose) {
this.onclose({
code: 1000,
reason: 'Normal closure',
wasClean: true,
});
}
}
static get CONNECTING() {
return 0;
}
static get OPEN() {
return 1;
}
static get CLOSING() {
return 2;
}
static get CLOSED() {
return 3;
}
};
// Mock global pour fetch
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve([]),
})
);
// Mock ResizeObserver
global.ResizeObserver = class ResizeObserver {
constructor() {}
observe() {
return null;
}
disconnect() {
return null;
}
unobserve() {
return null;
}
};

View File

@ -1,6 +1,6 @@
{ {
"dashboard": "Tableau de bord", "dashboard": "Tableau de bord",
"totalStudents": "Total d'étudiants inscrits", "totalStudents": "Total des étudiants",
"pendingRegistrations": "Inscriptions en attente", "pendingRegistrations": "Inscriptions en attente",
"reInscriptionRate": "Taux de réinscription", "reInscriptionRate": "Taux de réinscription",
"structureCapacity": "Capacité de la structure", "structureCapacity": "Capacité de la structure",

View File

@ -18,27 +18,12 @@ const nextConfig = {
protocol: 'https', protocol: 'https',
hostname: 'www.gravatar.com', hostname: 'www.gravatar.com',
}, },
{
protocol: 'https',
hostname: 'api.demo.n3wtschool.com',
},
{
protocol: 'https',
hostname: 'api.prod.n3wtschool.com',
},
{
protocol: 'http',
hostname: 'localhost',
port: '8080',
},
], ],
}, },
env: { env: {
NEXT_PUBLIC_APP_VERSION: pkg.version, NEXT_PUBLIC_APP_VERSION: pkg.version,
NEXT_PUBLIC_API_URL: NEXT_PUBLIC_API_URL:
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080', process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
NEXT_PUBLIC_WSAPI_URL:
process.env.NEXT_PUBLIC_WSAPI_URL || 'ws://localhost:8080',
NEXT_PUBLIC_USE_FAKE_DATA: process.env.NEXT_PUBLIC_USE_FAKE_DATA || 'false', NEXT_PUBLIC_USE_FAKE_DATA: process.env.NEXT_PUBLIC_USE_FAKE_DATA || 'false',
AUTH_SECRET: process.env.AUTH_SECRET || 'false', AUTH_SECRET: process.env.AUTH_SECRET || 'false',
NEXTAUTH_URL: process.env.NEXTAUTH_URL || 'http://localhost:3000', NEXTAUTH_URL: process.env.NEXTAUTH_URL || 'http://localhost:3000',

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "n3wt-school-front-end", "name": "n3wt-school-front-end",
"version": "0.0.2", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@ -8,10 +8,7 @@
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"lint-light": "next lint --quiet", "lint-light": "next lint --quiet",
"check-strings": "node scripts/check-hardcoded-strings.js", "check-strings": "node scripts/check-hardcoded-strings.js"
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}, },
"dependencies": { "dependencies": {
"@docuseal/react": "^1.0.56", "@docuseal/react": "^1.0.56",
@ -40,11 +37,6 @@
"react-tooltip": "^5.28.0" "react-tooltip": "^5.28.0"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/react": "^13.4.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/user-event": "^14.4.3",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "14.2.11", "eslint-config-next": "14.2.11",

View File

@ -1,5 +1,5 @@
NEXT_PUBLIC_API_URL=_NEXT_PUBLIC_API_URL_ NEXT_PUBLIC_API_URL=_NEXT_PUBLIC_API_URL_
NEXT_PUBLIC_WSAPI_URL=_NEXT_PUBLIC_WSAPI_URL_
NEXT_PUBLIC_USE_FAKE_DATA=_NEXT_PUBLIC_USE_FAKE_DATA_ NEXT_PUBLIC_USE_FAKE_DATA=_NEXT_PUBLIC_USE_FAKE_DATA_
AUTH_SECRET=_AUTH_SECRET_ AUTH_SECRET=_AUTH_SECRET_
NEXTAUTH_URL=_NEXTAUTH_URL_ NEXTAUTH_URL=_NEXTAUTH_URL_
DOCUSEAL_API_KEY=_DOCUSEAL_API_KEY_

View File

@ -3,6 +3,7 @@ import React, { useState, useEffect } from 'react';
import Sidebar from '@/components/Sidebar'; import Sidebar from '@/components/Sidebar';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Image from 'next/image';
import { import {
LayoutDashboard, LayoutDashboard,
FileText, FileText,
@ -12,8 +13,11 @@ import {
Calendar, Calendar,
Settings, Settings,
LogOut, LogOut,
MessageSquare, Menu,
X,
Mail,
} from 'lucide-react'; } from 'lucide-react';
import DropdownMenu from '@/components/DropdownMenu';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
import { import {
@ -82,7 +86,7 @@ export default function Layout({ children }) {
id: 'messagerie', id: 'messagerie',
name: t('messagerie'), name: t('messagerie'),
url: FE_ADMIN_MESSAGERIE_URL, url: FE_ADMIN_MESSAGERIE_URL,
icon: MessageSquare, icon: Mail,
}, },
settings: { settings: {
id: 'settings', id: 'settings',

View File

@ -3,6 +3,7 @@ import React from 'react';
import SidebarTabs from '@/components/SidebarTabs'; import SidebarTabs from '@/components/SidebarTabs';
import EmailSender from '@/components/Admin/EmailSender'; import EmailSender from '@/components/Admin/EmailSender';
import InstantMessaging from '@/components/Admin/InstantMessaging'; import InstantMessaging from '@/components/Admin/InstantMessaging';
import AnnouncementScheduler from '@/components/Admin/AnnouncementScheduler';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
export default function MessageriePage({ csrfToken }) { export default function MessageriePage({ csrfToken }) {
@ -17,11 +18,19 @@ export default function MessageriePage({ csrfToken }) {
label: 'Messagerie Instantanée', label: 'Messagerie Instantanée',
content: <InstantMessaging csrfToken={csrfToken} />, content: <InstantMessaging csrfToken={csrfToken} />,
}, },
{
id: 'announcement',
label: 'Planifier une Annonce',
content: <AnnouncementScheduler csrfToken={csrfToken} />,
},
]; ];
return ( return (
<div className="h-full flex flex-col p-0 m-0"> <div className="flex h-full w-full">
<SidebarTabs tabs={tabs} /> <SidebarTabs
tabs={tabs}
onTabChange={(tabId) => logger.debug(`Onglet actif : ${tabId}`)}
/>
</div> </div>
); );
} }

View File

@ -1,17 +1,11 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { import { Users, Clock, CalendarCheck, School, TrendingUp } from 'lucide-react';
Users,
Clock,
CalendarCheck,
School,
AlertTriangle,
CheckCircle2,
} from 'lucide-react';
import Loader from '@/components/Loader'; import Loader from '@/components/Loader';
import ClasseDetails from '@/components/ClasseDetails';
import { fetchClasses } from '@/app/actions/schoolAction';
import StatCard from '@/components/StatCard'; import StatCard from '@/components/StatCard';
import EventCard from '@/components/EventCard';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { import {
fetchRegisterForms, fetchRegisterForms,
@ -19,105 +13,80 @@ import {
} from '@/app/actions/subscriptionAction'; } from '@/app/actions/subscriptionAction';
import { fetchUpcomingEvents } from '@/app/actions/planningAction'; import { fetchUpcomingEvents } from '@/app/actions/planningAction';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { useNotification } from '@/context/NotificationContext';
import Attendance from '@/components/Grades/Attendance'; import Attendance from '@/components/Grades/Attendance';
import LineChart from '@/components/Charts/LineChart';
import PieChart from '@/components/Charts/PieChart';
const mockCompletionRate = 72; // en pourcentage // Composant EventCard pour afficher les événements
const EventCard = ({ title, date, description, type }) => (
<div className="bg-stone-50 p-4 rounded-lg shadow-sm border border-gray-100 mb-4">
<div className="flex items-center gap-3">
<CalendarCheck className="text-blue-500" size={20} />
<div>
<h4 className="font-medium">{title}</h4>
<p className="text-sm text-gray-500">{date}</p>
<p className="text-sm mt-1">{description}</p>
</div>
</div>
</div>
);
export default function DashboardPage() { export default function DashboardPage() {
const t = useTranslations('dashboard'); const t = useTranslations('dashboard');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [currentYearRegistrationCount, setCurrentYearRegistrationCount] = const [totalStudents, setTotalStudents] = useState(0);
useState(0); const [pendingRegistration, setPendingRegistration] = useState(0);
const [structureCapacity, setStructureCapacity] = useState(0);
const [upcomingEvents, setUpcomingEvents] = useState([]); const [upcomingEvents, setUpcomingEvents] = useState([]);
const [monthlyStats, setMonthlyStats] = useState({
inscriptions: [],
completionRate: 0,
});
const [classes, setClasses] = useState([]);
const [absencesToday, setAbsencesToday] = useState([]); const [absencesToday, setAbsencesToday] = useState([]);
const { const { selectedEstablishmentId, selectedEstablishmentTotalCapacity } =
selectedEstablishmentId, useEstablishment();
selectedEstablishmentTotalCapacity, const { showNotification } = useNotification();
apiDocuseal,
} = useEstablishment();
const [statusDistribution, setStatusDistribution] = useState([
{ label: 'Non envoyé', value: 0 },
{ label: 'En attente', value: 0 },
{ label: 'En validation', value: 0 },
{ label: 'Validé', value: 0 },
]);
const [monthlyRegistrations, setMonthlyRegistrations] = useState([]);
useEffect(() => { useEffect(() => {
if (!selectedEstablishmentId) return; if (!selectedEstablishmentId) return;
setIsLoading(true); // Début du chargement setIsLoading(true); // Début du chargement
// Fetch des classes
fetchClasses(selectedEstablishmentId)
.then((data) => {
setClasses(data);
logger.info('Classes fetched:', data);
const nbMaxStudents = data.reduce(
(acc, classe) => acc + classe.number_of_students,
0
);
const nbStudents = data.reduce(
(acc, classe) => acc + classe.students.length,
0
);
setStructureCapacity(nbMaxStudents);
setTotalStudents(nbStudents);
})
.catch((error) => {
logger.error('Error fetching classes:', error);
showNotification(
'Error fetching classes: ' + error.message,
'error',
'Erreur'
);
});
// Fetch des formulaires d'inscription // Fetch des formulaires d'inscription
fetchRegisterForms(selectedEstablishmentId) fetchRegisterForms(selectedEstablishmentId)
.then((data) => { .then((data) => {
logger.info('Pending registrations fetched:', data); logger.info('Pending registrations fetched:', data);
setCurrentYearRegistrationCount(data.count || 0); setPendingRegistration(data.count);
const forms = data.registerForms || [];
// Filtrage des statuts
const distribution = [
{
label: 'Non envoyé',
value: forms.filter((f) => f.status === 1).length,
},
{
label: 'En attente',
value: forms.filter((f) => f.status === 2 || f.status === 7).length,
},
{
label: 'En validation',
value: forms.filter((f) => f.status === 3 || f.status === 8).length,
},
{
label: 'Validé',
value: forms.filter((f) => f.status === 5).length,
},
];
setStatusDistribution(distribution);
// Calcul des inscriptions validées par mois
const validForms = forms.filter(
(f) => f.status === 5 && f.formatted_last_update
);
// Format attendu : "29-05-2025 09:23"
const monthLabels = [
'Janv',
'Fév',
'Mars',
'Avr',
'Mai',
'Juin',
'Juil',
'Août',
'Sept',
'Oct',
'Nov',
'Déc',
];
const monthlyCount = Array(12).fill(0);
validForms.forEach((f) => {
const [day, month, yearAndTime] = f.formatted_last_update.split('-');
const monthIdx = parseInt(month, 10) - 1;
if (monthIdx >= 0 && monthIdx < 12) {
monthlyCount[monthIdx]++;
}
});
const monthlyData = monthLabels.map((label, idx) => ({
month: label,
value: monthlyCount[idx],
}));
setMonthlyRegistrations(monthlyData);
}) })
.catch((error) => { .catch((error) => {
logger.error('Erreur lors du fetch des inscriptions :', error); logger.error('Error fetching pending registrations:', error);
}); });
// Fetch des événements à venir // Fetch des événements à venir
@ -154,35 +123,11 @@ export default function DashboardPage() {
}); });
}, [selectedEstablishmentId]); }, [selectedEstablishmentId]);
// Calculs à partir de statusDistribution
const totalStudents =
statusDistribution.find((s) => s.label === 'Validé')?.value || 0;
const pendingRegistrationCount = statusDistribution
.filter((s) => s.label !== 'Validé')
.reduce((acc, s) => acc + s.value, 0);
if (isLoading) return <Loader />; if (isLoading) return <Loader />;
return ( return (
<div key={selectedEstablishmentId} className="p-6"> <div key={selectedEstablishmentId} className="p-6">
<div className="flex items-center gap-3 mb-6"> <h1 className="text-2xl font-bold mb-6">{t('dashboard')}</h1>
<span
className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold ${
apiDocuseal
? 'bg-green-100 text-green-700 border border-green-300'
: 'bg-red-100 text-red-700 border border-red-300'
}`}
>
{apiDocuseal ? (
<CheckCircle2 className="w-4 h-4 mr-2 text-green-500" />
) : (
<AlertTriangle className="w-4 h-4 mr-2 text-red-500" />
)}
{apiDocuseal
? 'Clé API Docuseal renseignée'
: 'Clé API Docuseal manquante'}
</span>
</div>
{/* Statistiques principales */} {/* Statistiques principales */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
@ -193,60 +138,48 @@ export default function DashboardPage() {
/> />
<StatCard <StatCard
title={t('pendingRegistrations')} title={t('pendingRegistrations')}
value={pendingRegistrationCount} value={`${pendingRegistration} `}
icon={<Clock className="text-green-500" size={24} />} icon={<Clock className="text-green-500" size={24} />}
color="green" color="green"
/> />
<StatCard <StatCard
title={t('structureCapacity')} title={t('structureCapacity')}
value={selectedEstablishmentTotalCapacity} value={`${selectedEstablishmentTotalCapacity}`}
icon={<School className="text-green-500" size={24} />} icon={<School className="text-green-500" size={24} />}
color="emerald" color="emerald"
/> />
<StatCard <StatCard
title={t('capacityRate')} title={t('capacityRate')}
value={ value={`${((totalStudents / selectedEstablishmentTotalCapacity) * 100).toFixed(1)}%`}
selectedEstablishmentTotalCapacity > 0
? `${((totalStudents / selectedEstablishmentTotalCapacity) * 100).toFixed(2)}%`
: 0
}
icon={<School className="text-orange-500" size={24} />} icon={<School className="text-orange-500" size={24} />}
color="orange" color="orange"
/> />
</div> </div>
{/* Événements et KPIs */} {/* Événements et KPIs */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
{/* Colonne de gauche : Graphique des inscriptions + Présence */}
<div className="flex flex-col gap-6">
{/* Graphique des inscriptions */} {/* Graphique des inscriptions */}
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1"> <div className="lg:col-span-2 bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100">
<h2 className="text-lg font-semibold mb-6"> <h2 className="text-lg font-semibold mb-4">
{t('inscriptionTrends')} {t('inscriptionTrends')}
</h2> </h2>
<div className="flex flex-row gap-4"> {/* Insérer ici un composant de graphique */}
<div className="flex-1 p-6"> <div className="h-64 bg-gray-50 rounded flex items-center justify-center mb-6">
<LineChart data={monthlyRegistrations} /> <TrendingUp size={48} className="text-gray-300" />
</div>
<div className="flex-1 flex items-center justify-center">
<PieChart data={statusDistribution} />
</div>
</div>
</div>
{/* Présence et assiduité */}
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
<Attendance absences={absencesToday} readOnly={true} />
</div> </div>
</div> </div>
{/* Colonne de droite : Événements à venir */} {/* Événements à venir */}
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1 h-full"> <div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100">
<h2 className="text-lg font-semibold mb-4">{t('upcomingEvents')}</h2> <h2 className="text-lg font-semibold mb-4">{t('upcomingEvents')}</h2>
{upcomingEvents.map((event, index) => ( {upcomingEvents.map((event, index) => (
<EventCard key={index} {...event} /> <EventCard key={index} {...event} />
))} ))}
</div> </div>
</div> </div>
{/* Ajout du composant Attendance en dessous, en lecture seule */}
<Attendance absences={absencesToday} readOnly={true} />
</div> </div>
); );
} }

View File

@ -16,17 +16,19 @@ import { useNotification } from '@/context/NotificationContext';
import { useSearchParams } from 'next/navigation'; // Ajoute cet import import { useSearchParams } from 'next/navigation'; // Ajoute cet import
export default function SettingsPage() { export default function SettingsPage() {
const [activeTab, setActiveTab] = useState('smtp'); const [activeTab, setActiveTab] = useState('structure');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [smtpServer, setSmtpServer] = useState(''); const [smtpServer, setSmtpServer] = useState('');
const [imapServer, setImapServer] = useState('');
const [smtpPort, setSmtpPort] = useState(''); const [smtpPort, setSmtpPort] = useState('');
const [smtpUser, setSmtpUser] = useState(''); const [imapPort, setImapPort] = useState('');
const [smtpPassword, setSmtpPassword] = useState(''); const [mailUser, setMailUser] = useState('');
const [mailPassword, setMailPassword] = useState('');
const [useTls, setUseTls] = useState(true); const [useTls, setUseTls] = useState(true);
const [useSsl, setUseSsl] = useState(false); const [useSsl, setUseSsl] = useState(false);
const { selectedEstablishmentId } = useEstablishment(); const { user, selectedEstablishmentId } = useEstablishment();
const csrfToken = useCsrfToken(); // Récupération du csrfToken const csrfToken = useCsrfToken(); // Récupération du csrfToken
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -40,6 +42,8 @@ export default function SettingsPage() {
const tabParam = searchParams.get('tab'); const tabParam = searchParams.get('tab');
if (tabParam === 'smtp') { if (tabParam === 'smtp') {
setActiveTab('smtp'); setActiveTab('smtp');
} else if (tabParam === 'structure') {
setActiveTab('structure');
} }
}, [searchParams]); }, [searchParams]);
@ -50,8 +54,10 @@ export default function SettingsPage() {
.then((data) => { .then((data) => {
setSmtpServer(data.smtp_server || ''); setSmtpServer(data.smtp_server || '');
setSmtpPort(data.smtp_port || ''); setSmtpPort(data.smtp_port || '');
setSmtpUser(data.smtp_user || ''); setImapServer(data.imap_server || '');
setSmtpPassword(data.smtp_password || ''); setImapPort(data.imap_port || '');
setMailUser(data.mail_user || '');
setMailPassword(data.mail_password || '');
setUseTls(data.use_tls || false); setUseTls(data.use_tls || false);
setUseSsl(data.use_ssl || false); setUseSsl(data.use_ssl || false);
}) })
@ -77,30 +83,73 @@ export default function SettingsPage() {
} }
}, [activeTab, csrfToken]); // Ajouter csrfToken comme dépendance }, [activeTab, csrfToken]); // Ajouter csrfToken comme dépendance
const handleEmailChange = (e) => {
setEmail(e.target.value);
};
const handlePasswordChange = (e) => {
setPassword(e.target.value);
};
const handleConfirmPasswordChange = (e) => {
setConfirmPassword(e.target.value);
};
const handleSmtpServerChange = (e) => { const handleSmtpServerChange = (e) => {
setSmtpServer(e.target.value); setSmtpServer(e.target.value);
}; };
const handleImapServerChange = (e) => {
setImapServer(e.target.value);
};
const handleSmtpPortChange = (e) => { const handleSmtpPortChange = (e) => {
setSmtpPort(e.target.value); setSmtpPort(e.target.value);
}; };
const handleSmtpUserChange = (e) => { const handleImapPortChange = (e) => {
setSmtpUser(e.target.value); setImapPort(e.target.value);
}; };
const handleSmtpPasswordChange = (e) => { const handleMailUserChange = (e) => {
setSmtpPassword(e.target.value); setMailUser(e.target.value);
};
const handleMailPasswordChange = (e) => {
setMailPassword(e.target.value);
};
const handleUseTlsChange = (e) => {
setUseTls(e.target.checked);
};
const handleUseSslChange = (e) => {
setUseSsl(e.target.checked);
};
const handleSubmit = (e) => {
e.preventDefault();
if (password !== confirmPassword) {
showNotification(
'Les mots de passe ne correspondent pas',
'error',
'Erreur'
);
return;
}
}; };
const handleSmtpSubmit = (e) => { const handleSmtpSubmit = (e) => {
e.preventDefault(); e.preventDefault();
const smtpData = { const smtpData = {
establishment: selectedEstablishmentId, establishment: selectedEstablishmentId,
profile_id: user.user_id,
smtp_server: smtpServer, smtp_server: smtpServer,
smtp_port: smtpPort, smtp_port: smtpPort,
smtp_user: smtpUser, imap_server: imapServer,
smtp_password: smtpPassword, imap_port: imapPort,
mail_user: mailUser,
mail_password: mailPassword,
use_tls: useTls, use_tls: useTls,
use_ssl: useSsl, use_ssl: useSsl,
}; };
@ -130,6 +179,11 @@ export default function SettingsPage() {
return ( return (
<div className="p-8"> <div className="p-8">
<div className="flex space-x-4 mb-4"> <div className="flex space-x-4 mb-4">
<Tab
text="Informations de la structure"
active={activeTab === 'structure'}
onClick={() => handleTabClick('structure')}
/>
<Tab <Tab
text="Paramètres SMTP" text="Paramètres SMTP"
active={activeTab === 'smtp'} active={activeTab === 'smtp'}
@ -137,6 +191,28 @@ export default function SettingsPage() {
/> />
</div> </div>
<div className="mt-4"> <div className="mt-4">
<TabContent isActive={activeTab === 'structure'}>
<form onSubmit={handleSubmit}>
<InputText
label="Email"
value={email}
onChange={handleEmailChange}
/>
<InputText
label="Mot de passe"
type="password"
value={password}
onChange={handlePasswordChange}
/>
<InputText
label="Confirmer le mot de passe"
type="password"
value={confirmPassword}
onChange={handleConfirmPasswordChange}
/>
<Button type="submit" primary text="Mettre à jour"></Button>
</form>
</TabContent>
<TabContent isActive={activeTab === 'smtp'}> <TabContent isActive={activeTab === 'smtp'}>
<form onSubmit={handleSmtpSubmit}> <form onSubmit={handleSmtpSubmit}>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@ -151,15 +227,25 @@ export default function SettingsPage() {
onChange={handleSmtpPortChange} onChange={handleSmtpPortChange}
/> />
<InputText <InputText
label="Utilisateur SMTP" label="Serveur IMAP"
value={smtpUser} value={imapServer}
onChange={handleSmtpUserChange} onChange={handleImapServerChange}
/> />
<InputText <InputText
label="Mot de passe SMTP" label="Port IMAP"
value={imapPort}
onChange={handleImapPortChange}
/>
<InputText
label="Adresse mail"
value={mailUser}
onChange={handleMailUserChange}
/>
<InputText
label="Mot de passe"
type="password" type="password"
value={smtpPassword} value={mailPassword}
onChange={handleSmtpPasswordChange} onChange={handleMailPasswordChange}
/> />
</div> </div>
<div className="mt-6 border-t pt-4"> <div className="mt-6 border-t pt-4">

View File

@ -52,7 +52,7 @@ export default function Page() {
); );
const csrfToken = useCsrfToken(); const csrfToken = useCsrfToken();
const { selectedEstablishmentId, apiDocuseal } = useEstablishment(); const { selectedEstablishmentId } = useEstablishment();
useEffect(() => { useEffect(() => {
if (selectedEstablishmentId) { if (selectedEstablishmentId) {
@ -78,7 +78,7 @@ export default function Page() {
handleTuitionFees(); handleTuitionFees();
// Fetch data for registration file schoolFileTemplates // Fetch data for registration file schoolFileTemplates
fetchRegistrationSchoolFileMasters(selectedEstablishmentId) fetchRegistrationSchoolFileMasters()
.then((data) => { .then((data) => {
setFichiers(data); setFichiers(data);
}) })
@ -353,7 +353,6 @@ export default function Page() {
<FilesGroupsManagement <FilesGroupsManagement
csrfToken={csrfToken} csrfToken={csrfToken}
selectedEstablishmentId={selectedEstablishmentId} selectedEstablishmentId={selectedEstablishmentId}
apiDocuseal={apiDocuseal}
/> />
</div> </div>
), ),

View File

@ -96,7 +96,7 @@ export default function CreateSubscriptionPage() {
const { getNiveauLabel } = useClasses(); const { getNiveauLabel } = useClasses();
const formDataRef = useRef(formData); const formDataRef = useRef(formData);
const { selectedEstablishmentId, apiDocuseal } = useEstablishment(); const { selectedEstablishmentId } = useEstablishment();
const csrfToken = useCsrfToken(); const csrfToken = useCsrfToken();
const router = useRouter(); const router = useRouter();
@ -313,13 +313,13 @@ export default function CreateSubscriptionPage() {
}) })
.catch(requestErrorHandler); .catch(requestErrorHandler);
fetchRegistrationSchoolFileMasters(selectedEstablishmentId) fetchRegistrationSchoolFileMasters()
.then((data) => { .then((data) => {
setSchoolFileMasters(data); setSchoolFileMasters(data);
}) })
.catch(requestErrorHandler); .catch(requestErrorHandler);
fetchRegistrationParentFileMasters(selectedEstablishmentId) fetchRegistrationParentFileMasters()
.then((data) => { .then((data) => {
setParentFileMasters(data); setParentFileMasters(data);
}) })
@ -473,6 +473,8 @@ export default function CreateSubscriptionPage() {
} }
})(); })();
logger.debug('test : ', guardians);
const data = { const data = {
student: { student: {
last_name: formDataRef.current.studentLastName, last_name: formDataRef.current.studentLastName,
@ -502,11 +504,7 @@ export default function CreateSubscriptionPage() {
// Mode édition // Mode édition
editRegisterForm(registerFormID, formData, csrfToken) editRegisterForm(registerFormID, formData, csrfToken)
.then((response) => { .then((response) => {
showNotification( logger.debug('Dossier mis à jour avec succès:', response);
"Dossier d'inscription mis à jour avec succès",
'success',
'Succès'
);
router.push(FE_ADMIN_SUBSCRIPTIONS_URL); router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
}) })
.catch((error) => { .catch((error) => {
@ -534,14 +532,12 @@ export default function CreateSubscriptionPage() {
const clonePromises = masters.map((templateMaster) => const clonePromises = masters.map((templateMaster) =>
cloneTemplate( cloneTemplate(
templateMaster.id, templateMaster.id,
formDataRef.current.guardianEmail, formData.guardianEmail,
templateMaster.is_required, templateMaster.is_required
selectedEstablishmentId,
apiDocuseal
) )
.then((clonedDocument) => { .then((clonedDocument) => {
const cloneData = { const cloneData = {
name: `${templateMaster.name}_${formDataRef.current.studentFirstName}_${formDataRef.current.studentLastName}`, name: `${templateMaster.name}_${formData.studentFirstName}_${formData.studentLastName}`,
slug: clonedDocument.slug, slug: clonedDocument.slug,
id: clonedDocument.id, id: clonedDocument.id,
master: templateMaster.id, master: templateMaster.id,
@ -617,11 +613,6 @@ export default function CreateSubscriptionPage() {
Promise.all([...clonePromises, ...parentClonePromises]) Promise.all([...clonePromises, ...parentClonePromises])
.then(() => { .then(() => {
// Redirection après succès // Redirection après succès
showNotification(
"Dossier d'inscription créé avec succès",
'success',
'Succès'
);
router.push(FE_ADMIN_SUBSCRIPTIONS_URL); router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
}) })
.catch((error) => { .catch((error) => {
@ -664,7 +655,6 @@ export default function CreateSubscriptionPage() {
return { return {
...prevData, ...prevData,
selectedGuardians: updatedSelectedGuardians, selectedGuardians: updatedSelectedGuardians,
guardianEmail: guardian.associated_profile_email,
}; };
}); });
}; };

View File

@ -8,18 +8,16 @@ import { useEstablishment } from '@/context/EstablishmentContext';
import { editRegisterForm } from '@/app/actions/subscriptionAction'; import { editRegisterForm } from '@/app/actions/subscriptionAction';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import Loader from '@/components/Loader'; import Loader from '@/components/Loader';
import { useNotification } from '@/context/NotificationContext';
export default function Page() { export default function Page() {
const router = useRouter(); const router = useRouter();
const { showNotification } = useNotification();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const studentId = searchParams.get('studentId'); const studentId = searchParams.get('studentId');
const enable = searchParams.get('enabled') === 'true'; const enable = searchParams.get('enabled') === 'true';
const [formErrors, setFormErrors] = useState({}); const [formErrors, setFormErrors] = useState({});
const csrfToken = useCsrfToken(); const csrfToken = useCsrfToken();
const { selectedEstablishmentId, apiDocuseal } = useEstablishment(); const { selectedEstablishmentId } = useEstablishment();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const handleSubmit = (data) => { const handleSubmit = (data) => {
@ -28,21 +26,11 @@ export default function Page() {
.then((result) => { .then((result) => {
setIsLoading(false); setIsLoading(false);
logger.debug('Success:', result); logger.debug('Success:', result);
showNotification(
"Dossier d'inscription soumis avec succès",
'success',
'Succès'
);
router.push(FE_ADMIN_SUBSCRIPTIONS_URL); router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
}) })
.catch((error) => { .catch((error) => {
setIsLoading(false); setIsLoading(false);
logger.error('Error:', error.message); logger.error('Error:', error.message);
showNotification(
"Erreur lors de la soumission du dossier d'inscription",
'error',
'Erreur'
);
if (error.details) { if (error.details) {
logger.error('Form errors:', error.details); logger.error('Form errors:', error.details);
setFormErrors(error.details); setFormErrors(error.details);
@ -59,7 +47,6 @@ export default function Page() {
studentId={studentId} studentId={studentId}
csrfToken={csrfToken} csrfToken={csrfToken}
selectedEstablishmentId={selectedEstablishmentId} selectedEstablishmentId={selectedEstablishmentId}
apiDocuseal = {apiDocuseal}
onSubmit={handleSubmit} onSubmit={handleSubmit}
cancelUrl={FE_ADMIN_SUBSCRIPTIONS_URL} cancelUrl={FE_ADMIN_SUBSCRIPTIONS_URL}
errors={formErrors} errors={formErrors}

View File

@ -52,7 +52,6 @@ import {
HISTORICAL_FILTER, HISTORICAL_FILTER,
} from '@/utils/constants'; } from '@/utils/constants';
import AlertMessage from '@/components/AlertMessage'; import AlertMessage from '@/components/AlertMessage';
import { useNotification } from '@/context/NotificationContext';
export default function Page({ params: { locale } }) { export default function Page({ params: { locale } }) {
const t = useTranslations('subscriptions'); const t = useTranslations('subscriptions');
@ -92,6 +91,8 @@ export default function Page({ params: { locale } }) {
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
const [selectedRegisterForm, setSelectedRegisterForm] = useState([]); const [selectedRegisterForm, setSelectedRegisterForm] = useState([]);
const [popupVisible, setPopupVisible] = useState(false);
const [popupMessage, setPopupMessage] = useState('');
const [confirmPopupVisible, setConfirmPopupVisible] = useState(false); const [confirmPopupVisible, setConfirmPopupVisible] = useState(false);
const [confirmPopupMessage, setConfirmPopupMessage] = useState(''); const [confirmPopupMessage, setConfirmPopupMessage] = useState('');
const [confirmPopupOnConfirm, setConfirmPopupOnConfirm] = useState(() => {}); const [confirmPopupOnConfirm, setConfirmPopupOnConfirm] = useState(() => {});
@ -102,7 +103,6 @@ export default function Page({ params: { locale } }) {
const csrfToken = useCsrfToken(); const csrfToken = useCsrfToken();
const router = useRouter(); const router = useRouter();
const { selectedEstablishmentId } = useEstablishment(); const { selectedEstablishmentId } = useEstablishment();
const { showNotification } = useNotification();
const openSepaUploadModal = (row) => { const openSepaUploadModal = (row) => {
setSelectedRowForUpload(row); setSelectedRowForUpload(row);
@ -133,8 +133,9 @@ export default function Page({ params: { locale } }) {
const registerFormCurrrentYearDataHandler = (data) => { const registerFormCurrrentYearDataHandler = (data) => {
if (data) { if (data) {
const { registerForms, count, page_size } = data; const { registerForms, count, page_size } = data;
if (registerForms) {
setRegistrationFormsDataCurrentYear(registerForms); setRegistrationFormsDataCurrentYear(registerForms);
}
const calculatedTotalPages = const calculatedTotalPages =
count === 0 ? count : Math.ceil(count / page_size); count === 0 ? count : Math.ceil(count / page_size);
setTotalCurrentYear(count); setTotalCurrentYear(count);
@ -152,8 +153,9 @@ export default function Page({ params: { locale } }) {
const registerFormNextYearDataHandler = (data) => { const registerFormNextYearDataHandler = (data) => {
if (data) { if (data) {
const { registerForms, count, page_size } = data; const { registerForms, count, page_size } = data;
if (registerForms) {
setRegistrationFormsDataNextYear(registerForms); setRegistrationFormsDataNextYear(registerForms);
}
const calculatedTotalPages = const calculatedTotalPages =
count === 0 ? count : Math.ceil(count / page_size); count === 0 ? count : Math.ceil(count / page_size);
setTotalNextYear(count); setTotalNextYear(count);
@ -171,7 +173,9 @@ export default function Page({ params: { locale } }) {
const registerFormHistoricalDataHandler = (data) => { const registerFormHistoricalDataHandler = (data) => {
if (data) { if (data) {
const { registerForms, count, page_size } = data; const { registerForms, count, page_size } = data;
if (registerForms) {
setRegistrationFormsDataHistorical(registerForms); setRegistrationFormsDataHistorical(registerForms);
}
const calculatedTotalPages = const calculatedTotalPages =
count === 0 ? count : Math.ceil(count / page_size); count === 0 ? count : Math.ceil(count / page_size);
@ -221,11 +225,12 @@ export default function Page({ params: { locale } }) {
fetchDataAndSetState(); fetchDataAndSetState();
} }
}, [selectedEstablishmentId, reloadFetch]); }, [selectedEstablishmentId, reloadFetch, currentSchoolYearPage, searchTerm]);
useEffect(() => { useEffect(() => {
if (selectedEstablishmentId) { if (selectedEstablishmentId) {
const fetchDataAndSetState = () => { const fetchDataAndSetState = () => {
setIsLoading(true);
fetchRegisterForms( fetchRegisterForms(
selectedEstablishmentId, selectedEstablishmentId,
CURRENT_YEAR_FILTER, CURRENT_YEAR_FILTER,
@ -242,6 +247,7 @@ export default function Page({ params: { locale } }) {
.then(registerFormHistoricalDataHandler) .then(registerFormHistoricalDataHandler)
.catch(requestErrorHandler); .catch(requestErrorHandler);
setIsLoading(false);
setReloadFetch(false); setReloadFetch(false);
}; };
@ -250,7 +256,7 @@ export default function Page({ params: { locale } }) {
}, 500); // Debounce la recherche }, 500); // Debounce la recherche
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
} }
}, [searchTerm, selectedEstablishmentId, currentSchoolYearPage, itemsPerPage]); }, [searchTerm]);
/** /**
* UseEffect to update page count of tab * UseEffect to update page count of tab
@ -288,21 +294,15 @@ export default function Page({ params: { locale } }) {
editRegisterForm(row.student.id, formData, csrfToken) editRegisterForm(row.student.id, formData, csrfToken)
.then((response) => { .then((response) => {
logger.debug('Mandat SEPA uploadé avec succès :', response); logger.debug('Mandat SEPA uploadé avec succès :', response);
showNotification( setPopupMessage('Le mandat SEPA a été uploadé avec succès.');
'Le mandat SEPA a été uploadé avec succès.', setPopupVisible(true);
'success',
'Succès'
);
setReloadFetch(true); setReloadFetch(true);
closeSepaUploadModal(); closeSepaUploadModal();
}) })
.catch((error) => { .catch((error) => {
logger.error("Erreur lors de l'upload du mandat SEPA :", error); logger.error("Erreur lors de l'upload du mandat SEPA :", error);
showNotification( setPopupMessage("Erreur lors de l'upload du mandat SEPA.");
"Erreur lors de l'upload du mandat SEPA.", setPopupVisible(true);
'error',
'Erreur'
);
}); });
}; };
@ -321,22 +321,21 @@ export default function Page({ params: { locale } }) {
archiveRegisterForm(id) archiveRegisterForm(id)
.then((data) => { .then((data) => {
logger.debug('Success:', data); logger.debug('Success:', data);
showNotification( setPopupMessage(
"Le dossier d'inscription a été correctement archivé", "Le dossier d'inscription a été correctement archivé"
'success',
'Succès'
); );
setPopupVisible(true);
setRegistrationForms( setRegistrationForms(
registrationForms.filter((fiche) => fiche.id !== id) registrationForms.filter((fiche) => fiche.id !== id)
); );
setReloadFetch(true); setReloadFetch(true);
}) })
.catch((error) => { .catch((error) => {
showNotification( logger.error('Error archiving data:', error);
"Erreur lors de l'archivage du dossier d'inscription", setPopupMessage(
'error', "Erreur lors de l'archivage du dossier d'inscription.\nContactez l'administrateur."
'Erreur'
); );
setPopupVisible(true);
}); });
setConfirmPopupVisible(false); setConfirmPopupVisible(false);
}); });
@ -351,20 +350,16 @@ export default function Page({ params: { locale } }) {
sendRegisterForm(id) sendRegisterForm(id)
.then((data) => { .then((data) => {
logger.debug('Success:', data); logger.debug('Success:', data);
showNotification( setPopupMessage("Le dossier d'inscription a été envoyé avec succès");
"Le dossier d'inscription a été envoyé avec succès", setPopupVisible(true);
'success',
'Succès'
);
setReloadFetch(true); setReloadFetch(true);
}) })
.catch((error) => { .catch((error) => {
logger.error('Error archiving data:', error); logger.error('Error archiving data:', error);
showNotification( setPopupMessage(
"Erreur lors de l'envoi du dossier d'inscription", "Erreur lors de l'envoi du dossier d'inscription.\nContactez l'administrateur."
'error',
'Erreur'
); );
setPopupVisible(true);
}); });
setConfirmPopupVisible(false); setConfirmPopupVisible(false);
}); });
@ -699,7 +694,7 @@ export default function Page({ params: { locale } }) {
]; ];
let emptyMessage; let emptyMessage;
if (activeTab === CURRENT_YEAR_FILTER && searchTerm === '') { if (activeTab === CURRENT_YEAR_FILTER) {
emptyMessage = ( emptyMessage = (
<AlertMessage <AlertMessage
type="warning" type="warning"
@ -707,7 +702,7 @@ export default function Page({ params: { locale } }) {
message="Veuillez procéder à la création d'un nouveau dossier d'inscription pour l'année scolaire 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 === '') { } else if (activeTab === NEXT_YEAR_FILTER) {
emptyMessage = ( emptyMessage = (
<AlertMessage <AlertMessage
type="info" type="info"
@ -715,7 +710,7 @@ export default function Page({ params: { locale } }) {
message="Aucun dossier n'a encore été créé pour la prochaine année scolaire." message="Aucun dossier n'a encore été créé pour la prochaine année scolaire."
/> />
); );
} else if (activeTab === HISTORICAL_FILTER && searchTerm === '') { } else if (activeTab === HISTORICAL_FILTER) {
emptyMessage = ( emptyMessage = (
<AlertMessage <AlertMessage
type="info" type="info"
@ -841,6 +836,12 @@ export default function Page({ params: { locale } }) {
</React.Fragment> </React.Fragment>
) : null} ) : null}
</div> </div>
<Popup
isOpen={popupVisible}
message={popupMessage}
onConfirm={() => setPopupVisible(false)}
uniqueConfirmButton={true}
/>
<Popup <Popup
isOpen={confirmPopupVisible} isOpen={confirmPopupVisible}
message={confirmPopupMessage} message={confirmPopupMessage}

View File

@ -9,7 +9,6 @@ import logger from '@/utils/logger';
import Loader from '@/components/Loader'; import Loader from '@/components/Loader';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url'; import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url';
import { useNotification } from '@/context/NotificationContext';
export default function Page() { export default function Page() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -31,7 +30,6 @@ export default function Page() {
const csrfToken = useCsrfToken(); const csrfToken = useCsrfToken();
const { selectedEstablishmentId } = useEstablishment(); const { selectedEstablishmentId } = useEstablishment();
const { showNotification } = useNotification();
const requestErrorHandler = (err) => { const requestErrorHandler = (err) => {
logger.error('Error fetching data:', err); logger.error('Error fetching data:', err);
@ -65,20 +63,11 @@ export default function Page() {
editRegisterForm(studentId, formData, csrfToken) editRegisterForm(studentId, formData, csrfToken)
.then((response) => { .then((response) => {
logger.debug('RF mis à jour avec succès:', response); logger.debug('RF mis à jour avec succès:', response);
showNotification(
'Le dossier d\'inscription a été validé avec succès',
'success',
'Succès'
);
router.push(FE_ADMIN_SUBSCRIPTIONS_URL); router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
setIsLoading(false); setIsLoading(false);
// Logique supplémentaire après la mise à jour (par exemple, redirection ou notification)
}) })
.catch((error) => { .catch((error) => {
showNotification(
"Erreur lors de la validation du dossier d'inscription",
'error',
'Erreur'
);
setIsLoading(false); setIsLoading(false);
logger.error('Erreur lors de la mise à jour du RF:', error); logger.error('Erreur lors de la mise à jour du RF:', error);
}); });

View File

@ -7,53 +7,30 @@ import { useEstablishment } from '@/context/EstablishmentContext';
import { FE_PARENTS_HOME_URL } from '@/utils/Url'; import { FE_PARENTS_HOME_URL } from '@/utils/Url';
import { editRegisterForm } from '@/app/actions/subscriptionAction'; import { editRegisterForm } from '@/app/actions/subscriptionAction';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import Loader from '@/components/Loader';
import { useNotification } from '@/context/NotificationContext';
export default function Page() { export default function Page() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { showNotification } = useNotification();
const studentId = searchParams.get('studentId'); const studentId = searchParams.get('studentId');
const enable = searchParams.get('enabled') === 'true'; const enable = searchParams.get('enabled') === 'true';
const router = useRouter(); const router = useRouter();
const csrfToken = useCsrfToken(); const csrfToken = useCsrfToken();
const { selectedEstablishmentId, apiDocuseal } = useEstablishment(); const { selectedEstablishmentId } = useEstablishment();
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = (data) => { const handleSubmit = async (data) => {
setIsLoading(true); try {
editRegisterForm(studentId, data, csrfToken) const result = await editRegisterForm(studentId, data, csrfToken);
.then((result) => {
setIsLoading(false);
logger.debug('Success:', result); logger.debug('Success:', result);
showNotification(
"Dossier d'inscription soumis avec succès",
'success',
'Succès'
);
router.push(FE_PARENTS_HOME_URL); router.push(FE_PARENTS_HOME_URL);
}) } catch (error) {
.catch((error) => {
setIsLoading(false);
showNotification(
"Erreur lors de la soumission du dossier d'inscription",
'error',
'Erreur'
);
logger.error('Error:', error); logger.error('Error:', error);
});
};
if (isLoading === true) {
return <Loader />; // Affichez le composant Loader
} }
};
return ( return (
<InscriptionFormShared <InscriptionFormShared
studentId={studentId} studentId={studentId}
csrfToken={csrfToken} csrfToken={csrfToken}
selectedEstablishmentId={selectedEstablishmentId} selectedEstablishmentId={selectedEstablishmentId}
apiDocuseal = {apiDocuseal}
onSubmit={handleSubmit} onSubmit={handleSubmit}
cancelUrl={FE_PARENTS_HOME_URL} cancelUrl={FE_PARENTS_HOME_URL}
enable={enable} enable={enable}

View File

@ -1,57 +1,31 @@
'use client'; 'use client';
// src/components/Layout.js // src/components/Layout.js
import React, { useState, useEffect } from 'react'; import React, { useState } from 'react';
import Sidebar from '@/components/Sidebar'; import ProfileSelector from '@/components/ProfileSelector';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter } from 'next/navigation'; // Ajout de l'importation
import { MessageSquare, Settings, Home, Menu } from 'lucide-react'; import { MessageSquare, LogOut, Settings, Home } from 'lucide-react'; // Ajout de l'importation de l'icône Home
import { import {
FE_PARENTS_HOME_URL, FE_PARENTS_HOME_URL,
FE_PARENTS_MESSAGERIE_URL FE_PARENTS_MESSAGERIE_URL,
} from '@/utils/Url'; FE_PARENTS_SETTINGS_URL,
} from '@/utils/Url'; // Ajout de l'importation de l'URL de la page d'accueil parent
import ProtectedRoute from '@/components/ProtectedRoute'; import ProtectedRoute from '@/components/ProtectedRoute';
import { disconnect } from '@/app/actions/authAction'; import { disconnect } from '@/app/actions/authAction';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
import { RIGHTS } from '@/utils/rights'; import logger from '@/utils/logger';
import { getRightStr, RIGHTS } from '@/utils/rights';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
export default function Layout({ children }) { export default function Layout({ children }) {
const router = useRouter(); const router = useRouter(); // Définition de router
const pathname = usePathname(); const [messages, setMessages] = useState([]);
const [isPopupVisible, setIsPopupVisible] = useState(false); const [isPopupVisible, setIsPopupVisible] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const { profileRole, user, clearContext } = useEstablishment();
const { clearContext } = useEstablishment();
const softwareName = 'N3WT School'; const softwareName = 'N3WT School';
const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`; const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`;
// Vérifier si on est sur la page messagerie
const isMessagingPage = pathname?.includes('/messagerie');
// Configuration des éléments de la sidebar pour les parents
const sidebarItems = [
{
id: 'home',
name: 'Accueil',
url: FE_PARENTS_HOME_URL,
icon: Home,
},
{
id: 'messagerie',
name: 'Messagerie',
url: FE_PARENTS_MESSAGERIE_URL,
icon: MessageSquare,
}
];
// Déterminer la page actuelle pour la sidebar
const getCurrentPage = () => {
if (pathname?.includes('/messagerie')) return 'messagerie';
if (pathname?.includes('/settings')) return 'settings';
return 'home';
};
const currentPage = getCurrentPage();
const handleDisconnect = () => { const handleDisconnect = () => {
setIsPopupVisible(true); setIsPopupVisible(true);
}; };
@ -61,63 +35,52 @@ export default function Layout({ children }) {
disconnect(); disconnect();
clearContext(); clearContext();
}; };
const toggleSidebar = () => {
setIsSidebarOpen(!isSidebarOpen);
};
useEffect(() => {
// Fermer la sidebar quand on change de page sur mobile
setIsSidebarOpen(false);
}, [pathname]);
return ( return (
<ProtectedRoute requiredRight={RIGHTS.PARENT}> <ProtectedRoute requiredRight={RIGHTS.PARENT}>
{/* Bouton hamburger pour mobile */} <div className="flex flex-col min-h-screen bg-gray-50">
<button {/* Entête */}
onClick={toggleSidebar} <header className="h-16 bg-white border-b border-gray-200 px-4 md:px-8 py-4 flex items-center justify-between fixed top-0 left-0 right-0 z-10">
className="fixed top-4 left-4 z-40 p-2 rounded-md bg-white shadow-lg border border-gray-200 md:hidden" <div className="flex items-center space-x-2">
> {/* Suppression du menu profil parent */}
<Menu size={20} />
</button>
{/* Sidebar */} <div className="text-lg md:text-xl p-2 font-semibold">Accueil</div>
<div
className={`absolute 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
currentPage={currentPage}
items={sidebarItems}
onCloseMobile={toggleSidebar}
/>
</div> </div>
<div className="flex items-center space-x-2 md:space-x-4">
{/* Overlay for mobile */} <button
{isSidebarOpen && ( className="p-1 md:p-2 rounded-full hover:bg-gray-200"
<div onClick={() => {
className="fixed inset-0 bg-black bg-opacity-50 z-20 md:hidden" router.push(FE_PARENTS_HOME_URL);
onClick={toggleSidebar} }}
/>
)}
{/* Main container */}
<div
className={`absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-0 bottom-16 left-0 md:left-64 right-0 ${!isMessagingPage ? 'p-4 md:p-8' : ''}`}
> >
<Home className="h-5 w-5 md:h-6 md:w-6" />
</button>
<div className="relative">
<button
className="p-1 md:p-2 rounded-full hover:bg-gray-200"
onClick={() => {
router.push(FE_PARENTS_MESSAGERIE_URL);
}}
>
<MessageSquare className="h-5 w-5 md:h-6 md:w-6" />
</button>
{messages.length > 0 && (
<span className="absolute top-0 right-0 block h-2 w-2 rounded-full bg-emerald-600"></span>
)}
</div>
<ProfileSelector className="w-64 border-b border-gray-200 " />
{/* Suppression du DropdownMenu profil parent */}
</div>
</header>
{/* Content */}
<div className="pt-16 md:pt-20 p-4 md:p-8 flex-1">
{' '}
{/* Ajout de flex-1 pour utiliser toute la hauteur disponible */}
{children} {children}
</div> </div>
{/* Footer responsive */}
{/* Footer */}
<Footer softwareName={softwareName} softwareVersion={softwareVersion} /> <Footer softwareName={softwareName} softwareVersion={softwareVersion} />
</div>
<Popup
isOpen={isPopupVisible}
message="Êtes-vous sûr(e) de vouloir vous déconnecter ?"
onConfirm={confirmDisconnect}
onCancel={() => setIsPopupVisible(false)}
/>
</ProtectedRoute> </ProtectedRoute>
); );
} }

View File

@ -1,28 +1,15 @@
'use client'; 'use client';
import React from 'react'; import React, { useEffect, useState } from 'react';
import InstantChat from '@/components/Chat/InstantChat'; import Chat from '@/components/Chat';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
export default function MessageriePage() { export default function MessageriePage() {
const { user, selectedEstablishmentId } = useEstablishment(); const { user, selectedEstablishmentId } = useEstablishment();
if (!user?.user_id || !selectedEstablishmentId) { if (!user) return <div>Chargement...</div>;
return (
<div className="flex items-center justify-center h-full w-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement de la messagerie...</p>
</div>
</div>
);
}
return ( return (
<div className="h-full flex flex-col"> <Chat userProfileId={user.id} establishmentId={selectedEstablishmentId} />
<InstantChat
userProfileId={user.user_id}
establishmentId={selectedEstablishmentId}
/>
</div>
); );
} }

View File

@ -2,14 +2,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Table from '@/components/Table'; import Table from '@/components/Table';
import { import { Edit3, Users, Download, Eye, Upload } from 'lucide-react';
Edit3,
Users,
Download,
Eye,
Upload,
CalendarDays,
} from 'lucide-react';
import StatusLabel from '@/components/StatusLabel'; import StatusLabel from '@/components/StatusLabel';
import FileUpload from '@/components/FileUpload'; import FileUpload from '@/components/FileUpload';
import { FE_PARENTS_EDIT_SUBSCRIPTION_URL } from '@/utils/Url'; import { FE_PARENTS_EDIT_SUBSCRIPTION_URL } from '@/utils/Url';
@ -17,16 +10,11 @@ import {
fetchChildren, fetchChildren,
editRegisterForm, editRegisterForm,
} from '@/app/actions/subscriptionAction'; } from '@/app/actions/subscriptionAction';
import { fetchUpcomingEvents } from '@/app/actions/planningAction';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { BASE_URL } from '@/utils/Url'; import { BASE_URL } from '@/utils/Url';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { useCsrfToken } from '@/context/CsrfContext'; import { useCsrfToken } from '@/context/CsrfContext';
import { useClasses } from '@/context/ClassesContext'; import { useClasses } from '@/context/ClassesContext';
import { PlanningProvider, PlanningModes } from '@/context/PlanningContext';
import SectionHeader from '@/components/SectionHeader';
import ParentPlanningSection from '@/components/ParentPlanningSection';
import EventCard from '@/components/EventCard';
export default function ParentHomePage() { export default function ParentHomePage() {
const [children, setChildren] = useState([]); const [children, setChildren] = useState([]);
@ -34,9 +22,6 @@ export default function ParentHomePage() {
const [uploadingStudentId, setUploadingStudentId] = useState(null); // ID de l'étudiant pour l'upload const [uploadingStudentId, setUploadingStudentId] = useState(null); // ID de l'étudiant pour l'upload
const [uploadedFile, setUploadedFile] = useState(null); // Fichier uploadé const [uploadedFile, setUploadedFile] = useState(null); // Fichier uploadé
const [uploadState, setUploadState] = useState('off'); // État "on" ou "off" pour l'affichage du composant const [uploadState, setUploadState] = useState('off'); // État "on" ou "off" pour l'affichage du composant
const [showPlanning, setShowPlanning] = useState(false);
const [planningClassName, setPlanningClassName] = useState(null);
const [upcomingEvents, setUpcomingEvents] = useState([]);
const router = useRouter(); const router = useRouter();
const csrfToken = useCsrfToken(); const csrfToken = useCsrfToken();
const [reloadFetch, setReloadFetch] = useState(false); const [reloadFetch, setReloadFetch] = useState(false);
@ -50,20 +35,7 @@ export default function ParentHomePage() {
}); });
setReloadFetch(false); setReloadFetch(false);
} }
}, [selectedEstablishmentId, reloadFetch, user]); }, [selectedEstablishmentId, reloadFetch]);
useEffect(() => {
if (selectedEstablishmentId) {
// Fetch des événements à venir
fetchUpcomingEvents(selectedEstablishmentId)
.then((data) => {
setUpcomingEvents(data);
})
.catch((error) => {
logger.error('Error fetching upcoming events:', error);
});
}
}, [selectedEstablishmentId]);
function handleView(eleveId) { function handleView(eleveId) {
logger.debug(`View dossier for student id: ${eleveId}`); logger.debug(`View dossier for student id: ${eleveId}`);
@ -125,13 +97,6 @@ export default function ParentHomePage() {
} }
}; };
const showClassPlanning = (student) => {
setPlanningClassName(
`${student.associated_class_name} - ${getNiveauLabel(student.level)}`
);
setShowPlanning(true);
};
const childrenColumns = [ const childrenColumns = [
{ {
name: 'photo', name: 'photo',
@ -162,12 +127,6 @@ export default function ParentHomePage() {
}, },
{ name: 'Nom', transform: (row) => `${row.student.last_name}` }, { name: 'Nom', transform: (row) => `${row.student.last_name}` },
{ name: 'Prénom', transform: (row) => `${row.student.first_name}` }, { name: 'Prénom', transform: (row) => `${row.student.first_name}` },
{
name: 'Classe',
transform: (row) => (
<div className="text-center">{row.student.associated_class_name}</div>
),
},
{ {
name: 'Niveau', name: 'Niveau',
transform: (row) => ( transform: (row) => (
@ -233,6 +192,7 @@ export default function ParentHomePage() {
> >
<Download className="h-5 w-5" /> <Download className="h-5 w-5" />
</a> </a>
{/* Nouvelle action Upload */}
<button <button
className={`flex items-center justify-center w-8 h-8 rounded-full ${ className={`flex items-center justify-center w-8 h-8 rounded-full ${
uploadingStudentId === row.student.id && uploadState === 'on' uploadingStudentId === row.student.id && uploadState === 'on'
@ -241,7 +201,7 @@ export default function ParentHomePage() {
}`} }`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
toggleUpload(row.student.id); toggleUpload(row.student.id); // Activer ou désactiver l'upload pour cet étudiant
}} }}
aria-label="Uploader un fichier" aria-label="Uploader un fichier"
> >
@ -251,7 +211,6 @@ export default function ParentHomePage() {
)} )}
{row.status === 5 && ( {row.status === 5 && (
<>
<button <button
className="text-purple-500 hover:text-purple-700" className="text-purple-500 hover:text-purple-700"
onClick={(e) => { onClick={(e) => {
@ -262,17 +221,6 @@ export default function ParentHomePage() {
> >
<Eye className="h-5 w-5" /> <Eye className="h-5 w-5" />
</button> </button>
<button
className="text-emerald-500 hover:text-emerald-700 ml-1"
onClick={(e) => {
e.stopPropagation();
showClassPlanning(row.student);
}}
aria-label="Voir le planning de la classe"
>
<CalendarDays className="h-5 w-5" />
</button>
</>
)} )}
</div> </div>
), ),
@ -280,54 +228,18 @@ export default function ParentHomePage() {
]; ];
return ( return (
<div className="w-full h-full"> <div className="px-2 py-4 md:px-4 max-w-full">
{showPlanning && planningClassName ? (
// Affichage grand format mais respectant la sidebar
<>
<div className="p-4 flex items-center border-b">
<button
className="text-emerald-600 hover:text-emerald-800 font-semibold flex items-center"
onClick={() => setShowPlanning(false)}
>
Retour
</button>
</div>
<div className="flex-1 flex overflow-hidden">
<PlanningProvider
establishmentId={selectedEstablishmentId}
modeSet={PlanningModes.CLASS_SCHEDULE}
readOnly={true}
>
<ParentPlanningSection planningClassName={planningClassName} />
</PlanningProvider>
</div>
</>
) : (
// Affichage classique avec le tableau des enfants
<div> <div>
{/* Section des événements à venir */} <h2 className="text-xl font-semibold mb-3 px-1 flex items-center gap-2">
{upcomingEvents.length > 0 && ( <Users className="h-6 w-6 text-emerald-600" />
<div className="mb-6"> Enfants
<SectionHeader </h2>
icon={CalendarDays}
title="Événements à venir"
description="Prochains événements de l'établissement"
/>
<div className="bg-stone-50 p-4 rounded-lg shadow-sm border border-gray-100">
{upcomingEvents.slice(0, 3).map((event, index) => (
<EventCard key={index} {...event} />
))}
</div>
</div>
)}
<SectionHeader
icon={Users}
title="Vos enfants"
description="Suivez le parcours de vos enfants"
/>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table data={children} columns={childrenColumns} /> <Table
data={children}
columns={childrenColumns}
defaultTheme="bg-gray-50"
/>
</div> </div>
{/* Composant FileUpload et bouton Valider en dessous du tableau */} {/* Composant FileUpload et bouton Valider en dessous du tableau */}
{uploadState === 'on' && uploadingStudentId && ( {uploadState === 'on' && uploadingStudentId && (
@ -350,7 +262,6 @@ export default function ParentHomePage() {
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
); );
} }

View File

@ -14,18 +14,20 @@ import { useCsrfToken } from '@/context/CsrfContext'; // Importez le hook useCsr
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { useNotification } from '@/context/NotificationContext';
export default function Page() { export default function Page() {
const [errorMessage, setErrorMessage] = useState('');
const [userFieldError, setUserFieldError] = useState('');
const [passwordFieldError, setPasswordFieldError] = useState('');
const { initializeContextWithSession } = useEstablishment(); const { initializeContextWithSession } = useEstablishment();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const router = useRouter(); const router = useRouter();
const csrfToken = useCsrfToken(); // Utilisez le hook useCsrfToken const csrfToken = useCsrfToken(); // Utilisez le hook useCsrfToken
const { showNotification } = useNotification();
function handleFormLogin(formData) { function handleFormLogin(formData) {
setIsLoading(true); setIsLoading(true);
setErrorMessage('');
login({ login({
email: formData.get('login'), email: formData.get('login'),
@ -35,11 +37,7 @@ export default function Page() {
logger.debug('Sign In Result', result); logger.debug('Sign In Result', result);
if (result.error) { if (result.error) {
showNotification( setErrorMessage(result.error);
result.error,
'error',
'Erreur'
);
setIsLoading(false); setIsLoading(false);
} else { } else {
// On initialise le contexte establishement avec la session // On initialise le contexte establishement avec la session
@ -50,11 +48,7 @@ export default function Page() {
if (url) { if (url) {
router.push(url); router.push(url);
} else { } else {
showNotification( setErrorMessage('Type de rôle non géré');
'Type de rôle non géré',
'error',
'Erreur'
);
} }
}); });
setIsLoading(false); setIsLoading(false);
@ -65,10 +59,8 @@ export default function Page() {
error error
); );
setIsLoading(false); setIsLoading(false);
showNotification( setErrorMessage(
'Une erreur est survenue lors de la récupération de la session.', 'Une erreur est survenue lors de la récupération de la session.'
'error',
'Erreur'
); );
}); });
} }
@ -76,11 +68,7 @@ export default function Page() {
.catch((error) => { .catch((error) => {
logger.error('Erreur lors de la connexion:', error); logger.error('Erreur lors de la connexion:', error);
setIsLoading(false); setIsLoading(false);
showNotification( setErrorMessage('Une erreur est survenue lors de la connexion.');
'Une erreur est survenue lors de la connexion.',
'error',
'Erreur'
);
}); });
} }
@ -110,6 +98,7 @@ export default function Page() {
IconItem={User} IconItem={User}
label="Identifiant" label="Identifiant"
placeholder="Identifiant" placeholder="Identifiant"
errorMsg={userFieldError}
className="w-full mb-5" className="w-full mb-5"
/> />
<InputTextIcon <InputTextIcon
@ -118,9 +107,11 @@ export default function Page() {
IconItem={KeySquare} IconItem={KeySquare}
label="Mot de passe" label="Mot de passe"
placeholder="Mot de passe" placeholder="Mot de passe"
errorMsg={passwordFieldError}
className="w-full mb-5" className="w-full mb-5"
/> />
<div className="input-group mb-4"></div> <div className="input-group mb-4"></div>
<label className="text-red-500">{errorMessage}</label>
<label> <label>
<a <a
className="float-right mb-4" className="float-right mb-4"

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