mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-01-28 15:33:22 +00:00
Merge pull request 'feat: mise en place de la messagerie [#17]' (#59) from feat-gestion_messagerie-17 into develop
Reviewed-on: https://git.v0id.ovh/n3wt-innov/n3wt-school/pulls/59
This commit is contained in:
7
.github/copilot-instructions.md
vendored
7
.github/copilot-instructions.md
vendored
@ -35,6 +35,13 @@ 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é
|
||||
|
||||
0
.github/instructions/frontend.instruction.md
vendored
Normal file
0
.github/instructions/frontend.instruction.md
vendored
Normal file
24
.vscode/tasks.json
vendored
24
.vscode/tasks.json
vendored
@ -1,13 +1,13 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "dev",
|
||||
"path": "Front-End",
|
||||
"problemMatcher": [],
|
||||
"label": "npm: dev - Front-End",
|
||||
"detail": "next dev"
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Start Frontend Dev Server",
|
||||
"type": "shell",
|
||||
"command": "npm run dev",
|
||||
"group": "build",
|
||||
"isBackground": true,
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,61 +0,0 @@
|
||||
{% 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>
|
||||
@ -1,64 +0,0 @@
|
||||
{% 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>
|
||||
@ -1,37 +0,0 @@
|
||||
{% 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>
|
||||
@ -1,64 +0,0 @@
|
||||
{% 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>
|
||||
1
Back-End/GestionEmail/__init__.py
Normal file
1
Back-End/GestionEmail/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
default_app_config = 'GestionEmail.apps.GestionEmailConfig'
|
||||
5
Back-End/GestionEmail/apps.py
Normal file
5
Back-End/GestionEmail/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class GestionEmailConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'GestionEmail'
|
||||
9
Back-End/GestionEmail/urls.py
Normal file
9
Back-End/GestionEmail/urls.py
Normal file
@ -0,0 +1,9 @@
|
||||
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'),
|
||||
]
|
||||
119
Back-End/GestionEmail/views.py
Normal file
119
Back-End/GestionEmail/views.py
Normal file
@ -0,0 +1,119 @@
|
||||
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)
|
||||
627
Back-End/GestionMessagerie/consumers.py
Normal file
627
Back-End/GestionMessagerie/consumers.py
Normal file
@ -0,0 +1,627 @@
|
||||
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)}")
|
||||
108
Back-End/GestionMessagerie/middleware.py
Normal file
108
Back-End/GestionMessagerie/middleware.py
Normal file
@ -0,0 +1,108 @@
|
||||
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)
|
||||
@ -1,7 +1,104 @@
|
||||
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)
|
||||
|
||||
7
Back-End/GestionMessagerie/routing.py
Normal file
7
Back-End/GestionMessagerie/routing.py
Normal file
@ -0,0 +1,7 @@
|
||||
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()),
|
||||
]
|
||||
@ -1,15 +1,266 @@
|
||||
from rest_framework import serializers
|
||||
from Auth.models import Profile
|
||||
from GestionMessagerie.models import Messagerie
|
||||
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']
|
||||
|
||||
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:
|
||||
model = Messagerie
|
||||
fields = '__all__'
|
||||
read_only_fields = ['date_envoi']
|
||||
|
||||
|
||||
def get_destinataire_profil(self, obj):
|
||||
return obj.destinataire.email
|
||||
|
||||
|
||||
@ -1,15 +1,22 @@
|
||||
from django.urls import path, re_path
|
||||
from .views import SendEmailView, search_recipients, ConversationListView, ConversationMessagesView, MarkAsReadView
|
||||
from GestionMessagerie.views import MessagerieView, MessageView, MessageSimpleView
|
||||
from django.urls import path
|
||||
from .views import (
|
||||
InstantConversationListView, InstantConversationCreateView, InstantConversationDeleteView,
|
||||
InstantMessageListView, InstantMessageCreateView,
|
||||
InstantMarkAsReadView, FileUploadView,
|
||||
InstantRecipientSearchView
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
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'),
|
||||
# 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'),
|
||||
]
|
||||
@ -1,211 +1,455 @@
|
||||
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 django.db.models import Q
|
||||
from .models import Messagerie
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
from django.db import models
|
||||
from .models import Conversation, ConversationParticipant, Message, UserPresence
|
||||
from Auth.models import Profile, ProfileRole
|
||||
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 GestionMessagerie.serializers import (
|
||||
ConversationSerializer, MessageSerializer,
|
||||
ConversationCreateSerializer, UserPresenceSerializer,
|
||||
ProfileSimpleSerializer
|
||||
)
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from drf_yasg import openapi
|
||||
from rest_framework.exceptions import NotFound
|
||||
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
|
||||
|
||||
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)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MessageView(APIView):
|
||||
def post(self, request):
|
||||
message_data=JSONParser().parse(request)
|
||||
message_serializer = MessageSerializer(data=message_data)
|
||||
# ====================== MESSAGERIE INSTANTANÉE ======================
|
||||
|
||||
if message_serializer.is_valid():
|
||||
message_serializer.save()
|
||||
|
||||
return JsonResponse('Nouveau Message ajouté', safe=False)
|
||||
|
||||
return JsonResponse(message_serializer.errors, safe=False)
|
||||
|
||||
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):
|
||||
class InstantConversationListView(APIView):
|
||||
"""
|
||||
API pour envoyer des emails aux parents et professeurs.
|
||||
API pour lister les conversations instantanées d'un utilisateur
|
||||
"""
|
||||
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)
|
||||
|
||||
@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:
|
||||
# Récupérer la connexion SMTP
|
||||
connection = mailer.getConnection(establishment_id)
|
||||
user = Profile.objects.get(id=user_id)
|
||||
|
||||
# Envoyer l'email
|
||||
return mailer.sendMail(
|
||||
subject=subject,
|
||||
message=message,
|
||||
recipients=recipients,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
attachments=[],
|
||||
connection=connection
|
||||
)
|
||||
except NotFound as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_404_NOT_FOUND)
|
||||
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 ContactsView(APIView):
|
||||
class InstantConversationCreateView(APIView):
|
||||
"""
|
||||
API pour récupérer les contacts associés à un établissement.
|
||||
"""
|
||||
def get(self, request, establishment_id):
|
||||
try:
|
||||
# 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)
|
||||
|
||||
# Ajouter un contact pour l'administration
|
||||
admin_contact = {
|
||||
"id": "admin",
|
||||
"name": "Administration",
|
||||
"email": "admin@etablissement.com",
|
||||
"profilePic": "https://www.gravatar.com/avatar/admin"
|
||||
}
|
||||
|
||||
contacts = [admin_contact] + teachers_serializer.data
|
||||
return Response(contacts, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
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)
|
||||
|
||||
class ConversationListView(APIView):
|
||||
"""
|
||||
Liste les conversations d'un utilisateur (parent ou enseignant).
|
||||
Retourne la liste des interlocuteurs et le dernier message échangé.
|
||||
API pour créer une nouvelle conversation instantanée
|
||||
"""
|
||||
@swagger_auto_schema(
|
||||
operation_description="Liste les conversations d'un utilisateur (parent ou enseignant).",
|
||||
responses={200: openapi.Response('Liste des conversations')}
|
||||
operation_description="Crée une nouvelle conversation instantanée",
|
||||
request_body=ConversationCreateSerializer,
|
||||
responses={201: ConversationSerializer}
|
||||
)
|
||||
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)
|
||||
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)
|
||||
|
||||
class ConversationMessagesView(APIView):
|
||||
class InstantMessageListView(APIView):
|
||||
"""
|
||||
Récupère tous les messages d'une conversation donnée.
|
||||
API pour lister les messages d'une conversation
|
||||
"""
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère tous les messages d'une conversation donnée.",
|
||||
responses={200: openapi.Response('Liste des messages')}
|
||||
operation_description="Liste les messages d'une conversation",
|
||||
responses={200: MessageSerializer(many=True)}
|
||||
)
|
||||
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)
|
||||
try:
|
||||
conversation = Conversation.objects.get(id=conversation_id)
|
||||
messages = conversation.messages.filter(is_deleted=False).order_by('created_at')
|
||||
|
||||
class MarkAsReadView(APIView):
|
||||
# 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
|
||||
|
||||
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)
|
||||
|
||||
class InstantMessageCreateView(APIView):
|
||||
"""
|
||||
Marque tous les messages reçus dans une conversation comme lus pour l'utilisateur connecté.
|
||||
API pour envoyer un nouveau message instantané
|
||||
"""
|
||||
@swagger_auto_schema(
|
||||
operation_description="Marque tous les messages reçus dans une conversation comme lus pour l'utilisateur connecté.",
|
||||
operation_description="Envoie un nouveau message instantané",
|
||||
request_body=openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'profile_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID du profil utilisateur')
|
||||
'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=['profile_id']
|
||||
required=['conversation_id', 'sender_id', 'content']
|
||||
),
|
||||
responses={200: openapi.Response('Statut OK')}
|
||||
responses={201: MessageSerializer}
|
||||
)
|
||||
def post(self, request):
|
||||
try:
|
||||
conversation_id = request.data.get('conversation_id')
|
||||
sender_id = request.data.get('sender_id')
|
||||
content = request.data.get('content', '').strip()
|
||||
message_type = request.data.get('message_type', 'text')
|
||||
|
||||
if not all([conversation_id, sender_id, content]):
|
||||
return Response(
|
||||
{'error': 'conversation_id, sender_id, and content are required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Vérifier que la conversation existe
|
||||
conversation = Conversation.objects.get(id=conversation_id)
|
||||
|
||||
# Vérifier que l'expéditeur existe et peut envoyer dans cette conversation
|
||||
sender = Profile.objects.get(id=sender_id)
|
||||
participant = ConversationParticipant.objects.filter(
|
||||
conversation=conversation,
|
||||
participant=sender,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if not participant:
|
||||
return Response(
|
||||
{'error': 'You are not a participant in this conversation'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Récupérer les données de fichier si disponibles
|
||||
file_url = request.data.get('file_url')
|
||||
file_name = request.data.get('file_name')
|
||||
file_type = request.data.get('file_type')
|
||||
file_size = request.data.get('file_size')
|
||||
|
||||
# Créer le message
|
||||
message = Message.objects.create(
|
||||
conversation=conversation,
|
||||
sender=sender,
|
||||
content=content,
|
||||
message_type=message_type,
|
||||
file_url=file_url,
|
||||
file_name=file_name,
|
||||
file_type=file_type,
|
||||
file_size=file_size
|
||||
)
|
||||
|
||||
# Mettre à jour l'activité de la conversation
|
||||
conversation.last_activity = message.created_at
|
||||
conversation.save(update_fields=['last_activity'])
|
||||
|
||||
serializer = MessageSerializer(message)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
except Conversation.DoesNotExist:
|
||||
return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Profile.DoesNotExist:
|
||||
return Response({'error': 'Sender not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class InstantMarkAsReadView(APIView):
|
||||
"""
|
||||
API pour marquer une conversation comme lue
|
||||
"""
|
||||
@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):
|
||||
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)
|
||||
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)
|
||||
|
||||
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),
|
||||
}
|
||||
)),
|
||||
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)
|
||||
|
||||
except Exception as e:
|
||||
return Response({'error': f'Erreur lors de l\'upload: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
class InstantRecipientSearchView(APIView):
|
||||
"""
|
||||
API pour rechercher des destinataires pour la messagerie instantanée
|
||||
"""
|
||||
@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()
|
||||
|
||||
if not establishment_id:
|
||||
return Response({'error': 'establishment_id is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Récupérer les IDs des profils actifs dans l'établissement
|
||||
profile_roles = ProfileRole.objects.filter(
|
||||
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)
|
||||
)
|
||||
|
||||
# Exclure l'utilisateur actuel des résultats
|
||||
if request.user.is_authenticated:
|
||||
users = users.exclude(id=request.user.id)
|
||||
|
||||
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):
|
||||
"""
|
||||
API pour supprimer (désactiver) une conversation instantanée
|
||||
"""
|
||||
@swagger_auto_schema(
|
||||
operation_description="Supprime une conversation instantanée (désactivation soft)",
|
||||
responses={200: openapi.Schema(
|
||||
type=openapi.TYPE_OBJECT,
|
||||
properties={
|
||||
'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
|
||||
'message': openapi.Schema(type=openapi.TYPE_STRING)
|
||||
}
|
||||
)}
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
@ -8,9 +8,40 @@ 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')
|
||||
|
||||
application = get_asgi_application()
|
||||
# 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)
|
||||
)
|
||||
),
|
||||
})
|
||||
|
||||
@ -8,6 +8,10 @@ from rest_framework import status
|
||||
from rest_framework.exceptions import NotFound
|
||||
from Settings.models import SMTPSettings
|
||||
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:
|
||||
@ -53,6 +57,8 @@ def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connec
|
||||
from_email = settings.EMAIL_HOST_USER
|
||||
|
||||
|
||||
logger.info(f"From email: {from_email}")
|
||||
|
||||
email = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=plain_message,
|
||||
@ -67,10 +73,15 @@ def sendMail(subject, message, recipients, cc=[], bcc=[], attachments=[], connec
|
||||
for attachment in attachments:
|
||||
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:
|
||||
print(f"[DEBUG] Erreur lors de l'envoi de l'email : {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):
|
||||
|
||||
@ -46,6 +46,7 @@ INSTALLED_APPS = [
|
||||
'Subscriptions.apps.GestioninscriptionsConfig',
|
||||
'Auth.apps.GestionloginConfig',
|
||||
'GestionMessagerie.apps.GestionMessagerieConfig',
|
||||
'GestionEmail.apps.GestionEmailConfig',
|
||||
'GestionNotification.apps.GestionNotificationConfig',
|
||||
'School.apps.SchoolConfig',
|
||||
'Planning.apps.PlanningConfig',
|
||||
@ -62,14 +63,15 @@ INSTALLED_APPS = [
|
||||
'django_celery_beat',
|
||||
'N3wtSchool',
|
||||
'drf_yasg',
|
||||
'rest_framework_simplejwt'
|
||||
'rest_framework_simplejwt',
|
||||
'channels',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware', # Déplacez ici, avant CorsMiddleware
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
@ -161,6 +163,11 @@ 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"),
|
||||
@ -250,18 +257,35 @@ else:
|
||||
|
||||
DOCUMENT_DIR = 'documents'
|
||||
|
||||
CORS_ORIGIN_ALLOW_ALL = True
|
||||
# Configuration CORS temporaire pour debug
|
||||
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 = [
|
||||
'content-type',
|
||||
'accept',
|
||||
'accept-encoding',
|
||||
'authorization',
|
||||
'content-type',
|
||||
'dnt',
|
||||
'origin',
|
||||
'user-agent',
|
||||
'x-csrftoken',
|
||||
'x-requested-with',
|
||||
'X-Auth-Token',
|
||||
'x-csrftoken'
|
||||
]
|
||||
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
os.getenv('CORS_ALLOWED_ORIGINS', 'http://localhost:3000')
|
||||
# Méthodes HTTP autorisées
|
||||
CORS_ALLOWED_METHODS = [
|
||||
'DELETE',
|
||||
'GET',
|
||||
'OPTIONS',
|
||||
'PATCH',
|
||||
'POST',
|
||||
'PUT',
|
||||
]
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost:3000,http://localhost:8080').split(',')
|
||||
@ -303,6 +327,7 @@ REST_FRAMEWORK = {
|
||||
'PAGE_SIZE': NB_RESULT_SUBSCRIPTIONS_PER_PAGE,
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
),
|
||||
}
|
||||
|
||||
@ -344,4 +369,16 @@ DOCUSEAL_JWT = {
|
||||
'SIGNING_KEY': SECRET_KEY,
|
||||
'EXPIRATION_DELTA': timedelta(hours=1),
|
||||
'API_KEY': DOCUSEAL_API_KEY
|
||||
}
|
||||
|
||||
# Django Channels Configuration
|
||||
ASGI_APPLICATION = 'N3wtSchool.asgi.application'
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
'default': {
|
||||
'BACKEND': 'channels_redis.core.RedisChannelLayer',
|
||||
'CONFIG': {
|
||||
"hosts": [('redis', 6379)],
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -43,6 +43,7 @@ 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')),
|
||||
|
||||
@ -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(blank=True, null=True)
|
||||
description = models.CharField(max_length=500, blank=True, null=True)
|
||||
is_required = models.BooleanField(default=False)
|
||||
|
||||
############################################################
|
||||
@ -355,7 +355,7 @@ class StudentCompetency(models.Model):
|
||||
|
||||
class Meta:
|
||||
unique_together = ('student', 'establishment_competency', 'period')
|
||||
|
||||
|
||||
indexes = [
|
||||
models.Index(fields=['student', 'establishment_competency', 'period']),
|
||||
]
|
||||
|
||||
@ -8,6 +8,7 @@ APPS = [
|
||||
"Planning",
|
||||
"GestionNotification",
|
||||
"GestionMessagerie",
|
||||
"GestionEmail",
|
||||
"Auth",
|
||||
"School",
|
||||
"Common"
|
||||
|
||||
Binary file not shown.
@ -67,3 +67,6 @@ vine==5.1.0
|
||||
wcwidth==0.2.13
|
||||
webencodings==0.5.1
|
||||
xhtml2pdf==0.2.16
|
||||
channels==4.0.0
|
||||
channels-redis==4.1.0
|
||||
daphne==4.1.0
|
||||
|
||||
@ -14,13 +14,14 @@ test_mode = os.getenv('TEST_MODE', 'False') == 'True'
|
||||
|
||||
commands = [
|
||||
["python", "manage.py", "collectstatic", "--noinput"],
|
||||
["python", "manage.py", "flush", "--noinput"],
|
||||
#["python", "manage.py", "flush", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "Common", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "Establishment", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "Settings", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "Subscriptions", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "Planning", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "GestionNotification", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "GestionEmail", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "GestionMessagerie", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "Auth", "--noinput"],
|
||||
["python", "manage.py", "makemigrations", "School", "--noinput"],
|
||||
@ -35,14 +36,15 @@ 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(["python", "manage.py", "runserver", "0.0.0.0:8080"]),
|
||||
subprocess.Popen(["daphne", "-b", "0.0.0.0", "-p", "8080", "N3wtSchool.asgi:application"]),
|
||||
subprocess.Popen(["celery", "-A", "N3wtSchool", "worker", "--loglevel=info"]),
|
||||
subprocess.Popen(["celery", "-A", "N3wtSchool", "beat", "--loglevel=info", "--scheduler", "django_celery_beat.schedulers:DatabaseScheduler"])
|
||||
]
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
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"
|
||||
340
Front-End/docs/api-messagerie-technique.md
Normal file
340
Front-End/docs/api-messagerie-technique.md
Normal file
@ -0,0 +1,340 @@
|
||||
# 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
|
||||
126
Front-End/docs/messagerie-instantanee.md
Normal file
126
Front-End/docs/messagerie-instantanee.md
Normal file
@ -0,0 +1,126 @@
|
||||
# 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
|
||||
```
|
||||
33
Front-End/jest.config.js
Normal file
33
Front-End/jest.config.js
Normal file
@ -0,0 +1,33 @@
|
||||
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);
|
||||
95
Front-End/jest.setup.js
Normal file
95
Front-End/jest.setup.js
Normal file
@ -0,0 +1,95 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
@ -24,6 +24,8 @@ 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',
|
||||
|
||||
7445
Front-End/package-lock.json
generated
7445
Front-End/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -8,7 +8,10 @@
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint-light": "next lint --quiet",
|
||||
"check-strings": "node scripts/check-hardcoded-strings.js"
|
||||
"check-strings": "node scripts/check-hardcoded-strings.js",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docuseal/react": "^1.0.56",
|
||||
@ -37,10 +40,15 @@
|
||||
"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",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +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_
|
||||
|
||||
@ -3,7 +3,6 @@ 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,
|
||||
@ -13,11 +12,8 @@ import {
|
||||
Calendar,
|
||||
Settings,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
import DropdownMenu from '@/components/DropdownMenu';
|
||||
|
||||
import Popup from '@/components/Popup';
|
||||
import {
|
||||
@ -86,7 +82,7 @@ export default function Layout({ children }) {
|
||||
id: 'messagerie',
|
||||
name: t('messagerie'),
|
||||
url: FE_ADMIN_MESSAGERIE_URL,
|
||||
icon: Mail,
|
||||
icon: MessageSquare,
|
||||
},
|
||||
settings: {
|
||||
id: 'settings',
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import SidebarTabs from '@/components/SidebarTabs';
|
||||
import EmailSender from '@/components/Admin/EmailSender';
|
||||
import InstantMessaging from '@/components/Admin/InstantMessaging';
|
||||
@ -26,11 +26,8 @@ export default function MessageriePage({ csrfToken }) {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
<SidebarTabs
|
||||
tabs={tabs}
|
||||
onTabChange={(tabId) => logger.debug(`Onglet actif : ${tabId}`)}
|
||||
/>
|
||||
<div className="h-full flex flex-col p-0 m-0">
|
||||
<SidebarTabs tabs={tabs} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,31 +1,64 @@
|
||||
'use client';
|
||||
// src/components/Layout.js
|
||||
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 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 {
|
||||
FE_PARENTS_HOME_URL,
|
||||
FE_PARENTS_MESSAGERIE_URL,
|
||||
FE_PARENTS_SETTINGS_URL,
|
||||
} from '@/utils/Url'; // Ajout de l'importation de l'URL de la page d'accueil parent
|
||||
} from '@/utils/Url';
|
||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||
import { disconnect } from '@/app/actions/authAction';
|
||||
import Popup from '@/components/Popup';
|
||||
import logger from '@/utils/logger';
|
||||
import { getRightStr, RIGHTS } from '@/utils/rights';
|
||||
import { RIGHTS } from '@/utils/rights';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
|
||||
import Footer from '@/components/Footer';
|
||||
|
||||
export default function Layout({ children }) {
|
||||
const router = useRouter(); // Définition de router
|
||||
const [messages, setMessages] = useState([]);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [isPopupVisible, setIsPopupVisible] = useState(false);
|
||||
const { profileRole, user, clearContext } = useEstablishment();
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const { 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);
|
||||
};
|
||||
@ -35,52 +68,63 @@ 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}>
|
||||
<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 */}
|
||||
{/* 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="text-lg md:text-xl p-2 font-semibold">Accueil</div>
|
||||
</div>
|
||||
<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 responsive */}
|
||||
<Footer softwareName={softwareName} softwareVersion={softwareVersion} />
|
||||
{/* 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>
|
||||
|
||||
{/* 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' : ''}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer softwareName={softwareName} softwareVersion={softwareVersion} />
|
||||
|
||||
<Popup
|
||||
isOpen={isPopupVisible}
|
||||
message="Êtes-vous sûr(e) de vouloir vous déconnecter ?"
|
||||
onConfirm={confirmDisconnect}
|
||||
onCancel={() => setIsPopupVisible(false)}
|
||||
/>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,15 +1,28 @@
|
||||
'use client';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Chat from '@/components/Chat';
|
||||
|
||||
import React from 'react';
|
||||
import InstantChat from '@/components/Chat/InstantChat';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
|
||||
export default function MessageriePage() {
|
||||
const { user, selectedEstablishmentId } = useEstablishment();
|
||||
|
||||
if (!user) return <div>Chargement...</div>;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Chat userProfileId={user.id} establishmentId={selectedEstablishmentId} />
|
||||
<div className="h-full flex flex-col">
|
||||
<InstantChat
|
||||
userProfileId={user.user_id}
|
||||
establishmentId={selectedEstablishmentId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
34
Front-End/src/app/actions/emailAction.js
Normal file
34
Front-End/src/app/actions/emailAction.js
Normal file
@ -0,0 +1,34 @@
|
||||
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);
|
||||
};
|
||||
@ -1,67 +1,251 @@
|
||||
import {
|
||||
BE_GESTIONMESSAGERIE_CONVERSATIONS_URL,
|
||||
BE_GESTIONMESSAGERIE_CONVERSATION_MESSAGES_URL,
|
||||
BE_GESTIONMESSAGERIE_MARK_AS_READ_URL,
|
||||
BE_GESTIONMESSAGERIE_MESSAGES_URL,
|
||||
BE_GESTIONMESSAGERIE_SEND_MESSAGE_URL,
|
||||
BE_GESTIONMESSAGERIE_CREATE_CONVERSATION_URL,
|
||||
BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL,
|
||||
BE_GESTIONMESSAGERIE_MARK_AS_READ_URL,
|
||||
BE_GESTIONMESSAGERIE_UPLOAD_FILE_URL,
|
||||
BE_GESTIONMESSAGERIE_DELETE_CONVERSATION_URL,
|
||||
} from '@/utils/Url';
|
||||
import { errorHandler, requestResponseHandler } from './actionsHandlers';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
export const fetchConversations = (profileId) => {
|
||||
return fetch(`${BE_GESTIONMESSAGERIE_CONVERSATIONS_URL}/${profileId}/`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
// Helper pour construire les en-têtes avec CSRF
|
||||
const buildHeaders = (csrfToken) => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Ajouter le token CSRF
|
||||
if (csrfToken) {
|
||||
headers['X-CSRFToken'] = csrfToken;
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const fetchMessages = (conversationId) => {
|
||||
return fetch(
|
||||
`${BE_GESTIONMESSAGERIE_CONVERSATION_MESSAGES_URL}/${conversationId}/`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
)
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
export const sendMessage = (data) => {
|
||||
return fetch(`${BE_GESTIONMESSAGERIE_MESSAGES_URL}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then(requestResponseHandler)
|
||||
.catch(errorHandler);
|
||||
/**
|
||||
* Envoie un message dans une conversation
|
||||
*/
|
||||
export const sendMessage = async (messageData, csrfToken) => {
|
||||
try {
|
||||
const response = await fetch(BE_GESTIONMESSAGERIE_SEND_MESSAGE_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);
|
||||
}
|
||||
};
|
||||
|
||||
export const markAsRead = (conversationId, profileId) => {
|
||||
return fetch(`${BE_GESTIONMESSAGERIE_MARK_AS_READ_URL}/${conversationId}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ profile_id: profileId }),
|
||||
})
|
||||
.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, {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
export const searchRecipients = (establishmentId, query) => {
|
||||
const url = `${BE_GESTIONMESSAGERIE_SEARCH_RECIPIENTS_URL}/?establishment_id=${establishmentId}&q=${encodeURIComponent(query)}`;
|
||||
return fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.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, {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { sendMessage, searchRecipients } from '@/app/actions/messagerieAction';
|
||||
import { sendEmail, searchRecipients } from '@/app/actions/emailAction';
|
||||
import { fetchSmtpSettings } from '@/app/actions/settingsAction';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
@ -56,7 +56,7 @@ export default function EmailSender({ csrfToken }) {
|
||||
};
|
||||
|
||||
try {
|
||||
await sendMessage(data);
|
||||
await sendEmail(data);
|
||||
showNotification('Email envoyé avec succès.', 'success', 'Succès');
|
||||
// Réinitialiser les champs après succès
|
||||
setRecipients([]);
|
||||
|
||||
@ -1,88 +1,17 @@
|
||||
// filepath: d:\Dev\n3wt-innov\n3wt-school\Front-End\src\components\Admin\InstantMessaging.js
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Chat from '@/components/Chat';
|
||||
import InstantChat from '@/components/Chat/InstantChat';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import {
|
||||
fetchConversations,
|
||||
sendMessage,
|
||||
searchRecipients,
|
||||
} from '@/app/actions/messagerieAction';
|
||||
import RecipientInput from '@/components/RecipientInput';
|
||||
import Modal from '@/components/Modal';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
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 w-full">
|
||||
<Chat
|
||||
userProfileId={user.id}
|
||||
<div className="h-full flex flex-col">
|
||||
<InstantChat
|
||||
userProfileId={user.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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,175 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
79
Front-End/src/components/Chat/ConnectionStatus.js
Normal file
79
Front-End/src/components/Chat/ConnectionStatus.js
Normal file
@ -0,0 +1,79 @@
|
||||
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;
|
||||
196
Front-End/src/components/Chat/ConversationItem.js
Normal file
196
Front-End/src/components/Chat/ConversationItem.js
Normal file
@ -0,0 +1,196 @@
|
||||
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;
|
||||
115
Front-End/src/components/Chat/FileAttachment.js
Normal file
115
Front-End/src/components/Chat/FileAttachment.js
Normal file
@ -0,0 +1,115 @@
|
||||
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;
|
||||
179
Front-End/src/components/Chat/FileUpload.js
Normal file
179
Front-End/src/components/Chat/FileUpload.js
Normal file
@ -0,0 +1,179 @@
|
||||
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;
|
||||
1162
Front-End/src/components/Chat/InstantChat.js
Normal file
1162
Front-End/src/components/Chat/InstantChat.js
Normal file
File diff suppressed because it is too large
Load Diff
133
Front-End/src/components/Chat/MessageBubble.js
Normal file
133
Front-End/src/components/Chat/MessageBubble.js
Normal file
@ -0,0 +1,133 @@
|
||||
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;
|
||||
233
Front-End/src/components/Chat/MessageInput.js
Normal file
233
Front-End/src/components/Chat/MessageInput.js
Normal file
@ -0,0 +1,233 @@
|
||||
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;
|
||||
30
Front-End/src/components/Chat/TypingIndicator.js
Normal file
30
Front-End/src/components/Chat/TypingIndicator.js
Normal file
@ -0,0 +1,30 @@
|
||||
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;
|
||||
@ -3,6 +3,7 @@ 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';
|
||||
@ -20,6 +21,7 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
|
||||
setSelectedEstablishmentEvaluationFrequency,
|
||||
setSelectedEstablishmentTotalCapacity,
|
||||
} = useEstablishment();
|
||||
const { isConnected, connectionStatus } = useChatConnection();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
@ -60,6 +62,36 @@ 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;
|
||||
|
||||
@ -68,13 +100,20 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
|
||||
<DropdownMenu
|
||||
buttonContent={
|
||||
<div className="h-16 flex items-center gap-2 cursor-pointer px-4 bg-white">
|
||||
<Image
|
||||
src={getGravatarUrl(user?.email)}
|
||||
alt="Profile"
|
||||
className="w-8 h-8 rounded-full mr-2"
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Image
|
||||
src={getGravatarUrl(user?.email)}
|
||||
alt="Profile"
|
||||
className="w-10 h-10 rounded-full object-cover shadow-md"
|
||||
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"
|
||||
|
||||
@ -6,6 +6,7 @@ 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';
|
||||
@ -23,11 +24,13 @@ export default function Providers({ children, messages, locale, session }) {
|
||||
<CsrfProvider>
|
||||
<EstablishmentProvider>
|
||||
<ClassesProvider>
|
||||
<PopupProvider>
|
||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</PopupProvider>
|
||||
<ChatConnectionProvider>
|
||||
<PopupProvider>
|
||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</PopupProvider>
|
||||
</ChatConnectionProvider>
|
||||
</ClassesProvider>
|
||||
</EstablishmentProvider>
|
||||
</CsrfProvider>
|
||||
|
||||
@ -31,7 +31,7 @@ const SidebarTabs = ({ tabs, onTabChange }) => {
|
||||
</div>
|
||||
|
||||
{/* Tabs Content */}
|
||||
<div className="flex-1 overflow-y-auto rounded-b-lg shadow-inner relative">
|
||||
<div className="flex-1 flex flex-col overflow-hidden rounded-b-lg shadow-inner">
|
||||
<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="absolute w-full h-full"
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
{tab.content}
|
||||
</motion.div>
|
||||
|
||||
270
Front-End/src/context/ChatConnectionContext.js
Normal file
270
Front-End/src/context/ChatConnectionContext.js
Normal file
@ -0,0 +1,270 @@
|
||||
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;
|
||||
@ -1,3 +1,90 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
249
Front-End/src/hooks/useWebSocket.js
Normal file
249
Front-End/src/hooks/useWebSocket.js
Normal file
@ -0,0 +1,249 @@
|
||||
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;
|
||||
63
Front-End/src/styles/chat-animations.css
Normal file
63
Front-End/src/styles/chat-animations.css
Normal file
@ -0,0 +1,63 @@
|
||||
/* 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;
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
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
|
||||
|
||||
@ -53,13 +54,25 @@ 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-email/`;
|
||||
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_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`;
|
||||
|
||||
14
README.md
14
README.md
@ -1,6 +1,18 @@
|
||||
# N3wt School
|
||||
|
||||
Logiciel de gestion d'école
|
||||
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.
|
||||
|
||||
## Maquette
|
||||
|
||||
|
||||
1
debug.log
Normal file
1
debug.log
Normal file
@ -0,0 +1 @@
|
||||
[0526/220602.137:ERROR:third_party\crashpad\crashpad\client\crashpad_client_win.cc:811] not connected
|
||||
@ -1,6 +1,6 @@
|
||||
services:
|
||||
redis:
|
||||
image: 'redis:latest'
|
||||
image: "redis:latest"
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
@ -8,7 +8,7 @@ services:
|
||||
- TZ=Europe/Paris
|
||||
|
||||
database:
|
||||
image: 'postgres:latest'
|
||||
image: "postgres:latest"
|
||||
ports:
|
||||
- 5432:5432
|
||||
environment:
|
||||
@ -16,7 +16,7 @@ services:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: school
|
||||
TZ: Europe/Paris
|
||||
|
||||
|
||||
docuseal:
|
||||
image: docuseal/docuseal:latest
|
||||
depends_on:
|
||||
@ -36,8 +36,8 @@ services:
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
- TEST_MODE=True
|
||||
- CORS_ALLOWED_ORIGINS=http://localhost:3000
|
||||
- CSRF_TRUSTED_ORIGINS=http://localhost:3000
|
||||
- CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:8080,http://127.0.0.1:8080
|
||||
- CSRF_TRUSTED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:8080,http://127.0.0.1:8080
|
||||
links:
|
||||
- "database:database"
|
||||
- "redis:redis"
|
||||
@ -79,4 +79,3 @@ services:
|
||||
# - TZ=Europe/Paris
|
||||
# depends_on:
|
||||
# - backend
|
||||
|
||||
|
||||
Reference in New Issue
Block a user