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
104 changed files with 1363 additions and 13788 deletions

View File

@ -35,13 +35,6 @@ Corriger ou améliorer le projet N3WT-SCHOOL de manière minimaliste et fonction
## 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 unitaires obligatoires pour chaque nouvelle fonctionnalité

12
.vscode/tasks.json vendored
View File

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

2
Back-End/.gitignore vendored
View File

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

View File

@ -2,6 +2,7 @@ from django.contrib.auth.models import AbstractUser
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.core.validators import EmailValidator
from django_mailbox.models import Mailbox
class Profile(AbstractUser):
email = models.EmailField(max_length=255, unique=True, default="", validators=[EmailValidator()])
@ -11,9 +12,13 @@ class Profile(AbstractUser):
roleIndexLoginDefault = models.IntegerField(default=0)
code = models.CharField(max_length=200, default="", blank=True)
datePeremption = models.CharField(max_length=200, default="", blank=True)
def __str__(self):
return self.email
mailbox = models.OneToOneField(
Mailbox,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='profile'
)
class ProfileRole(models.Model):
class RoleType(models.IntegerChoices):
@ -22,7 +27,7 @@ class ProfileRole(models.Model):
PROFIL_ADMIN = 1, _('ADMIN')
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)
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='profile_roles')
is_active = models.BooleanField(default=False)
@ -30,11 +35,3 @@ class ProfileRole(models.Model):
def __str__(self):
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,7 +223,7 @@ def makeToken(user):
"""
try:
# Récupérer tous les rôles de l'utilisateur actifs
roles = ProfileRole.objects.filter(profile=user, is_active=True).values('role_type', 'establishment__id', 'establishment__name', 'establishment__evaluation_frequency', 'establishment__total_capacity', 'establishment__api_docuseal')
roles = ProfileRole.objects.filter(profile=user, is_active=True).values('role_type', 'establishment__id', 'establishment__name', 'establishment__evaluation_frequency', 'establishment__total_capacity')
# Générer le JWT avec la bonne syntaxe datetime
access_payload = {

View File

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

View File

@ -13,7 +13,7 @@ class EvaluationFrequency(models.IntegerChoices):
YEAR = 3, _("Année")
class Establishment(models.Model):
name = models.CharField(max_length=255)
name = models.CharField(max_length=255, unique=True)
address = models.CharField(max_length=255)
total_capacity = models.IntegerField()
establishment_type = ArrayField(models.IntegerField(choices=StructureType.choices))
@ -21,7 +21,6 @@ class Establishment(models.Model):
licence_code = models.CharField(max_length=100, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
api_docuseal = models.CharField(max_length=255, blank=True, null=True)
def __str__(self):
return self.name

View File

@ -9,8 +9,6 @@ from .serializers import EstablishmentSerializer
from N3wtSchool.bdd import delete_object, getAllObjects
from School.models import EstablishmentCompetency, Competency
from django.db.models import Q
from Auth.models import Profile, ProfileRole, Directeur
from Settings.models import SMTPSettings
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
@ -22,8 +20,9 @@ class EstablishmentListCreateView(APIView):
def post(self, request):
establishment_data = JSONParser().parse(request)
try:
establishment, data = create_establishment_with_directeur(establishment_data)
establishment_serializer = EstablishmentSerializer(data=establishment_data)
if establishment_serializer.is_valid():
establishment = establishment_serializer.save()
# Création des EstablishmentCompetency pour chaque compétence existante
competencies = Competency.objects.filter(
Q(end_of_cycle=True) | ~Q(level=None)
@ -34,9 +33,8 @@ class EstablishmentListCreateView(APIView):
competency=competency,
defaults={'is_required': True}
)
return JsonResponse(data, safe=False, status=status.HTTP_201_CREATED)
except Exception as e:
return JsonResponse({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
return JsonResponse(establishment_serializer.data, safe=False, status=status.HTTP_201_CREATED)
return JsonResponse(establishment_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
@ -63,55 +61,3 @@ class EstablishmentDetailView(APIView):
def delete(self, request, id):
return delete_object(Establishment, id)
def create_establishment_with_directeur(establishment_data):
# Extraction des sous-objets
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)
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": True}
)
# 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)
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
class GestionMessagerieConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
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,104 +1,6 @@
from django.db import models
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):
id = models.AutoField(primary_key=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 Auth.models import Profile
from GestionMessagerie.models import Messagerie, Conversation, ConversationParticipant, Message, MessageRead, UserPresence
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']
from GestionMessagerie.models import Messagerie
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()
emetteur_profil = serializers.SerializerMethodField()
class Meta:

View File

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

View File

@ -1,455 +1,211 @@
from django.http.response import JsonResponse
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 import status
from rest_framework.parsers import MultiPartParser, FormParser
from django.db import models
from .models import Conversation, ConversationParticipant, Message, UserPresence
from django.db.models import Q
from .models import Messagerie
from Auth.models import Profile, ProfileRole
from GestionMessagerie.serializers import (
ConversationSerializer, MessageSerializer,
ConversationCreateSerializer, UserPresenceSerializer,
ProfileSimpleSerializer
)
from GestionMessagerie.serializers import MessageSerializer
from School.models import Teacher
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 import openapi
from django.utils import timezone
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
from rest_framework.exceptions import NotFound
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 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}
)
class MessageView(APIView):
def post(self, request):
serializer = ConversationCreateSerializer(data=request.data)
if serializer.is_valid():
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)
message_data=JSONParser().parse(request)
message_serializer = MessageSerializer(data=message_data)
class InstantMessageListView(APIView):
"""
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')
if message_serializer.is_valid():
message_serializer.save()
# Récupérer l'utilisateur actuel depuis les paramètres de requête
user_id = request.GET.get('user_id')
user = None
if user_id:
try:
user = Profile.objects.get(id=user_id)
except Profile.DoesNotExist:
pass
return JsonResponse('Nouveau Message ajouté', safe=False)
serializer = MessageSerializer(messages, many=True, context={'user': user})
return Response(serializer.data, status=status.HTTP_200_OK)
except Conversation.DoesNotExist:
return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return JsonResponse(message_serializer.errors, safe=False)
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):
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:
conversation_id = request.data.get('conversation_id')
sender_id = request.data.get('sender_id')
content = request.data.get('content', '').strip()
message_type = request.data.get('message_type', 'text')
# Récupérer la connexion SMTP
connection = mailer.getConnection(establishment_id)
if not all([conversation_id, sender_id, content]):
return Response(
{'error': 'conversation_id, sender_id, and content are required'},
status=status.HTTP_400_BAD_REQUEST
# Envoyer l'email
return mailer.sendMail(
subject=subject,
message=message,
recipients=recipients,
cc=cc,
bcc=bcc,
attachments=[],
connection=connection
)
# Vérifier que la conversation existe
conversation = Conversation.objects.get(id=conversation_id)
# Vérifier que l'expéditeur existe et peut envoyer dans cette conversation
sender = Profile.objects.get(id=sender_id)
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 NotFound as e:
return Response({'error': str(e)}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
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(
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):
def get(self, request, establishment_id):
try:
user_id = request.data.get('user_id')
if not user_id:
return Response({'error': 'user_id is required'}, status=status.HTTP_400_BAD_REQUEST)
# Récupérer les enseignants associés à l'établissement
teachers = Teacher.objects.filter(profile_role__establishment_id=establishment_id)
teachers_serializer = TeacherSerializer(teachers, many=True)
participant = ConversationParticipant.objects.get(
conversation_id=conversation_id,
participant_id=user_id,
is_active=True
)
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),
# Ajouter un contact pour l'administration
admin_contact = {
"id": "admin",
"name": "Administration",
"email": "admin@etablissement.com",
"profilePic": "https://www.gravatar.com/avatar/admin"
}
)),
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:
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(
operation_description="Recherche des destinataires pour la messagerie instantanée",
manual_parameters=[
openapi.Parameter('establishment_id', openapi.IN_QUERY, description="ID de l'établissement", type=openapi.TYPE_INTEGER, required=True),
openapi.Parameter('q', openapi.IN_QUERY, description="Terme de recherche", type=openapi.TYPE_STRING, required=True)
],
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()
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 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=profile,
establishment_id=establishment_id,
is_active=True
).values_list('profile_id', flat=True)
# 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)
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
})
# Exclure l'utilisateur actuel des résultats
if request.user.is_authenticated:
users = users.exclude(id=request.user.id)
return JsonResponse(results, safe=False)
serializer = ProfileSimpleSerializer(users[:10], many=True) # Limiter à 10 résultats
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):
class ConversationListView(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(
operation_description="Supprime une conversation instantanée (désactivation soft)",
responses={200: openapi.Schema(
operation_description="Liste les conversations d'un utilisateur (parent ou enseignant).",
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,
properties={
'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
'message': openapi.Schema(type=openapi.TYPE_STRING)
}
)}
'profile_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID du profil utilisateur')
},
required=['profile_id']
),
responses={200: openapi.Response('Statut OK')}
)
def delete(self, request, conversation_id):
try:
# Récupérer la conversation par son ID UUID
conversation = Conversation.objects.filter(id=conversation_id).first()
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)
def post(self, request, conversation_id):
profile_id = request.data.get('profile_id')
Messagerie.objects.filter(conversation_id=conversation_id, destinataire_id=profile_id, is_read=False).update(is_read=True)
return Response({'status': 'ok'}, status=status.HTTP_200_OK)

View File

@ -8,40 +8,9 @@ https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
"""
import os
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')
# Initialize Django ASGI application early to ensure the AppRegistry
# 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)
)
),
})
application = get_asgi_application()

View File

@ -6,58 +6,40 @@ import re
from rest_framework.response import Response
from rest_framework import status
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
import logging
# Ajouter un logger pour debug
logger = logging.getLogger(__name__)
def getConnection(id_establishement):
try:
# Récupérer l'instance de l'établissement
establishment = Establishment.objects.get(id=id_establishement)
try:
# 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
connection = get_connection(
host=smtp_settings.smtp_server,
port=smtp_settings.smtp_port,
username=smtp_settings.smtp_user,
password=smtp_settings.smtp_password,
username=smtp_settings.mail_user,
password=smtp_settings.mail_password,
use_tls=smtp_settings.use_tls,
use_ssl=smtp_settings.use_ssl
)
return connection
except SMTPSettings.DoesNotExist:
# Aucun paramètre SMTP spécifique, retournera None
return None
except Establishment.DoesNotExist:
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):
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)
if connection is not None:
from_email = username
else:
from_email = settings.EMAIL_HOST_USER
logger.info(f"From email: {from_email}")
if connection is not None:
from_email = connection.username
email = EmailMultiAlternatives(
subject=subject,
@ -70,18 +52,15 @@ def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connec
)
email.attach_alternative(message, "text/html")
# Ajout des pièces jointes
for attachment in attachments:
# attachment doit être un tuple (filename, content, mimetype)
# ex: ("document.pdf", fichier.read(), "application/pdf")
email.attach(*attachment)
logger.info("Tentative d'envoi de l'email...")
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)
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)
def envoieReinitMotDePasse(recipients, code):
@ -94,7 +73,7 @@ def envoieReinitMotDePasse(recipients, code):
}
subject = EMAIL_REINIT_SUBJECT
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)
@ -112,11 +91,10 @@ def sendRegisterForm(recipients, establishment_id):
'email': recipients,
'establishment': establishment_id
}
# Récupérer la connexion SMTP
connection = getConnection(establishment_id)
subject = EMAIL_INSCRIPTION_SUBJECT
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:
@ -135,11 +113,9 @@ def sendMandatSEPA(recipients, establishment_id):
'establishment': establishment_id
}
# Récupérer la connexion SMTP
connection = getConnection(establishment_id)
subject = EMAIL_INSCRIPTION_SUBJECT
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:
errorMessage = str(e)
@ -178,3 +154,24 @@ def isValid(message, fiche_inscription):
mailReponsableAVerifier = responsable.mail
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

@ -46,7 +46,6 @@ INSTALLED_APPS = [
'Subscriptions.apps.GestioninscriptionsConfig',
'Auth.apps.GestionloginConfig',
'GestionMessagerie.apps.GestionMessagerieConfig',
'GestionEmail.apps.GestionEmailConfig',
'GestionNotification.apps.GestionNotificationConfig',
'School.apps.SchoolConfig',
'Planning.apps.PlanningConfig',
@ -64,14 +63,14 @@ INSTALLED_APPS = [
'N3wtSchool',
'drf_yasg',
'rest_framework_simplejwt',
'channels',
'django_mailbox',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'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.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
@ -163,16 +162,21 @@ LOGGING = {
"level": os.getenv("GESTION_MESSAGERIE_LOG_LEVEL", "INFO"),
"propagate": False,
},
"GestionEmail": {
"handlers": ["console"],
"level": os.getenv("GESTION_EMAIL_LOG_LEVEL", "INFO"),
"propagate": False,
},
"School": {
"handlers": ["console"],
"level": os.getenv("GESTION_ENSEIGNANTS_LOG_LEVEL", "INFO"),
"propagate": False,
},
"django_mailbox": {
"handlers": ["console"],
"level": "DEBUG",
"propagate": False,
},
"django_mailbox.models": {
"handlers": ["console"],
"level": "DEBUG",
"propagate": False,
},
},
}
@ -257,35 +261,18 @@ else:
DOCUMENT_DIR = 'documents'
# Configuration CORS temporaire pour debug
CORS_ORIGIN_ALLOW_ALL = True
CORS_ALLOW_ALL_HEADERS = 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 = [
'accept',
'accept-encoding',
'authorization',
'content-type',
'dnt',
'origin',
'user-agent',
'x-csrftoken',
'x-requested-with',
'authorization',
'X-Auth-Token',
'x-csrftoken'
]
# Méthodes HTTP autorisées
CORS_ALLOWED_METHODS = [
'DELETE',
'GET',
'OPTIONS',
'PATCH',
'POST',
'PUT',
CORS_ALLOWED_ORIGINS = [
os.getenv('CORS_ALLOWED_ORIGINS', 'http://localhost:3000')
]
CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost:3000,http://localhost:8080').split(',')
@ -327,7 +314,6 @@ REST_FRAMEWORK = {
'PAGE_SIZE': NB_RESULT_SUBSCRIPTIONS_PER_PAGE,
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
),
}
@ -363,20 +349,10 @@ SIMPLE_JWT = {
}
# Configuration for DocuSeal JWT
DOCUSEAL_API_KEY="LRvUTQCbMSSpManYKshdQk9Do6rBQgjHyPrbGfxU3Jg"
DOCUSEAL_JWT = {
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
'EXPIRATION_DELTA': timedelta(hours=1)
}
# Django Channels Configuration
ASGI_APPLICATION = 'N3wtSchool.asgi.application'
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
"hosts": [('redis', 6379)],
},
},
'EXPIRATION_DELTA': timedelta(hours=1),
'API_KEY': DOCUSEAL_API_KEY
}

View File

@ -14,8 +14,9 @@ def setup_periodic_tasks(sender, **kwargs):
# Déclarer la tâche périodique
PeriodicTask.objects.get_or_create(
interval=schedule, # Utiliser l'intervalle défini ci-dessus
name='Tâche périodique toutes les 5 secondes',
task='Subscriptions.tasks.check_for_signature_deadlines', # Remplacer par le nom de ta tâche
kwargs=json.dumps({}) # Si nécessaire, ajoute
interval=schedule,
name='getMail',
task='N3wtSchool.tasks.run_getmail',
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("Auth/", include(("Auth.urls", 'Auth'), namespace='Auth')),
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("School/", include(("School.urls", 'School'), namespace='School')),
path("DocuSeal/", include(("DocuSeal.urls", 'DocuSeal'), namespace='DocuSeal')),

View File

@ -4,14 +4,22 @@ from django.utils.translation import gettext_lazy as _
from django.conf import settings
from Establishment.models import Establishment
class SMTPSettings(models.Model):
class MailSettings(models.Model):
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_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)
# IMAP
imap_server = models.CharField(max_length=255)
imap_port = models.PositiveIntegerField(default=993)
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 .models import SMTPSettings
from .models import MailSettings
class SMTPSettingsSerializer(serializers.ModelSerializer):
class MailSettingsSerializer(serializers.ModelSerializer):
class Meta:
model = SMTPSettings
model = MailSettings
fields = '__all__'

View File

@ -1,6 +1,7 @@
from django.urls import path
from .views import SMTPSettingsView
from .views import MailSettingsView, SyncImapView
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 import openapi
from .models import SMTPSettings
from .serializers import SMTPSettingsSerializer
from .models import MailSettings
from .serializers import MailSettingsSerializer
from rest_framework.views import APIView
from rest_framework.response import Response
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.
"""
@ -23,7 +27,7 @@ class SMTPSettingsView(APIView):
)
],
responses={
200: SMTPSettingsSerializer(many=True),
200: MailSettingsSerializer(many=True),
404: openapi.Response(description="Aucun paramètre SMTP trouvé."),
500: openapi.Response(description="Erreur interne du serveur."),
},
@ -34,32 +38,48 @@ class SMTPSettingsView(APIView):
try:
if establishment_id:
# Récupérer les paramètres SMTP pour un établissement spécifique
smtp_settings = SMTPSettings.objects.filter(establishment_id=establishment_id).first()
if not smtp_settings:
mail_settings = MailSettings.objects.filter(establishment_id=establishment_id).first()
if not mail_settings:
return Response(
{'error': f"Aucun paramètre SMTP trouvé pour l'établissement {establishment_id}."},
status=status.HTTP_404_NOT_FOUND
)
serializer = SMTPSettingsSerializer(smtp_settings)
serializer = MailSettingsSerializer(mail_settings)
return Response(serializer.data, status=status.HTTP_200_OK)
else:
# Récupérer tous les paramètres SMTP
smtp_settings = SMTPSettings.objects.all()
if not smtp_settings.exists():
mail_settings = MailSettings.objects.all()
if not mail_settings.exists():
return Response(
{'error': "Aucun paramètre SMTP trouvé."},
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)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@swagger_auto_schema(
operation_description="Créer ou mettre à jour les paramètres SMTP pour un établissement spécifique",
request_body=SMTPSettingsSerializer,
request_body=MailSettingsSerializer,
responses={
200: SMTPSettingsSerializer(),
200: MailSettingsSerializer(),
400: openapi.Response(description="Données invalides."),
500: openapi.Response(description="Erreur interne du serveur."),
},
@ -67,15 +87,58 @@ class SMTPSettingsView(APIView):
def post(self, request):
data = request.data
try:
smtp_settings = SMTPSettings.objects.first()
if smtp_settings:
serializer = SMTPSettingsSerializer(smtp_settings, data=data)
mail_settings = MailSettings.objects.first()
if mail_settings:
serializer = MailSettingsSerializer(mail_settings, data=data)
else:
serializer = SMTPSettingsSerializer(data=data)
serializer = MailSettingsSerializer(data=data)
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.errors, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
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

@ -305,7 +305,7 @@ class RegistrationSchoolFileMaster(models.Model):
class RegistrationParentFileMaster(models.Model):
groups = models.ManyToManyField(RegistrationFileGroup, related_name='parent_file_masters', blank=True)
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)
############################################################

