mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-01-28 15:33:22 +00:00
628 lines
25 KiB
Python
628 lines
25 KiB
Python
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)}")
|