mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-01-28 23:43:22 +00:00
feat: mise en place de la messagerie [#17]
This commit is contained in:
@ -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"])
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user