View File

@ -4,9 +4,8 @@ from rest_framework import status
from drf_yasg.utils import swagger_auto_schema
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
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 Subscriptions.models import BilanCompetence
from datetime import date
from N3wtSchool.renderers import render_to_pdf
from django.core.files import File

View File

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

BIN
Back-End/db.sqlite3 Normal file

Binary file not shown.

Binary file not shown.

View File

@ -21,7 +21,6 @@ commands = [
["python", "manage.py", "makemigrations", "Subscriptions", "--noinput"],
["python", "manage.py", "makemigrations", "Planning", "--noinput"],
["python", "manage.py", "makemigrations", "GestionNotification", "--noinput"],
["python", "manage.py", "makemigrations", "GestionEmail", "--noinput"],
["python", "manage.py", "makemigrations", "GestionMessagerie", "--noinput"],
["python", "manage.py", "makemigrations", "Auth", "--noinput"],
["python", "manage.py", "makemigrations", "School", "--noinput"],
@ -36,15 +35,14 @@ for command in commands:
if run_command(command) != 0:
exit(1)
#if test_mode:
# for test_command in test_commands:
# if run_command(test_command) != 0:
# exit(1)
if test_mode:
for test_command in test_commands:
if run_command(test_command) != 0:
exit(1)
# Lancer les processus en parallèle
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", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"])
]

View File

@ -1,3 +0,0 @@
https://localhost:4443, https://127.0.0.1:4443 {
reverse_proxy docuseal:3000
}

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",
"totalStudents": "Total d'étudiants inscrits",
"totalStudents": "Total des étudiants",
"pendingRegistrations": "Inscriptions en attente",
"reInscriptionRate": "Taux de réinscription",
"structureCapacity": "Capacité de la structure",

View File

@ -24,8 +24,6 @@ const nextConfig = {
NEXT_PUBLIC_APP_VERSION: pkg.version,
NEXT_PUBLIC_API_URL:
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',
AUTH_SECRET: process.env.AUTH_SECRET || 'false',
NEXTAUTH_URL: process.env.NEXTAUTH_URL || 'http://localhost:3000',

File diff suppressed because it is too large Load Diff

View File

@ -8,10 +8,7 @@
"start": "next start",
"lint": "next lint",
"lint-light": "next lint --quiet",
"check-strings": "node scripts/check-hardcoded-strings.js",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
"check-strings": "node scripts/check-hardcoded-strings.js"
},
"dependencies": {
"@docuseal/react": "^1.0.56",
@ -40,11 +37,6 @@
"react-tooltip": "^5.28.0"
},
"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",
"eslint": "^8",
"eslint-config-next": "14.2.11",

View File

@ -1,5 +1,5 @@
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_
AUTH_SECRET=_AUTH_SECRET_
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 { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import {
LayoutDashboard,
FileText,
@ -12,8 +13,11 @@ import {
Calendar,
Settings,
LogOut,
MessageSquare,
Menu,
X,
Mail,
} from 'lucide-react';
import DropdownMenu from '@/components/DropdownMenu';
import Popup from '@/components/Popup';
import {
@ -82,7 +86,7 @@ export default function Layout({ children }) {
id: 'messagerie',
name: t('messagerie'),
url: FE_ADMIN_MESSAGERIE_URL,
icon: MessageSquare,
icon: Mail,
},
settings: {
id: 'settings',

View File

@ -1,5 +1,5 @@
'use client';
import React, { useEffect } from 'react';
import React from 'react';
import SidebarTabs from '@/components/SidebarTabs';
import EmailSender from '@/components/Admin/EmailSender';
import InstantMessaging from '@/components/Admin/InstantMessaging';
@ -26,8 +26,11 @@ export default function MessageriePage({ csrfToken }) {
];
return (
<div className="h-full flex flex-col p-0 m-0">
<SidebarTabs tabs={tabs} />
<div className="flex h-full w-full">
<SidebarTabs
tabs={tabs}
onTabChange={(tabId) => logger.debug(`Onglet actif : ${tabId}`)}
/>
</div>
);
}

View File

@ -1,8 +1,10 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { Users, Clock, CalendarCheck, School, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { Users, Clock, CalendarCheck, School, TrendingUp } from 'lucide-react';
import Loader from '@/components/Loader';
import ClasseDetails from '@/components/ClasseDetails';
import { fetchClasses } from '@/app/actions/schoolAction';
import StatCard from '@/components/StatCard';
import logger from '@/utils/logger';
import {
@ -11,9 +13,8 @@ import {
} from '@/app/actions/subscriptionAction';
import { fetchUpcomingEvents } from '@/app/actions/planningAction';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useNotification } from '@/context/NotificationContext';
import Attendance from '@/components/Grades/Attendance';
import LineChart from '@/components/Charts/LineChart';
import PieChart from '@/components/Charts/PieChart';
// Composant EventCard pour afficher les événements
const EventCard = ({ title, date, description, type }) => (
@ -29,98 +30,63 @@ const EventCard = ({ title, date, description, type }) => (
</div>
);
const mockCompletionRate = 72; // en pourcentage
export default function DashboardPage() {
const t = useTranslations('dashboard');
const [isLoading, setIsLoading] = useState(false);
const [currentYearRegistrationCount, setCurrentYearRegistrationCount] =
useState(0);
const [totalStudents, setTotalStudents] = useState(0);
const [pendingRegistration, setPendingRegistration] = useState(0);
const [structureCapacity, setStructureCapacity] = useState(0);
const [upcomingEvents, setUpcomingEvents] = useState([]);
const [monthlyStats, setMonthlyStats] = useState({
inscriptions: [],
completionRate: 0,
});
const [classes, setClasses] = useState([]);
const [absencesToday, setAbsencesToday] = useState([]);
const { selectedEstablishmentId, selectedEstablishmentTotalCapacity, apiDocuseal } =
const { selectedEstablishmentId, selectedEstablishmentTotalCapacity } =
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([]);
const { showNotification } = useNotification();
useEffect(() => {
if (!selectedEstablishmentId) return;
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
fetchRegisterForms(selectedEstablishmentId)
.then((data) => {
logger.info('Pending registrations fetched:', data);
setCurrentYearRegistrationCount(data.count || 0);
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);
setPendingRegistration(data.count);
})
.catch((error) => {
logger.error('Erreur lors du fetch des inscriptions :', error);
logger.error('Error fetching pending registrations:', error);
});
// Fetch des événements à venir
@ -157,35 +123,11 @@ export default function DashboardPage() {
});
}, [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 />;
return (
<div key={selectedEstablishmentId} className="p-6">
<div className="flex items-center gap-3 mb-6">
<span
className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold ${
apiDocuseal
? 'bg-green-100 text-green-700 border border-green-300'
: 'bg-red-100 text-red-700 border border-red-300'
}`}
>
{apiDocuseal ? (
<CheckCircle2 className="w-4 h-4 mr-2 text-green-500" />
) : (
<AlertTriangle className="w-4 h-4 mr-2 text-red-500" />
)}
{apiDocuseal
? 'Clé API Docuseal renseignée'
: 'Clé API Docuseal manquante'}
</span>
</div>
<h1 className="text-2xl font-bold mb-6">{t('dashboard')}</h1>
{/* Statistiques principales */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
@ -196,42 +138,34 @@ export default function DashboardPage() {
/>
<StatCard
title={t('pendingRegistrations')}
value={pendingRegistrationCount}
value={`${pendingRegistration} `}
icon={<Clock className="text-green-500" size={24} />}
color="green"
/>
<StatCard
title={t('structureCapacity')}
value={selectedEstablishmentTotalCapacity}
value={`${selectedEstablishmentTotalCapacity}`}
icon={<School className="text-green-500" size={24} />}
color="emerald"
/>
<StatCard
title={t('capacityRate')}
value={
selectedEstablishmentTotalCapacity > 0
? `${((totalStudents / selectedEstablishmentTotalCapacity) * 100).toFixed(2)}%`
: 0
}
value={`${((totalStudents / selectedEstablishmentTotalCapacity) * 100).toFixed(1)}%`}
icon={<School className="text-orange-500" size={24} />}
color="orange"
/>
</div>
{/* É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">
{/* Graphique des inscriptions */}
<div className="lg:col-span-1 bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100">
<h2 className="text-lg font-semibold mb-6">
<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-4">
{t('inscriptionTrends')}
</h2>
<div className="flex flex-row gap-4">
<div className="flex-1 p-6">
<LineChart data={monthlyRegistrations} />
</div>
<div className="flex-1 flex items-center justify-center">
<PieChart data={statusDistribution} />
</div>
{/* Insérer ici un composant de graphique */}
<div className="h-64 bg-gray-50 rounded flex items-center justify-center mb-6">
<TrendingUp size={48} className="text-gray-300" />
</div>
</div>
@ -245,9 +179,7 @@ export default function DashboardPage() {
</div>
{/* Ajout du composant Attendance en dessous, en lecture seule */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<Attendance absences={absencesToday} readOnly={true} />
</div>
</div>
);
}

View File

@ -21,12 +21,14 @@ export default function SettingsPage() {
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [smtpServer, setSmtpServer] = useState('');
const [imapServer, setImapServer] = useState('');
const [smtpPort, setSmtpPort] = useState('');
const [smtpUser, setSmtpUser] = useState('');
const [smtpPassword, setSmtpPassword] = useState('');
const [imapPort, setImapPort] = useState('');
const [mailUser, setMailUser] = useState('');
const [mailPassword, setMailPassword] = useState('');
const [useTls, setUseTls] = useState(true);
const [useSsl, setUseSsl] = useState(false);
const { selectedEstablishmentId } = useEstablishment();
const { user, selectedEstablishmentId } = useEstablishment();
const csrfToken = useCsrfToken(); // Récupération du csrfToken
const { showNotification } = useNotification();
const searchParams = useSearchParams();
@ -52,8 +54,10 @@ export default function SettingsPage() {
.then((data) => {
setSmtpServer(data.smtp_server || '');
setSmtpPort(data.smtp_port || '');
setSmtpUser(data.smtp_user || '');
setSmtpPassword(data.smtp_password || '');
setImapServer(data.imap_server || '');
setImapPort(data.imap_port || '');
setMailUser(data.mail_user || '');
setMailPassword(data.mail_password || '');
setUseTls(data.use_tls || false);
setUseSsl(data.use_ssl || false);
})
@ -95,16 +99,24 @@ export default function SettingsPage() {
setSmtpServer(e.target.value);
};
const handleImapServerChange = (e) => {
setImapServer(e.target.value);
};
const handleSmtpPortChange = (e) => {
setSmtpPort(e.target.value);
};
const handleSmtpUserChange = (e) => {
setSmtpUser(e.target.value);
const handleImapPortChange = (e) => {
setImapPort(e.target.value);
};
const handleSmtpPasswordChange = (e) => {
setSmtpPassword(e.target.value);
const handleMailUserChange = (e) => {
setMailUser(e.target.value);
};
const handleMailPasswordChange = (e) => {
setMailPassword(e.target.value);
};
const handleUseTlsChange = (e) => {
@ -131,10 +143,13 @@ export default function SettingsPage() {
e.preventDefault();
const smtpData = {
establishment: selectedEstablishmentId,
profile_id: user.user_id,
smtp_server: smtpServer,
smtp_port: smtpPort,
smtp_user: smtpUser,
smtp_password: smtpPassword,
imap_server: imapServer,
imap_port: imapPort,
mail_user: mailUser,
mail_password: mailPassword,
use_tls: useTls,
use_ssl: useSsl,
};
@ -212,15 +227,25 @@ export default function SettingsPage() {
onChange={handleSmtpPortChange}
/>
<InputText
label="Utilisateur SMTP"
value={smtpUser}
onChange={handleSmtpUserChange}
label="Serveur IMAP"
value={imapServer}
onChange={handleImapServerChange}
/>
<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"
value={smtpPassword}
onChange={handleSmtpPasswordChange}
value={mailPassword}
onChange={handleMailPasswordChange}
/>
</div>
<div className="mt-6 border-t pt-4">

View File

@ -52,7 +52,7 @@ export default function Page() {
);
const csrfToken = useCsrfToken();
const { selectedEstablishmentId, apiDocuseal } = useEstablishment();
const { selectedEstablishmentId } = useEstablishment();
useEffect(() => {
if (selectedEstablishmentId) {
@ -353,7 +353,6 @@ export default function Page() {
<FilesGroupsManagement
csrfToken={csrfToken}
selectedEstablishmentId={selectedEstablishmentId}
apiDocuseal={apiDocuseal}
/>
</div>
),

View File

@ -96,7 +96,7 @@ export default function CreateSubscriptionPage() {
const { getNiveauLabel } = useClasses();
const formDataRef = useRef(formData);
const { selectedEstablishmentId, apiDocuseal } = useEstablishment();
const { selectedEstablishmentId } = useEstablishment();
const csrfToken = useCsrfToken();
const router = useRouter();
@ -473,6 +473,8 @@ export default function CreateSubscriptionPage() {
}
})();
logger.debug('test : ', guardians);
const data = {
student: {
last_name: formDataRef.current.studentLastName,
@ -530,14 +532,12 @@ export default function CreateSubscriptionPage() {
const clonePromises = masters.map((templateMaster) =>
cloneTemplate(
templateMaster.id,
formDataRef.current.guardianEmail,
templateMaster.is_required,
selectedEstablishmentId,
apiDocuseal
formData.guardianEmail,
templateMaster.is_required
)
.then((clonedDocument) => {
const cloneData = {
name: `${templateMaster.name}_${formDataRef.current.studentFirstName}_${formDataRef.current.studentLastName}`,
name: `${templateMaster.name}_${formData.studentFirstName}_${formData.studentLastName}`,
slug: clonedDocument.slug,
id: clonedDocument.id,
master: templateMaster.id,
@ -655,7 +655,6 @@ export default function CreateSubscriptionPage() {
return {
...prevData,
selectedGuardians: updatedSelectedGuardians,
guardianEmail: guardian.associated_profile_email,
};
});
};

View File

@ -17,7 +17,7 @@ export default function Page() {
const [formErrors, setFormErrors] = useState({});
const csrfToken = useCsrfToken();
const { selectedEstablishmentId, apiDocuseal } = useEstablishment();
const { selectedEstablishmentId } = useEstablishment();
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = (data) => {
@ -47,7 +47,6 @@ export default function Page() {
studentId={studentId}
csrfToken={csrfToken}
selectedEstablishmentId={selectedEstablishmentId}
apiDocuseal = {apiDocuseal}
onSubmit={handleSubmit}
cancelUrl={FE_ADMIN_SUBSCRIPTIONS_URL}
errors={formErrors}

View File

@ -14,7 +14,7 @@ export default function Page() {
const enable = searchParams.get('enabled') === 'true';
const router = useRouter();
const csrfToken = useCsrfToken();
const { selectedEstablishmentId, apiDocuseal } = useEstablishment();
const { selectedEstablishmentId } = useEstablishment();
const handleSubmit = async (data) => {
try {
@ -31,7 +31,6 @@ export default function Page() {
studentId={studentId}
csrfToken={csrfToken}
selectedEstablishmentId={selectedEstablishmentId}
apiDocuseal = {apiDocuseal}
onSubmit={handleSubmit}
cancelUrl={FE_PARENTS_HOME_URL}
enable={enable}

View File

@ -1,64 +1,31 @@
'use client';
// src/components/Layout.js
import React, { useState, useEffect } from 'react';
import Sidebar from '@/components/Sidebar';
import { useRouter, usePathname } from 'next/navigation';
import { MessageSquare, Settings, Home, Menu } from 'lucide-react';
import React, { useState } from 'react';
import ProfileSelector from '@/components/ProfileSelector';
import { useRouter } from 'next/navigation'; // Ajout de l'importation
import { MessageSquare, LogOut, Settings, Home } from 'lucide-react'; // Ajout de l'importation de l'icône Home
import {
FE_PARENTS_HOME_URL,
FE_PARENTS_MESSAGERIE_URL,
FE_PARENTS_SETTINGS_URL,
} from '@/utils/Url';
} from '@/utils/Url'; // Ajout de l'importation de l'URL de la page d'accueil parent
import ProtectedRoute from '@/components/ProtectedRoute';
import { disconnect } from '@/app/actions/authAction';
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 Footer from '@/components/Footer';
export default function Layout({ children }) {
const router = useRouter();
const pathname = usePathname();
const router = useRouter(); // Définition de router
const [messages, setMessages] = useState([]);
const [isPopupVisible, setIsPopupVisible] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const { clearContext } = useEstablishment();
const { profileRole, user, clearContext } = useEstablishment();
const softwareName = 'N3WT School';
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,
},
{
id: 'settings',
name: 'Paramètres',
url: FE_PARENTS_SETTINGS_URL,
icon: Settings,
},
];
// 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 = () => {
setIsPopupVisible(true);
};
@ -68,63 +35,52 @@ export default function Layout({ children }) {
disconnect();
clearContext();
};
const toggleSidebar = () => {
setIsSidebarOpen(!isSidebarOpen);
};
useEffect(() => {
// Fermer la sidebar quand on change de page sur mobile
setIsSidebarOpen(false);
}, [pathname]);
return (
<ProtectedRoute requiredRight={RIGHTS.PARENT}>
{/* Bouton hamburger pour mobile */}
<button
onClick={toggleSidebar}
className="fixed top-4 left-4 z-40 p-2 rounded-md bg-white shadow-lg border border-gray-200 md:hidden"
>
<Menu size={20} />
</button>
<div className="flex flex-col min-h-screen bg-gray-50">
{/* Entête */}
<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">
<div className="flex items-center space-x-2">
{/* Suppression du menu profil parent */}
{/* Sidebar */}
<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 className="text-lg md:text-xl p-2 font-semibold">Accueil</div>
</div>
{/* Overlay for mobile */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-20 md:hidden"
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' : ''}`}
<div className="flex items-center space-x-2 md:space-x-4">
<button
className="p-1 md:p-2 rounded-full hover:bg-gray-200"
onClick={() => {
router.push(FE_PARENTS_HOME_URL);
}}
>
<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}
</div>
{/* Footer */}
{/* Footer responsive */}
<Footer softwareName={softwareName} softwareVersion={softwareVersion} />
<Popup
isOpen={isPopupVisible}
message="Êtes-vous sûr(e) de vouloir vous déconnecter ?"
onConfirm={confirmDisconnect}
onCancel={() => setIsPopupVisible(false)}
/>
</div>
</ProtectedRoute>
);
}

View File

@ -1,28 +1,15 @@
'use client';
import React from 'react';
import InstantChat from '@/components/Chat/InstantChat';
import React, { useEffect, useState } from 'react';
import Chat from '@/components/Chat';
import { useEstablishment } from '@/context/EstablishmentContext';
export default function MessageriePage() {
const { user, selectedEstablishmentId } = useEstablishment();
if (!user?.user_id || !selectedEstablishmentId) {
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>
);
}
if (!user) return <div>Chargement...</div>;
return (
<div className="h-full flex flex-col">
<InstantChat
userProfileId={user.user_id}
establishmentId={selectedEstablishmentId}
/>
</div>
<Chat userProfileId={user.id} establishmentId={selectedEstablishmentId} />
);
}

View File

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

View File

@ -1,251 +1,67 @@
import {
BE_GESTIONMESSAGERIE_CONVERSATIONS_URL,
BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL,
BE_GESTIONMESSAGERIE_CREATE_CONVERSATION_URL,
BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL,
BE_GESTIONMESSAGERIE_CONVERSATION_MESSAGES_URL,
BE_GESTIONMESSAGERIE_MARK_AS_READ_URL,
BE_GESTIONMESSAGERIE_UPLOAD_FILE_URL,
BE_GESTIONMESSAGERIE_DELETE_CONVERSATION_URL,
BE_GESTIONMESSAGERIE_MESSAGES_URL,
BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL,
} from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers';
import logger from '@/utils/logger';
// Helper pour construire les en-têtes avec CSRF
const buildHeaders = (csrfToken) => {
const headers = {
export const fetchConversations = (profileId) => {
return fetch(`${BE_GESTIONMESSAGERIE_CONVERSATIONS_URL}/${profileId}/`, {
headers: {
'Content-Type': 'application/json',
};
// Ajouter le token CSRF
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
return headers;
},
})
.then(requestResponseHandler)
.catch(errorHandler);
};
/**
* Récupère les conversations d'un utilisateur
*/
export const fetchConversations = async (userId, csrfToken) => {
try {
// Utiliser la nouvelle route avec user_id en paramètre d'URL
const url = `${BE_GESTIONMESSAGERIE_CONVERSATIONS_URL}/user/${userId}/`;
const response = await fetch(url, {
method: 'GET',
headers: buildHeaders(csrfToken),
credentials: 'include',
});
return await requestResponseHandler(response);
} catch (error) {
logger.error('Erreur lors de la récupération des conversations:', error);
return errorHandler(error);
export const fetchMessages = (conversationId) => {
return fetch(
`${BE_GESTIONMESSAGERIE_CONVERSATION_MESSAGES_URL}/${conversationId}/`,
{
headers: {
'Content-Type': 'application/json',
},
}
)
.then(requestResponseHandler)
.catch(errorHandler);
};
/**
* Récupère les messages d'une conversation
*/
export const fetchMessages = async (
conversationId,
page = 1,
limit = 50,
csrfToken,
userId = null
) => {
try {
// Utiliser la nouvelle URL avec conversation_id en paramètre d'URL
let url = `${BE_GESTIONMESSAGERIE_CONVERSATIONS_URL}/${conversationId}/messages/?page=${page}&limit=${limit}`;
// Ajouter user_id si fourni pour calculer correctement is_read
if (userId) {
url += `&user_id=${userId}`;
}
const response = await fetch(url, {
method: 'GET',
headers: buildHeaders(csrfToken),
credentials: 'include',
});
return await requestResponseHandler(response);
} catch (error) {
logger.error('Erreur lors de la récupération des messages:', error);
return errorHandler(error);
}
};
/**
* Envoie un message dans une conversation
*/
export const sendMessage = async (messageData, csrfToken) => {
try {
const response = await fetch(BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL, {
export const sendMessage = (data) => {
return fetch(`${BE_GESTIONMESSAGERIE_MESSAGES_URL}`, {
method: 'POST',
headers: buildHeaders(csrfToken),
credentials: 'include',
body: JSON.stringify(messageData),
});
return await requestResponseHandler(response);
} catch (error) {
logger.error("Erreur lors de l'envoi du message:", error);
return errorHandler(error);
}
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
.then(requestResponseHandler)
.catch(errorHandler);
};
/**
* Crée une nouvelle conversation
*/
export const createConversation = async (participantIds, csrfToken) => {
try {
const requestBody = {
participant_ids: participantIds, // Le backend attend "participant_ids"
conversation_type: 'private', // Spécifier le type de conversation
name: '', // Le nom sera généré côté backend
};
const response = await fetch(BE_GESTIONMESSAGERIE_CREATE_CONVERSATION_URL, {
export const markAsRead = (conversationId, profileId) => {
return fetch(`${BE_GESTIONMESSAGERIE_MARK_AS_READ_URL}/${conversationId}/`, {
method: 'POST',
headers: buildHeaders(csrfToken),
credentials: 'include',
body: JSON.stringify(requestBody),
});
return await requestResponseHandler(response);
} catch (error) {
logger.error('Erreur lors de la création de la conversation:', error);
return errorHandler(error);
}
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ profile_id: profileId }),
})
.then(requestResponseHandler)
.catch(errorHandler);
};
/**
* Recherche des destinataires pour la messagerie
*/
export const searchMessagerieRecipients = async (
establishmentId,
query,
csrfToken
) => {
try {
const baseUrl = BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL.endsWith('/')
? BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL
: BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL + '/';
const url = `${baseUrl}?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`;
const response = await fetch(url, {
export const searchRecipients = (establishmentId, query) => {
const url = `${BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL}/?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`;
return fetch(url, {
method: 'GET',
headers: buildHeaders(csrfToken),
credentials: 'include',
});
return await requestResponseHandler(response);
} catch (error) {
logger.error('Erreur lors de la recherche des destinataires:', error);
return errorHandler(error);
}
};
/**
* Marque des messages comme lus
*/
export const markAsRead = async (conversationId, userId, csrfToken) => {
try {
const response = await fetch(BE_GESTIONMESSAGERIE_MARK_AS_READ_URL, {
method: 'POST',
headers: buildHeaders(csrfToken),
credentials: 'include',
body: JSON.stringify({
conversation_id: conversationId,
user_id: userId,
}),
});
return await requestResponseHandler(response);
} catch (error) {
logger.error('Erreur lors du marquage des messages comme lus:', error);
return errorHandler(error);
}
};
/**
* Upload un fichier pour la messagerie
*/
export const uploadFile = async (
file,
conversationId,
senderId,
csrfToken,
onProgress = null
) => {
const formData = new FormData();
formData.append('file', file);
formData.append('conversation_id', conversationId);
formData.append('sender_id', senderId);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
if (onProgress) {
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
onProgress(percentComplete);
}
});
}
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
resolve(response);
} catch (error) {
reject(new Error('Réponse invalide du serveur'));
}
} else {
try {
const errorResponse = JSON.parse(xhr.responseText);
reject(new Error(errorResponse.message || "Erreur lors de l'upload"));
} catch {
reject(new Error(`Erreur HTTP: ${xhr.status}`));
}
}
});
xhr.addEventListener('error', () => {
reject(new Error("Erreur réseau lors de l'upload"));
});
xhr.addEventListener('timeout', () => {
reject(new Error("Timeout lors de l'upload"));
});
xhr.open('POST', BE_GESTIONMESSAGERIE_UPLOAD_FILE_URL);
xhr.withCredentials = true;
xhr.timeout = 30000;
// Ajouter le header CSRF pour XMLHttpRequest
if (csrfToken) {
xhr.setRequestHeader('X-CSRFToken', csrfToken);
}
xhr.send(formData);
});
};
/**
* Supprime une conversation
*/
export const deleteConversation = async (conversationId, csrfToken) => {
try {
const url = `${BE_GESTIONMESSAGERIE_DELETE_CONVERSATION_URL}/${conversationId}/`;
const response = await fetch(url, {
method: 'DELETE',
headers: buildHeaders(csrfToken),
credentials: 'include',
});
return await requestResponseHandler(response);
} catch (error) {
logger.error('Erreur lors de la suppression de la conversation:', error);
return errorHandler(error);
}
headers: {
'Content-Type': 'application/json',
},
})
.then(requestResponseHandler)
.catch(errorHandler);
};

View File

@ -7,7 +7,6 @@ import {
FE_API_DOCUSEAL_CLONE_URL,
FE_API_DOCUSEAL_DOWNLOAD_URL,
FE_API_DOCUSEAL_GENERATE_TOKEN,
FE_API_DOCUSEAL_DELETE_URL
} from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers';
@ -338,23 +337,8 @@ export const deleteRegistrationParentFileTemplate = (id, csrfToken) => {
};
// API requests
export const removeTemplate = (templateId, selectedEstablishmentId, apiDocuseal) => {
return fetch(`${FE_API_DOCUSEAL_DELETE_URL}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
templateId,
establishment_id :selectedEstablishmentId,
apiDocuseal
}),
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const cloneTemplate = (templateId, email, is_required, selectedEstablishmentId, apiDocuseal) => {
export const cloneTemplate = (templateId, email, is_required) => {
return fetch(`${FE_API_DOCUSEAL_CLONE_URL}`, {
method: 'POST',
headers: {
@ -364,17 +348,14 @@ export const cloneTemplate = (templateId, email, is_required, selectedEstablishm
templateId,
email,
is_required,
establishment_id :selectedEstablishmentId,
apiDocuseal
}),
})
.then(requestResponseHandler)
.catch(errorHandler);
};
export const downloadTemplate = (slug, selectedEstablishmentId, apiDocuseal) => {
const url = `${FE_API_DOCUSEAL_DOWNLOAD_URL}/${slug}?establishment_id=${selectedEstablishmentId}&apiDocuseal=${apiDocuseal}`;
return fetch(url, {
export const downloadTemplate = (slug) => {
return fetch(`${FE_API_DOCUSEAL_DOWNLOAD_URL}/${slug}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@ -384,13 +365,13 @@ export const downloadTemplate = (slug, selectedEstablishmentId, apiDocuseal) =>
.catch(errorHandler);
};
export const generateToken = (email, id = null, selectedEstablishmentId, apiDocuseal) => {
export const generateToken = (email, id = null) => {
return fetch(`${FE_API_DOCUSEAL_GENERATE_TOKEN}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ user_email: email, id, establishment_id :selectedEstablishmentId, apiDocuseal }),
body: JSON.stringify({ user_email: email, id }),
})
.then(requestResponseHandler)
.catch(errorHandler);

View File

@ -1,4 +1,4 @@
import { BE_SETTINGS_SMTP_URL } from '@/utils/Url';
import { BE_SETTINGS_MAIL_URL } from '@/utils/Url';
import { errorHandler, requestResponseHandler } from './actionsHandlers';
export const PENDING = 'pending';
@ -6,7 +6,7 @@ export const SUBSCRIBED = 'subscribed';
export const ARCHIVED = 'archived';
export const fetchSmtpSettings = (csrfToken, establishment_id = null) => {
let url = `${BE_SETTINGS_SMTP_URL}/`;
let url = `${BE_SETTINGS_MAIL_URL}/`;
if (establishment_id) {
url += `?establishment_id=${establishment_id}`;
}
@ -21,7 +21,7 @@ export const fetchSmtpSettings = (csrfToken, establishment_id = null) => {
};
export const editSmtpSettings = (data, csrfToken) => {
return fetch(`${BE_SETTINGS_SMTP_URL}/`, {
return fetch(`${BE_SETTINGS_MAIL_URL}/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
import { sendEmail, searchRecipients } from '@/app/actions/emailAction';
import { sendMessage, searchRecipients } from '@/app/actions/messagerieAction';
import { fetchSmtpSettings } from '@/app/actions/settingsAction';
import { useNotification } from '@/context/NotificationContext';
import { useEstablishment } from '@/context/EstablishmentContext';
@ -30,7 +30,7 @@ export default function EmailSender({ csrfToken }) {
// Vérifier si les paramètres SMTP sont configurés
fetchSmtpSettings(csrfToken, selectedEstablishmentId)
.then((data) => {
if (data.smtp_server && data.smtp_port && data.smtp_user) {
if (data.smtp_server && data.smtp_port && data.mail_user) {
setFromEmail(data.smtp_user);
setSmtpConfigured(true);
} else {
@ -56,7 +56,7 @@ export default function EmailSender({ csrfToken }) {
};
try {
await sendEmail(data);
await sendMessage(data);
showNotification('Email envoyé avec succès.', 'success', 'Succès');
// Réinitialiser les champs après succès
setRecipients([]);

View File

@ -1,17 +1,88 @@
// filepath: d:\Dev\n3wt-innov\n3wt-school\Front-End\src\components\Admin\InstantMessaging.js
import React, { useEffect, useState } from 'react';
import InstantChat from '@/components/Chat/InstantChat';
import Chat from '@/components/Chat';
import { useEstablishment } from '@/context/EstablishmentContext';
import logger from '@/utils/logger';
import {
fetchConversations,
sendMessage,
searchRecipients,
} from '@/app/actions/messagerieAction';
import RecipientInput from '@/components/RecipientInput';
import Modal from '@/components/Modal';
export default function InstantMessaging({ csrfToken }) {
const { user, selectedEstablishmentId } = useEstablishment();
const [discussions, setDiscussions] = useState([]);
const [recipients, setRecipients] = useState([]); // Liste des correspondants sélectionnés
const [showModal, setShowModal] = useState(false);
const [firstMessage, setFirstMessage] = useState('');
useEffect(() => {
if (user) {
fetchConversations(user.id).then(setDiscussions);
}
}, [user]);
// Fonction pour ajouter une nouvelle discussion avec plusieurs correspondants
const handleCreateDiscussion = async () => {
if (!user || recipients.length === 0 || !firstMessage.trim()) return;
for (const recipient of recipients) {
await sendMessage({
emetteur: user.id,
destinataire: recipient.id,
objet: '',
corpus: firstMessage,
conversation_id: undefined, // L'API générera un nouvel ID
});
}
setRecipients([]);
setFirstMessage('');
setShowModal(false);
fetchConversations(user.id).then(setDiscussions);
};
if (!user) return <div>Chargement...</div>;
return (
<div className="h-full flex flex-col">
<InstantChat
userProfileId={user.user_id}
<div className="h-full w-full">
<Chat
userProfileId={user.id}
establishmentId={selectedEstablishmentId}
discussions={discussions}
setDiscussions={setDiscussions}
onShowCreateDiscussion={() => setShowModal(true)}
/>
<Modal
isOpen={showModal}
setIsOpen={setShowModal}
title="Nouvelle discussion"
modalClassName="w-full max-w-xs sm:max-w-md"
>
<div className="p-2 sm:p-4">
<h3 className="text-lg font-bold mb-2">Nouvelle discussion</h3>
<RecipientInput
label="Rechercher un correspondant"
recipients={recipients}
setRecipients={setRecipients}
searchRecipients={searchRecipients}
establishmentId={selectedEstablishmentId}
required
/>
<input
type="text"
value={firstMessage}
onChange={(e) => setFirstMessage(e.target.value)}
placeholder="Premier message"
className="w-full p-2 mb-2 border rounded"
/>
<button
onClick={handleCreateDiscussion}
className="w-full p-2 bg-green-500 text-white rounded-lg"
>
Démarrer la discussion
</button>
</div>
</Modal>
</div>
);
}

View File

@ -1,45 +0,0 @@
import React from 'react';
export default function LineChart({ data }) {
if (!data || data.length === 0) {
return <div className="text-gray-400 text-center">Aucune donnée</div>;
}
// Hauteur max du graphique en pixels
const chartHeight = 120;
const maxValue = Math.max(...data.map((d) => d.value), 1);
// Trouver les indices des barres ayant la valeur max (pour gérer les égalités)
const maxIndices = data
.map((d, idx) => (d.value === maxValue ? idx : -1))
.filter((idx) => idx !== -1);
return (
<div
className="w-full flex items-end space-x-4"
style={{ height: chartHeight }}
>
{data.map((point, idx) => {
const barHeight = Math.max((point.value / maxValue) * chartHeight, 8); // min 8px
const isMax = maxIndices.includes(idx);
return (
<div key={idx} className="flex flex-col items-center flex-1">
{/* Valeur au-dessus de la barre */}
<span className="text-xs mb-1 text-gray-700 font-semibold">
{point.value}
</span>
<div
className={`${isMax ? 'bg-emerald-400' : 'bg-blue-400'} rounded-t w-4`}
style={{
height: `${barHeight}px`,
transition: 'height 0.3s',
}}
title={`${point.month}: ${point.value}`}
/>
<span className="text-xs mt-1 text-gray-600">{point.month}</span>
</div>
);
})}
</div>
);
}

View File

@ -1,67 +0,0 @@
import React from 'react';
const COLORS = [
'fill-blue-400 text-blue-400',
'fill-orange-400 text-orange-400',
'fill-purple-400 text-purple-400',
'fill-emerald-400 text-emerald-400',
];
export default function PieChart({ data }) {
if (!data || data.length === 0) {
return <div className="text-gray-400 text-center">Aucune donnée</div>;
}
const total = data.reduce((acc, d) => acc + d.value, 0);
if (total === 0) {
return <div className="text-gray-400 text-center">Aucune donnée</div>;
}
let cumulative = 0;
return (
<div className="flex items-center justify-center w-full">
<svg width={100} height={100} viewBox="0 0 32 32">
{data.map((slice, idx) => {
const value = (slice.value / total) * 100;
const startAngle = (cumulative / 100) * 360;
const endAngle = ((cumulative + value) / 100) * 360;
const largeArc = value > 50 ? 1 : 0;
const x1 = 16 + 16 * Math.cos((Math.PI * (startAngle - 90)) / 180);
const y1 = 16 + 16 * Math.sin((Math.PI * (startAngle - 90)) / 180);
const x2 = 16 + 16 * Math.cos((Math.PI * (endAngle - 90)) / 180);
const y2 = 16 + 16 * Math.sin((Math.PI * (endAngle - 90)) / 180);
const pathData = `
M16,16
L${x1},${y1}
A16,16 0 ${largeArc} 1 ${x2},${y2}
Z
`;
cumulative += value;
return (
<path
key={idx}
d={pathData}
className={COLORS[idx % COLORS.length].split(' ')[0]}
stroke="#fff"
strokeWidth="0.5"
/>
);
})}
</svg>
<div className="ml-4 flex flex-col space-y-1">
{data.map((slice, idx) => (
<div
key={idx}
className={`flex items-center text-xs font-semibold ${COLORS[idx % COLORS.length].split(' ')[1]}`}
>
<span
className={`inline-block w-3 h-3 mr-2 rounded ${COLORS[idx % COLORS.length].split(' ')[0]}`}
/>
{slice.label} : {slice.value}
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,175 @@
import React, { useState, useRef, useEffect } from 'react';
import { SendHorizontal, Plus } from 'lucide-react';
import Image from 'next/image';
import {
fetchConversations,
fetchMessages,
sendMessage,
markAsRead,
} from '@/app/actions/messagerieAction';
export default function Chat({
userProfileId,
establishmentId,
discussions: discussionsProp,
setDiscussions: setDiscussionsProp,
onCreateDiscussion,
onShowCreateDiscussion,
}) {
const [discussions, setDiscussions] = useState(discussionsProp || []);
const [selectedDiscussion, setSelectedDiscussion] = useState(null);
const [messages, setMessages] = useState([]);
const [newMessage, setNewMessage] = useState('');
const messagesEndRef = useRef(null);
useEffect(() => {
if (userProfileId) {
fetchConversations(userProfileId).then(setDiscussions);
}
}, [userProfileId]);
useEffect(() => {
if (selectedDiscussion) {
fetchMessages(selectedDiscussion.conversation_id).then(setMessages);
// Marquer comme lu
markAsRead(selectedDiscussion.conversation_id, userProfileId);
}
}, [selectedDiscussion, userProfileId]);
const handleSendMessage = async () => {
if (newMessage.trim() && selectedDiscussion) {
await sendMessage({
conversation_id: selectedDiscussion.conversation_id,
emetteur: userProfileId,
destinataire: selectedDiscussion.interlocuteur.id,
corpus: newMessage,
objet: '',
});
setNewMessage('');
fetchMessages(selectedDiscussion.conversation_id).then(setMessages);
fetchConversations(userProfileId).then(setDiscussions);
}
};
const handleKeyPress = (event) => {
if (event.key === 'Enter') {
handleSendMessage();
}
};
return (
<div className="flex h-full w-full">
{/* Bandeau droit : Liste des discussions */}
<div className="w-1/4 min-w-[280px] bg-gray-100 border-r border-gray-300 p-4 flex flex-col">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold">Discussions</h2>
<button
className="p-2 rounded-full bg-blue-500 hover:bg-blue-600 text-white shadow"
title="Nouvelle discussion"
onClick={onShowCreateDiscussion}
>
<Plus size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{discussions && discussions.length > 0 ? (
discussions.map((discussion) => (
<div
key={discussion.id}
className={`flex items-center p-2 mb-2 cursor-pointer rounded transition-colors ${
selectedDiscussion?.id === discussion.id
? 'bg-blue-100'
: 'hover:bg-gray-200'
}`}
onClick={() => setSelectedDiscussion(discussion)}
>
<Image
src={discussion.profilePic}
alt={`${discussion.name}'s profile`}
className="w-10 h-10 rounded-full mr-3"
width={40}
height={40}
/>
<div className="flex-1">
<p className="font-medium">{discussion.name}</p>
<p className="text-sm text-gray-500 truncate">
{discussion.lastMessage}
</p>
</div>
<span className="text-xs text-gray-400">
{discussion.lastMessageDate &&
new Date(discussion.lastMessageDate).toLocaleTimeString()}
</span>
</div>
))
) : (
<p className="text-gray-500">Aucune discussion disponible.</p>
)}
</div>
</div>
{/* Zone de chat */}
<div className="flex-1 flex flex-col bg-white">
{/* En-tête du chat */}
{selectedDiscussion && (
<div className="flex items-center p-4 border-b border-gray-300">
<Image
src={selectedDiscussion.profilePic}
alt={`${selectedDiscussion.name}'s profile`}
className="w-10 h-10 rounded-full mr-3"
width={40}
height={40}
/>
<h2 className="text-lg font-bold">{selectedDiscussion.name}</h2>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4">
{selectedDiscussion &&
messages.map((message) => (
<div
key={message.id}
className={`flex mb-4 ${
message.isResponse ? 'justify-start' : 'justify-end'
}`}
>
<div
className={`p-3 rounded-lg max-w-xs ${
message.isResponse
? 'bg-gray-200 text-gray-800'
: 'bg-blue-500 text-white'
}`}
>
<p>{message.corpus}</p>
<span className="text-xs text-gray-500 block mt-1">
{message.date &&
new Date(message.date).toLocaleTimeString()}
</span>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Champ de saisie */}
{selectedDiscussion && (
<div className="p-4 border-t border-gray-300 flex items-center">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
className="flex-1 p-2 border border-gray-300 rounded-lg mr-2"
placeholder="Écrire un message..."
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
/>
<button
onClick={handleSendMessage}
className="p-2 bg-blue-500 text-white rounded-lg"
>
<SendHorizontal />
</button>
</div>
)}
</div>
</div>
);
}

View File

@ -1,79 +0,0 @@
import React from 'react';
import { Wifi, WifiOff, RotateCcw } from 'lucide-react';
const ConnectionStatus = ({ status, onReconnect }) => {
const getStatusInfo = () => {
switch (status) {
case 'connected':
return {
icon: <Wifi className="w-4 h-4" />,
text: 'Connecté',
className: 'text-green-600 bg-green-50 border-green-200',
};
case 'disconnected':
return {
icon: <WifiOff className="w-4 h-4" />,
text: 'Déconnecté',
className: 'text-red-600 bg-red-50 border-red-200',
};
case 'reconnecting':
return {
icon: <RotateCcw className="w-4 h-4 animate-spin" />,
text: 'Reconnexion...',
className: 'text-yellow-600 bg-yellow-50 border-yellow-200',
};
case 'error':
return {
icon: <WifiOff className="w-4 h-4" />,
text: 'Erreur de connexion',
className: 'text-red-600 bg-red-50 border-red-200',
};
case 'failed':
return {
icon: <WifiOff className="w-4 h-4" />,
text: 'Connexion échouée',
className: 'text-red-600 bg-red-50 border-red-200',
};
default:
return {
icon: <WifiOff className="w-4 h-4" />,
text: 'Inconnu',
className: 'text-gray-600 bg-gray-50 border-gray-200',
};
}
};
if (status === 'connected') {
return (
<div className="flex items-center justify-between px-3 py-2 border rounded-lg text-green-600 bg-green-50 border-green-200">
<div className="flex items-center space-x-2">
<Wifi className="w-4 h-4" />
<span className="text-sm font-medium">Connecté</span>
</div>
</div>
);
}
const { icon, text, className } = getStatusInfo();
return (
<div
className={`flex items-center justify-between px-3 py-2 border rounded-lg ${className}`}
>
<div className="flex items-center space-x-2">
{icon}
<span className="text-sm font-medium">{text}</span>
</div>
{(status === 'failed' || status === 'error') && onReconnect && (
<button
onClick={onReconnect}
className="text-sm underline hover:no-underline"
>
Réessayer
</button>
)}
</div>
);
};
export default ConnectionStatus;

View File

@ -1,196 +0,0 @@
import React from 'react';
import { User, Trash2 } from 'lucide-react';
import { getGravatarUrl } from '@/utils/gravatar';
const ConversationItem = ({
conversation,
isSelected,
onClick,
onDelete, // Nouvelle prop pour la suppression
unreadCount = 0,
lastMessage,
isTyping = false,
userPresences = {}, // Nouveau prop pour les statuts de présence
}) => {
const formatTime = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffInHours = (now - date) / (1000 * 60 * 60);
if (diffInHours < 24) {
return date.toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit',
});
} else {
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
});
}
};
const getInterlocutorName = () => {
if (conversation.interlocuteur) {
// Si nous avons le nom et prénom, les utiliser
if (
conversation.interlocuteur.first_name &&
conversation.interlocuteur.last_name
) {
return `${conversation.interlocuteur.first_name} ${conversation.interlocuteur.last_name}`;
}
// Sinon, utiliser l'email comme fallback
if (conversation.interlocuteur.email) {
return conversation.interlocuteur.email;
}
}
return conversation.name || 'Utilisateur inconnu';
};
const getLastMessageText = () => {
if (isTyping) {
return (
<span className="text-emerald-500 italic">Tape un message...</span>
);
}
if (lastMessage) {
return lastMessage.content || lastMessage.corpus || 'Message...';
}
if (conversation.last_message) {
return (
conversation.last_message.content ||
conversation.last_message.corpus ||
'Message...'
);
}
return 'Aucun message';
};
const getLastMessageTime = () => {
if (lastMessage) {
return formatTime(lastMessage.created_at || lastMessage.date_envoi);
}
if (conversation.last_message) {
return formatTime(
conversation.last_message.created_at ||
conversation.last_message.date_envoi
);
}
return '';
};
const getUserPresenceStatus = () => {
if (conversation.interlocuteur?.id) {
const presence = userPresences[conversation.interlocuteur.id];
return presence?.status || 'offline';
}
return 'offline';
};
const getPresenceColor = (status) => {
switch (status) {
case 'online':
return 'bg-emerald-400';
case 'away':
return 'bg-yellow-400';
case 'busy':
return 'bg-red-400';
case 'offline':
default:
return 'bg-gray-400';
}
};
const getPresenceLabel = (status) => {
switch (status) {
case 'online':
return 'En ligne';
case 'away':
return 'Absent';
case 'busy':
return 'Occupé';
case 'offline':
default:
return 'Hors ligne';
}
};
const presenceStatus = getUserPresenceStatus();
return (
<div
className={`group flex items-center p-3 cursor-pointer rounded-lg transition-all duration-200 hover:bg-gray-50 ${
isSelected
? 'bg-emerald-50 border-l-4 border-emerald-500'
: 'hover:bg-gray-50'
}`}
onClick={onClick}
>
{/* Avatar */}
<div className="relative">
<img
src={getGravatarUrl(
conversation.interlocuteur?.email || 'default',
48
)}
alt={`Avatar de ${getInterlocutorName()}`}
className="w-12 h-12 rounded-full object-cover shadow-md"
/>
{/* Indicateur de statut en ligne */}
<div
className={`absolute -bottom-0.5 -right-0.5 w-4 h-4 ${getPresenceColor(presenceStatus)} border-2 border-white rounded-full`}
title={getPresenceLabel(presenceStatus)}
></div>
</div>
{/* Contenu de la conversation */}
<div className="flex-1 ml-3 overflow-hidden">
<div className="flex items-center justify-between">
<h3
className={`font-semibold truncate ${
isSelected ? 'text-emerald-700' : 'text-gray-900'
}`}
>
{getInterlocutorName()}
</h3>
<div className="flex items-center space-x-2">
{unreadCount > 0 && (
<span className="bg-red-500 text-white text-xs rounded-full w-4 h-4 text-center"></span>
)}
<span className="text-xs text-gray-500">
{getLastMessageTime()}
</span>
{/* Bouton de suppression */}
{onDelete && (
<button
onClick={(e) => {
e.stopPropagation(); // Empêcher la sélection de la conversation
onDelete();
}}
className="opacity-0 group-hover:opacity-100 hover:bg-red-100 p-1 rounded transition-all duration-200"
title="Supprimer la conversation"
>
<Trash2 className="w-4 h-4 text-red-500 hover:text-red-700" />
</button>
)}
</div>
</div>
<p
className={`text-sm truncate mt-1 ${isTyping ? '' : 'text-gray-600'}`}
>
{getLastMessageText()}
</p>
</div>
</div>
);
};
export default ConversationItem;

View File

@ -1,115 +0,0 @@
import React from 'react';
import {
Download,
FileText,
Image,
Film,
Music,
Archive,
AlertCircle,
} from 'lucide-react';
const FileAttachment = ({
fileName,
fileSize,
fileType,
fileUrl,
onDownload = null,
}) => {
// Obtenir l'icône en fonction du type de fichier
const getFileIcon = (type) => {
if (type.startsWith('image/')) {
return <Image className="w-6 h-6 text-blue-500" />;
}
if (type.startsWith('video/')) {
return <Film className="w-6 h-6 text-purple-500" />;
}
if (type.startsWith('audio/')) {
return <Music className="w-6 h-6 text-green-500" />;
}
if (type.includes('pdf')) {
return <FileText className="w-6 h-6 text-red-500" />;
}
if (type.includes('zip') || type.includes('rar')) {
return <Archive className="w-6 h-6 text-yellow-500" />;
}
return <FileText className="w-6 h-6 text-gray-500" />;
};
// Formater la taille du fichier
const formatFileSize = (bytes) => {
if (!bytes) return '';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// Gérer le téléchargement
const handleDownload = () => {
if (onDownload) {
onDownload();
} else if (fileUrl) {
const link = document.createElement('a');
link.href = fileUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
// Vérifier si c'est une image pour afficher un aperçu
const isImage = fileType && fileType.startsWith('image/');
return (
<div className="max-w-sm">
{isImage && fileUrl ? (
// Affichage pour les images
<div className="relative group">
<img
src={fileUrl}
alt={fileName}
className="max-w-full h-auto rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => window.open(fileUrl, '_blank')}
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all rounded-lg flex items-center justify-center">
<button
onClick={handleDownload}
className="opacity-0 group-hover:opacity-100 bg-white bg-opacity-90 hover:bg-opacity-100 rounded-full p-2 transition-all"
>
<Download className="w-4 h-4 text-gray-700" />
</button>
</div>
{fileName && (
<p className="mt-1 text-xs text-gray-500 truncate">{fileName}</p>
)}
</div>
) : (
// Affichage pour les autres fichiers
<div className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg border hover:bg-gray-100 transition-colors">
<div className="flex-shrink-0">{getFileIcon(fileType)}</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{fileName || 'Fichier sans nom'}
</p>
{fileSize && (
<p className="text-xs text-gray-500">
{formatFileSize(fileSize)}
</p>
)}
</div>
<button
onClick={handleDownload}
className="flex-shrink-0 p-1 text-gray-400 hover:text-gray-600 transition-colors"
title="Télécharger"
>
<Download className="w-4 h-4" />
</button>
</div>
)}
</div>
);
};
export default FileAttachment;

View File

@ -1,179 +0,0 @@
import React, { useState } from 'react';
import { X, Upload, FileText, Image, AlertCircle } from 'lucide-react';
const FileUpload = ({
file,
onUpload,
onCancel,
conversationId,
senderId,
maxSize = 10 * 1024 * 1024, // 10MB par défaut
}) => {
const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState(null);
// Vérifier le type de fichier et obtenir l'icône appropriée
const getFileIcon = (fileType) => {
if (fileType.startsWith('image/')) {
return <Image className="w-8 h-8 text-blue-500" alt="Icône image" />;
}
return <FileText className="w-8 h-8 text-gray-500" alt="Icône fichier" />;
};
// Formater la taille du fichier
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// Vérifier si le fichier est valide
const isValidFile = () => {
const allowedTypes = [
'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 (!allowedTypes.includes(file.type)) {
return false;
}
if (file.size > maxSize) {
return false;
}
return true;
};
// Gérer l'upload
const handleUpload = async () => {
if (!isValidFile()) {
setError('Type de fichier non autorisé ou fichier trop volumineux');
return;
}
setIsUploading(true);
setError(null);
try {
const result = await onUpload(
file,
conversationId,
senderId,
setUploadProgress
);
// L'upload s'est bien passé, le parent gère la suite
} catch (error) {
setError(error.message || "Erreur lors de l'upload");
setIsUploading(false);
}
};
if (!file) return null;
return (
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-lg">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center space-x-3">
{getFileIcon(file.type)}
<div>
<p className="font-medium text-gray-900 truncate max-w-xs">
{file.name}
</p>
<p className="text-sm text-gray-500">{formatFileSize(file.size)}</p>
</div>
</div>
<button
onClick={onCancel}
disabled={isUploading}
className="text-gray-400 hover:text-gray-600 disabled:opacity-50"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Prévisualisation pour les images */}
{file.type.startsWith('image/') && (
<div className="mb-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={URL.createObjectURL(file)}
alt="Aperçu du fichier sélectionné"
className="max-w-full h-32 object-cover rounded-lg"
/>
</div>
)}
{/* Validation du fichier */}
{!isValidFile() && (
<div className="mb-3 p-2 bg-red-50 border border-red-200 rounded-lg flex items-center space-x-2">
<AlertCircle className="w-4 h-4 text-red-500" />
<span className="text-sm text-red-700">
{file.size > maxSize
? `Fichier trop volumineux (max ${formatFileSize(maxSize)})`
: 'Type de fichier non autorisé'}
</span>
</div>
)}
{/* Erreur d'upload */}
{error && (
<div className="mb-3 p-2 bg-red-50 border border-red-200 rounded-lg flex items-center space-x-2">
<AlertCircle className="w-4 h-4 text-red-500" />
<span className="text-sm text-red-700">{error}</span>
</div>
)}
{/* Barre de progression */}
{isUploading && (
<div className="mb-3">
<div className="flex justify-between items-center mb-1">
<span className="text-sm text-gray-600">Upload en cours...</span>
<span className="text-sm text-gray-600">
{Math.round(uploadProgress)}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
{/* Boutons d'action */}
<div className="flex justify-end space-x-2">
<button
onClick={onCancel}
disabled={isUploading}
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-800 disabled:opacity-50"
>
Annuler
</button>
<button
onClick={handleUpload}
disabled={isUploading || !isValidFile()}
className="flex items-center space-x-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Upload className="w-4 h-4" />
<span>{isUploading ? 'Upload...' : 'Envoyer'}</span>
</button>
</div>
</div>
);
};
export default FileUpload;

File diff suppressed because it is too large Load Diff

View File

@ -1,133 +0,0 @@
import React from 'react';
import { format, isToday, isYesterday } from 'date-fns';
import { fr } from 'date-fns/locale';
import { Check, CheckCheck } from 'lucide-react';
import FileAttachment from './FileAttachment';
import { getGravatarUrl } from '@/utils/gravatar';
const MessageBubble = ({
message,
isOwnMessage,
showAvatar = true,
isRead = false,
senderName = '',
senderEmail = '', // Nouveau prop pour l'email du sender
isFirstInGroup = true,
isLastInGroup = true,
showTime = true,
}) => {
const formatMessageTime = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
if (isToday(date)) {
return format(date, 'HH:mm', { locale: fr });
} else if (isYesterday(date)) {
return `Hier ${format(date, 'HH:mm', { locale: fr })}`;
} else {
return format(date, 'dd/MM HH:mm', { locale: fr });
}
};
const getMessageContent = () => {
return message.content || message.corpus || '';
};
const getMessageTime = () => {
return message.created_at || message.date_envoi;
};
const hasAttachment = () => {
return (
message.attachment &&
(message.attachment.fileName || message.attachment.fileUrl)
);
};
const isFileOnlyMessage = () => {
return hasAttachment() && !getMessageContent().trim();
};
return (
<div
className={`group hover:bg-gray-50 px-4 py-1 ${isFirstInGroup ? 'mt-4' : 'mt-0.5'} message-appear`}
onMouseEnter={() => {
/* Peut ajouter des actions au hover */
}}
>
<div className="flex">
{/* Avatar - affiché seulement pour le premier message du groupe */}
{showAvatar && isFirstInGroup && (
<img
src={getGravatarUrl(senderEmail || senderName, 40)}
alt={`Avatar de ${senderName || 'Utilisateur'}`}
className="w-10 h-10 rounded-full object-cover shadow-sm mr-3 flex-shrink-0 mt-0.5"
/>
)}
{/* Espace pour aligner avec l'avatar quand il n'est pas affiché */}
{(!showAvatar || !isFirstInGroup) && (
<div className="w-10 mr-3 flex-shrink-0"></div>
)}
{/* Contenu du message */}
<div className="flex-1 min-w-0">
{/* En-tête du message (nom + heure) - seulement pour le premier message du groupe */}
{isFirstInGroup && (
<div className="flex items-baseline space-x-2 mb-1">
<span className="font-semibold text-gray-900 text-sm">
{senderName || (isOwnMessage ? 'Moi' : 'Utilisateur')}
</span>
<span className="text-xs text-gray-500">
{formatMessageTime(getMessageTime())}
</span>
</div>
)}
{/* Fichier attaché */}
{hasAttachment() && (
<div className={`${getMessageContent().trim() ? 'mb-2' : ''}`}>
<FileAttachment
fileName={message.attachment.fileName}
fileSize={message.attachment.fileSize}
fileType={message.attachment.fileType}
fileUrl={message.attachment.fileUrl}
/>
</div>
)}
{/* Contenu du message */}
{getMessageContent().trim() && (
<div className="text-sm leading-relaxed whitespace-pre-wrap break-words text-gray-800">
{getMessageContent()}
</div>
)}
{/* Indicateurs de lecture et heure pour les messages non-groupés */}
<div className="flex items-center space-x-2 mt-1">
{/* Heure pour les messages qui ne sont pas le premier du groupe */}
{!isFirstInGroup && (
<span className="text-xs text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity">
{formatMessageTime(getMessageTime())}
</span>
)}
{/* Indicateurs de lecture (uniquement pour nos messages) */}
{isOwnMessage && (
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
{isRead ? (
<CheckCheck className="w-3 h-3 text-green-500" title="Lu" />
) : (
<Check className="w-3 h-3 text-gray-400" title="Envoyé" />
)}
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default MessageBubble;

View File

@ -1,233 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
import { Send, Paperclip } from 'lucide-react';
import FileUpload from './FileUpload';
import { uploadFile } from '@/app/actions/messagerieAction';
import logger from '@/utils/logger';
const MessageInput = ({
onSendMessage,
onTypingStart,
onTypingStop,
disabled = false,
placeholder = 'Tapez votre message...',
conversationId = null,
senderId = null,
}) => {
const [message, setMessage] = useState('');
const [isTyping, setIsTyping] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const [showFileUpload, setShowFileUpload] = useState(false);
const textareaRef = useRef(null);
const typingTimeoutRef = useRef(null);
// Ajuster la hauteur du textarea automatiquement
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`;
}
}, [message]);
const handleInputChange = (e) => {
const value = e.target.value;
setMessage(value);
// Gestion du statut de frappe
if (value.trim() && !isTyping) {
setIsTyping(true);
onTypingStart?.();
}
// Réinitialiser le timeout de frappe
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(() => {
if (isTyping) {
setIsTyping(false);
onTypingStop?.();
}
}, 1000);
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleSend = () => {
const trimmedMessage = message.trim();
logger.debug('📝 MessageInput: handleSend appelé:', {
message,
trimmedMessage,
disabled,
});
if (!trimmedMessage || disabled) {
logger.debug('❌ MessageInput: Message vide ou désactivé');
return;
}
logger.debug(
'📤 MessageInput: Appel de onSendMessage avec:',
trimmedMessage
);
onSendMessage(trimmedMessage);
setMessage('');
// Arrêter le statut de frappe
if (isTyping) {
setIsTyping(false);
onTypingStop?.();
}
// Effacer le timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
const handleFileSelect = (e) => {
const file = e.target.files[0];
if (file) {
setSelectedFile(file);
setShowFileUpload(true);
}
// Réinitialiser l'input file
e.target.value = '';
};
const handleFileUpload = async (
file,
conversationId,
senderId,
onProgress
) => {
try {
const result = await uploadFile(
file,
conversationId,
senderId,
onProgress
);
// Envoyer un message avec le fichier
onSendMessage('', {
type: 'file',
fileName: file.name,
fileSize: file.size,
fileType: file.type,
fileUrl: result.fileUrl,
});
// Réinitialiser l'état
setSelectedFile(null);
setShowFileUpload(false);
return result;
} catch (error) {
throw error;
}
};
const handleCancelFileUpload = () => {
setSelectedFile(null);
setShowFileUpload(false);
};
// Nettoyage du timeout lors du démontage du composant
useEffect(() => {
return () => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
}, []);
return (
<div className="border-t border-gray-200 bg-white">
{/* Aperçu d'upload de fichier */}
{showFileUpload && selectedFile && (
<div className="p-4 border-b border-gray-200">
<FileUpload
file={selectedFile}
onUpload={handleFileUpload}
onCancel={handleCancelFileUpload}
conversationId={conversationId}
senderId={senderId}
/>
</div>
)}
{/* Zone de saisie */}
<div className="p-4">
<div className="flex items-end space-x-3">
{/* Zone de saisie */}
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={message}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
style={{ minHeight: '44px', maxHeight: '120px' }}
/>
</div>
{/* Boutons à droite (trombone au-dessus, envoi en dessous) */}
<div className="flex flex-col space-y-2 flex-shrink-0">
{/* Bouton d'ajout de fichier */}
<div className="relative">
<input
type="file"
id="file-upload"
className="hidden"
onChange={handleFileSelect}
accept="image/*,application/pdf,.doc,.docx,.xls,.xlsx,.txt"
/>
<label
htmlFor="file-upload"
className="flex items-center justify-center w-10 h-10 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full cursor-pointer transition-colors"
>
<Paperclip className="w-5 h-5" />
</label>
</div>
{/* Bouton d'envoi */}
<button
onClick={handleSend}
disabled={!message.trim() || disabled}
className={`flex items-center justify-center w-10 h-10 rounded-full transition-all ${
message.trim() && !disabled
? 'bg-blue-500 hover:bg-blue-600 text-white shadow-lg hover:shadow-xl'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
>
<Send className="w-5 h-5" />
</button>
</div>
</div>
{/* Indicateur de limite de caractères (optionnel) */}
{message.length > 900 && (
<div className="mt-2 text-right">
<span
className={`text-xs ${
message.length > 1000 ? 'text-red-500' : 'text-yellow-500'
}`}
>
{message.length}/1000
</span>
</div>
)}
</div>
</div>
);
};
export default MessageInput;

View File

@ -1,30 +0,0 @@
import React from 'react';
const TypingIndicator = ({ typingUsers = [] }) => {
if (typingUsers.length === 0) return null;
const getTypingText = () => {
if (typingUsers.length === 1) {
return `${typingUsers[0]} tape un message...`;
} else if (typingUsers.length === 2) {
return `${typingUsers[0]} et ${typingUsers[1]} tapent un message...`;
} else {
return `${typingUsers[0]} et ${typingUsers.length - 1} autres tapent un message...`;
}
};
return (
<div className="flex items-center px-4 py-3 bg-gray-50 border-t border-gray-100 typing-indicator-enter">
<div className="flex space-x-1 mr-3">
<div className="w-2 h-2 bg-blue-400 rounded-full typing-dot"></div>
<div className="w-2 h-2 bg-blue-400 rounded-full typing-dot"></div>
<div className="w-2 h-2 bg-blue-400 rounded-full typing-dot"></div>
</div>
<span className="text-sm text-gray-600 italic animate-pulse">
{getTypingText()}
</span>
</div>
);
};
export default TypingIndicator;

View File

@ -0,0 +1,17 @@
import React, { useEffect } from 'react';
import { DocusealBuilder as OriginalDocusealBuilder } from '@docuseal/react';
const DocusealBuilder = ({ onSave, onSend, ...props }) => {
useEffect(() => {
if (onSave) {
props.save = onSave;
}
if (onSend) {
props.send = onSend;
}
}, [onSave, onSend, props]);
return <OriginalDocusealBuilder {...props} />;
};
export default DocusealBuilder;

View File

@ -17,7 +17,6 @@ import {
fetchRegistrationPaymentPlans,
fetchTuitionPaymentPlans,
} from '@/app/actions/schoolAction';
import { fetchProfiles } from '@/app/actions/authAction';
import { BASE_URL, FE_PARENTS_HOME_URL } from '@/utils/Url';
import logger from '@/utils/logger';
import FilesToUpload from '@/components/Inscription/FilesToUpload';
@ -42,7 +41,6 @@ export default function InscriptionFormShared({
studentId,
csrfToken,
selectedEstablishmentId,
apiDocuseal,
onSubmit,
errors = {}, // Nouvelle prop pour les erreurs
enable = true,
@ -89,8 +87,6 @@ export default function InscriptionFormShared({
// État pour suivre l'index du fichier en cours
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
const [profiles, setProfiles] = useState([]);
const router = useRouter();
// Mettre à jour les états en fonction de la valeur de `enable`
@ -154,7 +150,7 @@ export default function InscriptionFormShared({
}
// Télécharger le template
downloadTemplate(template.slug, selectedEstablishmentId, apiDocuseal)
downloadTemplate(template.slug)
.then((downloadUrl) => fetch(downloadUrl))
.then((response) => {
if (!response.ok) {
@ -216,14 +212,6 @@ export default function InscriptionFormShared({
setUploadedFiles(filteredFiles);
});
fetchProfiles()
.then((data) => {
setProfiles(data);
})
.catch((error) =>
logger.error('Error fetching profiles : ', error)
);
if (selectedEstablishmentId) {
// Fetch data for registration payment modes
handleRegistrationPaymentModes();
@ -504,7 +492,6 @@ export default function InscriptionFormShared({
<ResponsableInputFields
guardians={guardians}
setGuardians={setGuardians}
profiles={profiles}
errors={errors}
setIsPageValid={setIsPage2Valid}
enable={enable}
@ -600,7 +587,7 @@ export default function InscriptionFormShared({
/>
) : (
<iframe
src={`${BASE_URL}${schoolFileTemplates[currentTemplateIndex].file}`}
src={`${BASE_URL}/${schoolFileTemplates[currentTemplateIndex].file}`}
title="Document Viewer"
className="w-full"
style={{

View File

@ -97,20 +97,13 @@ export default function PaymentMethodSelector({
const selectedMode = registrationPaymentModes.find(
(mode) => mode.id === selectedId
);
// Pour le mode de paiement d'inscription
onChange('registration_payment', selectedId);
// Ajoute ou retire isSepa selon le mode choisi
if (selectedMode && selectedMode.mode === 1) {
onChange('isSepa', 1);
} else {
// Vérifie si le mode de paiement scolarité n'est pas SEPA
const tuitionMode = tuitionPaymentModes.find(
(mode) => mode.id === formData.tuition_payment
);
if (!tuitionMode || tuitionMode.mode !== 1) {
onChange('isSepa', 0);
}
}
}}
choices={registrationPaymentModes.map((mode) => ({
value: mode.id, // <-- utiliser l'id du mode de paiement
@ -176,20 +169,13 @@ export default function PaymentMethodSelector({
const selectedMode = tuitionPaymentModes.find(
(mode) => mode.id === selectedId
);
// Pour le mode de paiement de scolarité
onChange('tuition_payment', selectedId);
// Ajoute ou retire isSepa selon le mode choisi
if (selectedMode && selectedMode.mode === 1) {
onChange('isSepa', 1);
} else {
// Vérifie si le mode de paiement inscription n'est pas SEPA
const registrationMode = registrationPaymentModes.find(
(mode) => mode.id === formData.registration_payment
);
if (!registrationMode || registrationMode.mode !== 1) {
onChange('isSepa', 0);
}
}
}}
choices={tuitionPaymentModes.map((mode) => ({
value: mode.id,

View File

@ -5,12 +5,10 @@ import { useTranslations } from 'next-intl';
import { Trash2, Plus, Users } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader';
import { useEstablishment } from '@/context/EstablishmentContext';
import logger from '@/utils/logger';
export default function ResponsableInputFields({
guardians,
setGuardians,
profiles,
errors,
setIsPageValid,
enable = true,
@ -72,17 +70,8 @@ export default function ResponsableInputFields({
// Synchroniser profile_data.email et profile_data.username avec associated_profile_email
if (field === 'associated_profile_email') {
const existingProfile = profiles?.find(
(profile) => profile.email === value
);
if (existingProfile) {
updatedGuardian.profile_role_data.profile = existingProfile.id;
delete updatedGuardian.profile_role_data.profile_data;
} else {
updatedGuardian.profile_role_data.profile_data.email = value;
updatedGuardian.profile_role_data.profile_data.username = value;
delete updatedGuardian.profile_role_data.profile;
}
}
return updatedGuardian;

View File

@ -47,6 +47,7 @@ const Popup = ({
onClick={() => {
if (setIsOpen) setIsOpen(false);
else if (onCancel) onCancel();
if (onCancel) onCancel();
}}
>
Annuler
@ -57,6 +58,7 @@ const Popup = ({
onClick={() => {
if (setIsOpen) setIsOpen(false);
else if (onConfirm) onConfirm();
if (onConfirm) onConfirm();
}}
>
{uniqueConfirmButton ? 'Fermer' : 'Confirmer'}

View File

@ -3,7 +3,6 @@ import { LogOut } from 'lucide-react';
import { disconnect } from '@/app/actions/authAction';
import { getGravatarUrl } from '@/utils/gravatar';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useChatConnection } from '@/context/ChatConnectionContext';
import DropdownMenu from '@/components/DropdownMenu';
import { usePopup } from '@/context/PopupContext';
import { getRightStr } from '@/utils/rights';
@ -18,10 +17,7 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
setSelectedEstablishmentId,
setProfileRole,
user,
setSelectedEstablishmentEvaluationFrequency,
setSelectedEstablishmentTotalCapacity,
} = useEstablishment();
const { isConnected, connectionStatus } = useChatConnection();
const [dropdownOpen, setDropdownOpen] = useState(false);
const { showPopup } = usePopup();
@ -29,16 +25,8 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
// Pas bon quand on a plusieur role pour le même établissement
const role = user.roles[roleId].role_type;
const establishmentId = user.roles[roleId].establishment__id;
const establishmentEvaluationFrequency =
user.roles[roleId].establishment__evaluation_frequency;
const establishmentTotalCapacity =
user.roles[roleId].establishment__total_capacity;
setProfileRole(role);
setSelectedEstablishmentId(establishmentId);
setSelectedEstablishmentEvaluationFrequency(
establishmentEvaluationFrequency
);
setSelectedEstablishmentTotalCapacity(establishmentTotalCapacity);
setSelectedRoleId(roleId);
if (onRoleChange) {
onRoleChange(roleId);
@ -62,36 +50,6 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
(est) => est.role_id === selectedRoleId
);
// Fonction pour obtenir la couleur de la bulle de statut
const getStatusColor = () => {
switch (connectionStatus) {
case 'connected':
return 'bg-green-500';
case 'connecting':
return 'bg-yellow-500';
case 'error':
return 'bg-red-500';
case 'disconnected':
default:
return 'bg-gray-400';
}
};
// Fonction pour obtenir le titre de la bulle de statut
const getStatusTitle = () => {
switch (connectionStatus) {
case 'connected':
return 'Chat connecté';
case 'connecting':
return 'Connexion au chat...';
case 'error':
return 'Erreur de connexion au chat';
case 'disconnected':
default:
return 'Chat déconnecté';
}
};
// Suppression du tronquage JS, on utilise uniquement CSS
const isSingleRole = establishments && establishments.length === 1;
@ -100,20 +58,13 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
<DropdownMenu
buttonContent={
<div className="h-16 flex items-center gap-2 cursor-pointer px-4 bg-white">
<div className="relative">
<Image
src={getGravatarUrl(user?.email)}
alt="Profile"
className="w-10 h-10 rounded-full object-cover shadow-md"
className="w-8 h-8 rounded-full mr-2"
width={32}
height={32}
/>
{/* Bulle de statut de connexion au chat */}
<div
className={`absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-white ${getStatusColor()}`}
title={getStatusTitle()}
/>
</div>
<div className="flex-1 min-w-0">
<div
className="font-bold text-left truncate max-w-full"

View File

@ -6,7 +6,6 @@ import { NextIntlClientProvider } from 'next-intl';
import { EstablishmentProvider } from '@/context/EstablishmentContext';
import { NotificationProvider } from '@/context/NotificationContext';
import { ClassesProvider } from '@/context/ClassesContext';
import { ChatConnectionProvider } from '@/context/ChatConnectionContext';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import logger from '@/utils/logger';
@ -24,13 +23,11 @@ export default function Providers({ children, messages, locale, session }) {
<CsrfProvider>
<EstablishmentProvider>
<ClassesProvider>
<ChatConnectionProvider>
<PopupProvider>
<NextIntlClientProvider messages={messages} locale={locale}>
{children}
</NextIntlClientProvider>
</PopupProvider>
</ChatConnectionProvider>
</ClassesProvider>
</EstablishmentProvider>
</CsrfProvider>

View File

@ -31,7 +31,7 @@ const SidebarTabs = ({ tabs, onTabChange }) => {
</div>
{/* Tabs Content */}
<div className="flex-1 flex flex-col overflow-hidden rounded-b-lg shadow-inner">
<div className="flex-1 overflow-y-auto rounded-b-lg shadow-inner relative">
<AnimatePresence mode="wait">
{tabs.map(
(tab) =>
@ -42,7 +42,7 @@ const SidebarTabs = ({ tabs, onTabChange }) => {
animate={{ opacity: 1, x: 0 }} // Animation visible
exit={{ opacity: 0, x: -50 }} // Animation de sortie
transition={{ duration: 0.3 }} // Durée des animations
className="flex-1 flex flex-col h-full min-h-0"
className="absolute w-full h-full"
>
{tab.content}
</motion.div>

View File

@ -30,7 +30,7 @@ export default function FileUploadDocuSeal({
const csrfToken = useCsrfToken();
const { selectedEstablishmentId, user, apiDocuseal } = useEstablishment();
const { selectedEstablishmentId } = useEstablishment();
useEffect(() => {
fetchRegistrationFileGroups(selectedEstablishmentId).then((data) =>
@ -44,12 +44,10 @@ export default function FileUploadDocuSeal({
}, [fileToEdit]);
useEffect(() => {
if (!user && !user?.email) {
return;
}
const email = 'n3wt.school@gmail.com';
const id = fileToEdit ? fileToEdit.id : null;
generateToken(user?.email, id, selectedEstablishmentId, apiDocuseal)
generateToken(email, id)
.then((data) => {
setToken(data.token);
})
@ -121,7 +119,7 @@ export default function FileUploadDocuSeal({
guardianDetails.forEach((guardian, index) => {
logger.debug('creation du clone avec required : ', is_required);
cloneTemplate(templateMaster?.id, guardian.email, is_required, selectedEstablishmentId, apiDocuseal)
cloneTemplate(templateMaster?.id, guardian.email, is_required)
.then((clonedDocument) => {
// Sauvegarde des schoolFileTemplates clonés dans la base de données
const data = {

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Download, Edit3, Trash2, FolderPlus, Signature, AlertTriangle } from 'lucide-react';
import { Download, Edit3, Trash2, FolderPlus, Signature } from 'lucide-react';
import Modal from '@/components/Modal';
import Table from '@/components/Table';
import FileUploadDocuSeal from '@/components/Structure/Files/FileUploadDocuSeal';
@ -22,8 +22,6 @@ import {
deleteRegistrationFileGroup,
deleteRegistrationSchoolFileMaster,
deleteRegistrationParentFileMaster,
removeTemplate
} from '@/app/actions/registerFileGroupAction';
import RegistrationFileGroupForm from '@/components/Structure/Files/RegistrationFileGroupForm';
import logger from '@/utils/logger';
@ -37,7 +35,6 @@ import AlertMessage from '@/components/AlertMessage';
export default function FilesGroupsManagement({
csrfToken,
selectedEstablishmentId,
apiDocuseal
}) {
const [schoolFileMasters, setSchoolFileMasters] = useState([]);
const [schoolFileTemplates, setSchoolFileTemplates] = useState([]);
@ -117,19 +114,17 @@ export default function FilesGroupsManagement({
setRemovePopupOnConfirm(() => () => {
setIsLoading(true);
// Supprimer les clones associés via l'API DocuSeal
const removeClonesPromises = [
...schoolFileTemplates
const removeClonesPromises = schoolFileTemplates
.filter((template) => template.master === templateMaster.id)
.map((template) =>
removeTemplate(template.id, selectedEstablishmentId, apiDocuseal)
),
removeTemplate(templateMaster.id, selectedEstablishmentId, apiDocuseal),
];
.map((template) => removeTemplate(template.id));
// Ajouter la suppression du master à la liste des promesses
removeClonesPromises.push(removeTemplate(templateMaster.id));
// Attendre que toutes les suppressions dans DocuSeal soient terminées
Promise.all(removeClonesPromises)
.then((responses) => {
const allSuccessful = responses.every((response) => response && response.id);
const allSuccessful = responses.every((response) => response.ok);
if (allSuccessful) {
logger.debug('Master et clones supprimés avec succès de DocuSeal.');
@ -193,6 +188,31 @@ export default function FilesGroupsManagement({
});
};
const removeTemplate = (templateId) => {
return fetch('/api/docuseal/removeTemplate/', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
templateId,
}),
})
.then((response) => {
if (!response.ok) {
return response.json().then((err) => {
throw new Error(err.message);
});
}
return response;
})
.catch((error) => {
logger.error('Error removing template:', error);
throw error;
});
};
const editTemplateMaster = (file) => {
setIsEditing(true);
setFileToEdit(file);
@ -542,25 +562,13 @@ export default function FilesGroupsManagement({
icon={Signature}
title="Formulaires à remplir"
description="Gérez les formulaires nécessitant une signature électronique."
button={apiDocuseal}
button={true}
buttonOpeningModal={true}
onClick={() => {
setIsModalOpen(true);
setIsEditing(false);
}}
/>
<div className="mb-4">
<span
className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold ${
!apiDocuseal && 'bg-red-100 text-red-700 border border-red-300'
}`}
>
{!apiDocuseal && (
<AlertTriangle className="w-4 h-4 mr-2 text-red-500" />
)}
{!apiDocuseal && 'Clé API Docuseal manquante'}
</span>
</div>
<Table
data={filteredFiles}
columns={columnsFiles}

View File

@ -1,270 +0,0 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useRef,
useCallback,
} from 'react';
import { useSession } from 'next-auth/react';
import logger from '@/utils/logger';
import { WS_CHAT_URL } from '@/utils/Url';
const ChatConnectionContext = createContext();
export const ChatConnectionProvider = ({ children }) => {
const { data: session, status } = useSession(); // Ajouter le hook useSession
const [isConnected, setIsConnected] = useState(false);
const [connectionStatus, setConnectionStatus] = useState('disconnected'); // 'disconnected', 'connecting', 'connected', 'error'
const [userPresences, setUserPresences] = useState({}); // Nouvel état pour les présences
const websocketRef = useRef(null);
const reconnectTimeoutRef = useRef(null);
const [reconnectAttempts, setReconnectAttempts] = useState(0);
const [currentUserId, setCurrentUserId] = useState(null);
const maxReconnectAttempts = 5;
// Système de callbacks pour les messages
const messageCallbacksRef = useRef(new Set());
// Fonctions pour gérer les callbacks de messages
const addMessageCallback = useCallback((callback) => {
messageCallbacksRef.current.add(callback);
return () => {
messageCallbacksRef.current.delete(callback);
};
}, []);
const notifyMessageCallbacks = useCallback((data) => {
messageCallbacksRef.current.forEach((callback) => {
try {
callback(data);
} catch (error) {
logger.error('ChatConnection: Error in message callback', error);
}
});
}, []);
// Gestion des présences utilisateur
const handlePresenceUpdate = useCallback((data) => {
const { user_id, status } = data;
setUserPresences((prev) => ({
...prev,
[user_id]: { status },
}));
}, []);
// Configuration WebSocket
const getWebSocketUrl = (userId) => {
if (!userId) {
logger.warn('ChatConnection: No user ID provided for WebSocket URL');
return null;
}
// Récupérer le token d'authentification depuis NextAuth session
const token = session?.user?.token;
if (!token) {
logger.warn(
'ChatConnection: No access token found for WebSocket connection'
);
return null;
}
// Construire l'URL WebSocket avec le token
const baseUrl = WS_CHAT_URL(userId);
const wsUrl = `${baseUrl}?token=${encodeURIComponent(token)}`;
return wsUrl;
};
// Connexion WebSocket
const connectToChat = (userId = null) => {
const userIdToUse = userId || currentUserId;
// Vérifier que la session est chargée
if (status === 'loading') {
setConnectionStatus('connecting');
return;
}
if (status === 'unauthenticated' || !session) {
logger.warn('ChatConnection: User not authenticated');
setConnectionStatus('error');
return;
}
if (!userIdToUse) {
logger.warn('ChatConnection: Cannot connect without user ID');
setConnectionStatus('error');
return;
}
if (websocketRef.current?.readyState === WebSocket.OPEN) {
return;
}
setCurrentUserId(userIdToUse);
setConnectionStatus('connecting');
try {
const wsUrl = getWebSocketUrl(userIdToUse);
if (!wsUrl) {
throw new Error(
'Cannot generate WebSocket URL - missing token or user ID'
);
}
websocketRef.current = new WebSocket(wsUrl);
websocketRef.current.onopen = () => {
logger.info(
'ChatConnection: Connected successfully for user:',
userIdToUse
);
setIsConnected(true);
setConnectionStatus('connected');
setReconnectAttempts(0);
};
websocketRef.current.onclose = (event) => {
setIsConnected(false);
setConnectionStatus('disconnected');
// Tentative de reconnexion automatique
if (reconnectAttempts < maxReconnectAttempts && !event.wasClean) {
const timeout = Math.min(
1000 * Math.pow(2, reconnectAttempts),
30000
);
reconnectTimeoutRef.current = setTimeout(() => {
setReconnectAttempts((prev) => prev + 1);
connectToChat();
}, timeout);
}
};
websocketRef.current.onerror = (error) => {
logger.error('ChatConnection: WebSocket error', error);
setConnectionStatus('error');
setIsConnected(false);
};
websocketRef.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Gérer les messages de présence
if (data.type === 'presence_update') {
handlePresenceUpdate(data);
}
// Notifier tous les callbacks enregistrés
notifyMessageCallbacks(data);
} catch (error) {
logger.error('ChatConnection: Error parsing message', error);
}
};
} catch (error) {
logger.error('ChatConnection: Error creating WebSocket', error);
setConnectionStatus('error');
setIsConnected(false);
}
};
// Déconnexion WebSocket
const disconnectFromChat = () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (websocketRef.current) {
websocketRef.current.close(1000, 'User disconnected');
websocketRef.current = null;
}
setIsConnected(false);
setConnectionStatus('disconnected');
setReconnectAttempts(0);
logger.info('ChatConnection: Disconnected by user');
};
// Envoi de message
const sendMessage = (message) => {
if (websocketRef.current?.readyState === WebSocket.OPEN) {
const messageStr = JSON.stringify(message);
websocketRef.current.send(messageStr);
return true;
} else {
logger.warn('ChatConnection: Cannot send message - not connected');
return false;
}
};
// Obtenir la référence WebSocket pour les composants qui en ont besoin
const getWebSocket = () => websocketRef.current;
// Effet pour la gestion de la session et connexion automatique
useEffect(() => {
// Si la session change vers authenticated et qu'on a un user_id, essayer de se connecter
if (status === 'authenticated' && session?.user?.user_id && !isConnected) {
connectToChat(session.user.user_id);
}
// Si la session devient unauthenticated, déconnecter
if (status === 'unauthenticated' && isConnected) {
disconnectFromChat();
}
}, [
status,
session?.user?.user_id,
isConnected,
connectToChat,
disconnectFromChat,
]);
// Nettoyage à la destruction du composant
useEffect(() => {
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (websocketRef.current) {
websocketRef.current.close();
}
};
}, []);
const value = {
isConnected,
connectionStatus,
userPresences, // Ajouter les présences utilisateur
connectToChat,
disconnectFromChat,
sendMessage,
getWebSocket,
reconnectAttempts,
maxReconnectAttempts,
addMessageCallback, // Ajouter cette fonction
};
return (
<ChatConnectionContext.Provider value={value}>
{children}
</ChatConnectionContext.Provider>
);
};
export const useChatConnection = () => {
const context = useContext(ChatConnectionContext);
if (!context) {
throw new Error(
'useChatConnection must be used within a ChatConnectionProvider'
);
}
return context;
};
export default ChatConnectionContext;

View File

@ -26,7 +26,7 @@ export const EstablishmentProvider = ({ children }) => {
setSelectedEstablishmentTotalCapacityState,
] = useState(() => {
const storedEstablishmentTotalCapacity = +sessionStorage.getItem(
'selectedEstablishmentTotalCapacity'
'setSelectedEstablishmentTotalCapacity'
);
return storedEstablishmentTotalCapacity;
});
@ -46,10 +46,6 @@ export const EstablishmentProvider = ({ children }) => {
const storedUser = sessionStorage.getItem('user');
return storedUser ? JSON.parse(storedUser) : null;
});
const [apiDocuseal, setApiDocusealState] = useState(() => {
const storedApiDocuseal = sessionStorage.getItem('apiDocuseal');
return storedApiDocuseal ? JSON.parse(storedApiDocuseal) : null;
});
// Sauvegarder dans sessionStorage à chaque mise à jour
const setSelectedEstablishmentId = (id) => {
@ -90,11 +86,6 @@ export const EstablishmentProvider = ({ children }) => {
sessionStorage.setItem('user', JSON.stringify(user));
};
const setApiDocuseal = (api) => {
setApiDocusealState(api);
sessionStorage.setItem('apiDocuseal', JSON.stringify(api));
};
/**
* Fonction d'initialisation du contexte avec la session (appelée lors du login)
* @param {*} session
@ -113,7 +104,6 @@ export const EstablishmentProvider = ({ children }) => {
name: role.establishment__name,
evaluation_frequency: role.establishment__evaluation_frequency,
total_capacity: role.establishment__total_capacity,
api_docuseal: role.establishment__api_docuseal,
role_id: i,
role_type: role.role_type,
}));
@ -133,9 +123,6 @@ export const EstablishmentProvider = ({ children }) => {
setSelectedEstablishmentTotalCapacity(
userEstablishments[roleIndexDefault].total_capacity
);
setApiDocuseal(
userEstablishments[roleIndexDefault].api_docuseal
);
setProfileRole(userEstablishments[roleIndexDefault].role_type);
}
if (endInitFunctionHandler) {
@ -153,9 +140,6 @@ export const EstablishmentProvider = ({ children }) => {
setProfileRoleState(null);
setEstablishmentsState([]);
setUserState(null);
setSelectedEstablishmentEvaluationFrequencyState(null);
setSelectedEstablishmentTotalCapacityState(null);
setApiDocusealState(null);
sessionStorage.clear();
};
@ -170,8 +154,6 @@ export const EstablishmentProvider = ({ children }) => {
setSelectedEstablishmentEvaluationFrequency,
selectedEstablishmentTotalCapacity,
setSelectedEstablishmentTotalCapacity,
apiDocuseal,
setApiDocuseal,
selectedRoleId,
setSelectedRoleId,
profileRole,

View File

@ -1,90 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Animations pour le chat */
@keyframes typing-bounce {
0%,
60%,
100% {
transform: translateY(0);
opacity: 0.7;
}
30% {
transform: translateY(-8px);
opacity: 1;
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.typing-dot {
animation: typing-bounce 1.4s ease-in-out infinite;
}
.typing-dot:nth-child(1) {
animation-delay: 0s;
}
.typing-dot:nth-child(2) {
animation-delay: 0.2s;
}
.typing-dot:nth-child(3) {
animation-delay: 0.4s;
}
.typing-indicator-enter {
animation: fade-in 0.3s ease-out;
}
/* Améliorations visuelles pour les accusés de lecture */
.read-indicator {
transition: all 0.2s ease-in-out;
}
.read-indicator.read {
color: #60a5fa; /* Bleu plus visible pour les messages lus */
}
.read-indicator.sent {
color: #93c5fd; /* Bleu plus clair pour les messages envoyés */
}
/* Animation pour l'apparition des nouveaux messages */
.message-appear {
animation: fade-in 0.3s ease-out;
}
/* Styles Discord-like pour les messages */
.message-container:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.message-container:hover .message-timestamp {
opacity: 1;
}
.message-container:hover .message-actions {
opacity: 1;
}
.message-timestamp {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.message-actions {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}

View File

@ -1,249 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useSession } from 'next-auth/react';
import { WS_CHAT_URL } from '@/utils/Url';
import logger from '@/utils/logger';
const useWebSocket = (userId, onMessage, onConnectionChange) => {
const [isConnected, setIsConnected] = useState(false);
const [connectionStatus, setConnectionStatus] = useState('disconnected');
const wsRef = useRef(null);
const reconnectTimeoutRef = useRef(null);
const reconnectAttemptsRef = useRef(0);
const isConnectingRef = useRef(false); // Empêcher les connexions multiples
const maxReconnectAttempts = 5;
// Récupération du token JWT
const { data: session } = useSession();
const authToken = session?.user?.token;
// Références stables pour les callbacks
const onMessageRef = useRef(onMessage);
const onConnectionChangeRef = useRef(onConnectionChange);
useEffect(() => {
onMessageRef.current = onMessage;
}, [onMessage]);
useEffect(() => {
onConnectionChangeRef.current = onConnectionChange;
}, [onConnectionChange]);
const connect = useCallback(() => {
if (!userId || !authToken) {
logger.warn('WebSocket: userId ou token manquant');
return;
}
// Empêcher les connexions multiples simultanées
if (
isConnectingRef.current ||
(wsRef.current && wsRef.current.readyState === WebSocket.CONNECTING)
) {
logger.debug('WebSocket: connexion déjà en cours');
return;
}
// Fermer la connexion existante si elle existe
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
isConnectingRef.current = true;
try {
// Ajouter le token à l'URL du WebSocket
const wsUrl = new URL(WS_CHAT_URL(userId));
wsUrl.searchParams.append('token', authToken);
wsRef.current = new WebSocket(wsUrl.toString());
wsRef.current.onopen = () => {
logger.debug('WebSocket connecté');
isConnectingRef.current = false;
setIsConnected(true);
setConnectionStatus('connected');
reconnectAttemptsRef.current = 0;
onConnectionChangeRef.current?.(true);
};
wsRef.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
onMessageRef.current?.(data);
} catch (error) {
logger.error('Erreur lors du parsing du message WebSocket:', error);
}
};
wsRef.current.onclose = (event) => {
logger.debug('WebSocket fermé:', event.code, event.reason);
isConnectingRef.current = false;
setIsConnected(false);
setConnectionStatus('disconnected');
onConnectionChangeRef.current?.(false);
// Tentative de reconnexion automatique seulement si la fermeture n'est pas intentionnelle
if (
event.code !== 1000 &&
reconnectAttemptsRef.current < maxReconnectAttempts
) {
reconnectAttemptsRef.current++;
setConnectionStatus('reconnecting');
const delay = Math.min(
1000 * Math.pow(2, reconnectAttemptsRef.current),
30000
);
reconnectTimeoutRef.current = setTimeout(() => {
logger.debug(
`Tentative de reconnexion ${reconnectAttemptsRef.current}/${maxReconnectAttempts}`
);
connect();
}, delay);
} else {
setConnectionStatus('failed');
}
};
wsRef.current.onerror = (error) => {
logger.error('Erreur WebSocket:', error);
isConnectingRef.current = false;
setConnectionStatus('error');
};
} catch (error) {
logger.error('Erreur lors de la création du WebSocket:', error);
isConnectingRef.current = false;
setConnectionStatus('error');
}
}, [userId, authToken]);
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
setConnectionStatus('disconnected');
}, []);
const sendMessage = useCallback(
(message) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
// Ajouter le token à chaque message
const messageWithAuth = {
...message,
token: authToken,
};
wsRef.current.send(JSON.stringify(messageWithAuth));
return true;
} else {
logger.warn("WebSocket non connecté, impossible d'envoyer le message");
return false;
}
},
[authToken]
);
const sendTypingStart = useCallback(
(conversationId) => {
sendMessage({
type: 'typing_start',
conversation_id: conversationId,
});
},
[sendMessage]
);
const sendTypingStop = useCallback(
(conversationId) => {
sendMessage({
type: 'typing_stop',
conversation_id: conversationId,
});
},
[sendMessage]
);
const markAsRead = useCallback(
(conversationId) => {
sendMessage({
type: 'mark_as_read',
conversation_id: conversationId,
});
},
[sendMessage]
);
const joinConversation = useCallback(
(conversationId) => {
sendMessage({
type: 'join_conversation',
conversation_id: conversationId,
});
},
[sendMessage]
);
const leaveConversation = useCallback(
(conversationId) => {
sendMessage({
type: 'leave_conversation',
conversation_id: conversationId,
});
},
[sendMessage]
);
const sendChatMessage = useCallback(
(conversationId, content, attachment = null) => {
const messageData = {
type: 'send_message',
conversation_id: conversationId,
content: content,
message_type: attachment ? 'file' : 'text',
};
// Ajouter les informations du fichier si présent
if (attachment) {
messageData.attachment = attachment;
}
return sendMessage(messageData);
},
[sendMessage]
);
useEffect(() => {
// Se connecter seulement si on a un userId et un token
if (userId && authToken) {
connect();
}
return () => {
disconnect();
};
}, [userId, authToken]); // Retirer connect et disconnect des dépendances
return {
isConnected,
connectionStatus,
sendMessage,
sendTypingStart,
sendTypingStop,
markAsRead,
joinConversation,
leaveConversation,
sendChatMessage,
reconnect: connect,
disconnect,
};
};
export default useWebSocket;

View File

@ -3,19 +3,18 @@ import { BE_DOCUSEAL_CLONE_TEMPLATE } from '@/utils/Url';
export default function handler(req, res) {
if (req.method === 'POST') {
const { templateId, email, is_required, establishment_id, apiDocuseal } = req.body;
const { templateId, email, is_required } = req.body;
fetch(BE_DOCUSEAL_CLONE_TEMPLATE, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': apiDocuseal,
'X-Auth-Token': process.env.DOCUSEAL_API_KEY,
},
body: JSON.stringify({
templateId,
email,
is_required,
establishment_id,
}),
})
.then((response) => {

View File

@ -3,13 +3,13 @@ import { BE_DOCUSEAL_DOWNLOAD_TEMPLATE } from '@/utils/Url';
export default function handler(req, res) {
if (req.method === 'GET') {
const { slug, establishment_id, apiDocuseal } = req.query;
const { slug } = req.query;
logger.debug('slug : ', slug);
fetch(`${BE_DOCUSEAL_DOWNLOAD_TEMPLATE}/${slug}?establishment_id=${establishment_id}`, {
fetch(`${BE_DOCUSEAL_DOWNLOAD_TEMPLATE}/${slug}`, {
method: 'GET',
headers: {
'X-Auth-Token': apiDocuseal,
'X-Auth-Token': process.env.DOCUSEAL_API_KEY,
},
})
.then((response) => {

View File

@ -3,15 +3,13 @@ import { BE_DOCUSEAL_GET_JWT } from '@/utils/Url';
export default function handler(req, res) {
if (req.method === 'POST') {
const { apiDocuseal, ...rest } = req.body;
fetch(BE_DOCUSEAL_GET_JWT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': apiDocuseal,
'X-Auth-Token': process.env.DOCUSEAL_API_KEY,
},
body: JSON.stringify(rest),
body: JSON.stringify(req.body),
})
.then((response) => {
logger.debug('Response status:', response.status);

View File

@ -3,12 +3,12 @@ import { BE_DOCUSEAL_REMOVE_TEMPLATE } from '@/utils/Url';
export default function handler(req, res) {
if (req.method === 'DELETE') {
const { templateId, establishment_id, apiDocuseal } = req.body;
const { templateId } = req.body;
fetch(`${BE_DOCUSEAL_REMOVE_TEMPLATE}/${templateId}?establishment_id=${establishment_id}`, {
fetch(`${BE_DOCUSEAL_REMOVE_TEMPLATE}/${templateId}`, {
method: 'DELETE',
headers: {
'X-Auth-Token': apiDocuseal,
'X-Auth-Token': process.env.DOCUSEAL_API_KEY,
},
})
.then((response) => {

View File

@ -1,63 +0,0 @@
/* Animations pour le chat */
@keyframes typing-bounce {
0%,
60%,
100% {
transform: translateY(0);
opacity: 0.7;
}
30% {
transform: translateY(-8px);
opacity: 1;
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.typing-dot {
animation: typing-bounce 1.4s ease-in-out infinite;
}
.typing-dot:nth-child(1) {
animation-delay: 0s;
}
.typing-dot:nth-child(2) {
animation-delay: 0.2s;
}
.typing-dot:nth-child(3) {
animation-delay: 0.4s;
}
.typing-indicator-enter {
animation: fade-in 0.3s ease-out;
}
/* Améliorations visuelles pour les accusés de lecture */
.read-indicator {
transition: all 0.2s ease-in-out;
}
.read-indicator.read {
color: #60a5fa; /* Bleu plus visible pour les messages lus */
}
.read-indicator.sent {
color: #93c5fd; /* Bleu plus clair pour les messages envoyés */
}
/* Animation pour l'apparition des nouveaux messages */
.message-appear {
animation: fade-in 0.3s ease-out;
}

View File

@ -1,6 +1,5 @@
import { RIGHTS } from '@/utils/rights';
export const BASE_URL = process.env.NEXT_PUBLIC_API_URL;
export const WS_BASE_URL = process.env.NEXT_PUBLIC_WSAPI_URL;
//URL-Back-End
@ -54,28 +53,16 @@ export const BE_SCHOOL_ESTABLISHMENT_URL = `${BASE_URL}/Establishment/establishm
export const BE_PLANNING_PLANNINGS_URL = `${BASE_URL}/Planning/plannings`;
export const BE_PLANNING_EVENTS_URL = `${BASE_URL}/Planning/events`;
// GESTION EMAIL
export const BE_GESTIONEMAIL_SEND_EMAIL_URL = `${BASE_URL}/GestionEmail/send-email/`;
export const BE_GESTIONEMAIL_SEARCH_RECIPIENTS_URL = `${BASE_URL}/GestionEmail/search-recipients`;
// GESTION MESSAGERIE
export const BE_GESTIONMESSAGERIE_CONVERSATIONS_URL = `${BASE_URL}/GestionMessagerie/conversations`;
export const BE_GESTIONMESSAGERIE_CONVERSATION_MESSAGES_URL = `${BASE_URL}/GestionMessagerie/conversations/messages`;
export const BE_GESTIONMESSAGERIE_MARK_AS_READ_URL = `${BASE_URL}/GestionMessagerie/conversations/mark-as-read`;
export const BE_GESTIONMESSAGERIE_DELETE_CONVERSATION_URL = `${BASE_URL}/GestionMessagerie/conversations`;
export const BE_GESTIONMESSAGERIE_MESSAGES_URL = `${BASE_URL}/GestionMessagerie/messages`;
export const BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL = `${BASE_URL}/GestionMessagerie/send-message`;
export const BE_GESTIONMESSAGERIE_CREATE_CONVERSATION_URL = `${BASE_URL}/GestionMessagerie/create-conversation/`;
export const BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL = `${BASE_URL}/GestionMessagerie/send-email/`;
export const BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL = `${BASE_URL}/GestionMessagerie/search-recipients`;
export const BE_GESTIONMESSAGERIE_UPLOAD_FILE_URL = `${BASE_URL}/GestionMessagerie/upload-file/`;
// WEBSOCKET MESSAGERIE
export const WS_CHAT_URL = (userId) => {
return `${WS_BASE_URL}/ws/chat/${userId}/`;
};
// SETTINGS
export const BE_SETTINGS_SMTP_URL = `${BASE_URL}/Settings/smtp-settings`;
export const BE_SETTINGS_MAIL_URL = `${BASE_URL}/Settings/mail-settings`;
// URL FRONT-END
export const FE_HOME_URL = '/';
@ -136,7 +123,6 @@ export const FE_PARENTS_EDIT_SUBSCRIPTION_URL = '/parents/editSubscription';
export const FE_API_DOCUSEAL_GENERATE_TOKEN = '/api/docuseal/generateToken';
export const FE_API_DOCUSEAL_CLONE_URL = '/api/docuseal/cloneTemplate';
export const FE_API_DOCUSEAL_DOWNLOAD_URL = '/api/docuseal/downloadTemplate';
export const FE_API_DOCUSEAL_DELETE_URL = '/api/docuseal/removeTemplate';
/**
* Fonction pour obtenir l'URL de redirection en fonction du rôle

View File

@ -1,18 +1,6 @@
# N3wt School
Logiciel de gestion d'école avec système de messagerie instantanée intégré
## ✨ Nouvelles Fonctionnalités
### 📱 Messagerie Instantanée (Nouveau)
- **Chat temps réel** avec WebSocket
- **Upload de fichiers** avec prévisualisation
- **Indicateurs de présence** (en ligne/hors ligne)
- **Messages lus/non lus** avec compteurs
- **Interface moderne** avec composants React
Voir la [documentation complète](./docs/messagerie-instantanee.md) pour plus de détails.
Logiciel de gestion d'école
## Maquette

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