mirror of
				https://gitlab.com/animath/si/plateforme.git
				synced 2025-10-31 15:40:01 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			371 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			371 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| # Copyright (C) 2024 by Animath
 | |
| # SPDX-License-Identifier: GPL-3.0-or-later
 | |
| 
 | |
| from channels.generic.websocket import AsyncJsonWebsocketConsumer
 | |
| from django.contrib.auth.models import User
 | |
| from django.db.models import Count, F, Q
 | |
| from registration.models import Registration
 | |
| 
 | |
| from .models import Channel, Message
 | |
| 
 | |
| 
 | |
| class ChatConsumer(AsyncJsonWebsocketConsumer):
 | |
|     """
 | |
|     Ce consommateur gère les connexions WebSocket pour le chat.
 | |
|     """
 | |
|     async def connect(self) -> None:
 | |
|         """
 | |
|         Cette fonction est appelée lorsqu'un nouveau websocket tente de se connecter au serveur.
 | |
|         On n'accept que si c'est un⋅e utilisateur⋅rice connecté⋅e.
 | |
|         """
 | |
|         if '_fake_user_id' in self.scope['session']:
 | |
|             # Dans le cas d'une impersonification, on charge l'utilisateur⋅rice concerné
 | |
|             self.scope['user'] = await User.objects.aget(pk=self.scope['session']['_fake_user_id'])
 | |
| 
 | |
|         # Récupération de l'utilisateur⋅rice courant⋅e
 | |
|         user = self.scope['user']
 | |
|         if user.is_anonymous:
 | |
|             # L'utilisateur⋅rice n'est pas connecté⋅e
 | |
|             await self.close()
 | |
|             return
 | |
| 
 | |
|         reg = await Registration.objects.aget(user_id=user.id)
 | |
|         self.registration = reg
 | |
| 
 | |
|         # Acceptation de la connexion
 | |
|         await self.accept()
 | |
| 
 | |
|         # Récupération des canaux accessibles en lecture et/ou en écriture
 | |
|         self.read_channels = await Channel.get_accessible_channels(user, 'read')
 | |
|         self.write_channels = await Channel.get_accessible_channels(user, 'write')
 | |
| 
 | |
|         # Abonnement aux canaux de diffusion Websocket pour les différents canaux de chat
 | |
|         async for channel in self.read_channels.all():
 | |
|             await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name)
 | |
|         # Abonnement à un canal de diffusion Websocket personnel, utile pour s'adresser à une unique personne
 | |
|         await self.channel_layer.group_add(f"user-{user.id}", self.channel_name)
 | |
| 
 | |
|     async def disconnect(self, close_code: int) -> None:
 | |
|         """
 | |
|         Cette fonction est appelée lorsqu'un websocket se déconnecte du serveur.
 | |
|         :param close_code: Le code d'erreur.
 | |
|         """
 | |
|         if self.scope['user'].is_anonymous:
 | |
|             # L'utilisateur⋅rice n'était pas connecté⋅e, on ne fait rien
 | |
|             return
 | |
| 
 | |
|         async for channel in self.read_channels.all():
 | |
|             # Désabonnement des canaux de diffusion Websocket liés aux canaux de chat
 | |
|             await self.channel_layer.group_discard(f"chat-{channel.id}", self.channel_name)
 | |
|         # Désabonnement du canal de diffusion Websocket personnel
 | |
|         await self.channel_layer.group_discard(f"user-{self.scope['user'].id}", self.channel_name)
 | |
| 
 | |
|     async def receive_json(self, content: dict, **kwargs) -> None:
 | |
|         """
 | |
|         Appelée lorsque le client nous envoie des données, décodées depuis du JSON.
 | |
|         :param content: Les données envoyées par le client, décodées depuis du JSON. Doit contenir un champ 'type'.
 | |
|         """
 | |
|         match content['type']:
 | |
|             case 'fetch_channels':
 | |
|                 # Demande de récupération des canaux disponibles
 | |
|                 await self.fetch_channels()
 | |
|             case 'send_message':
 | |
|                 # Envoi d'un message dans un canal
 | |
|                 await self.receive_message(**content)
 | |
|             case 'edit_message':
 | |
|                 # Modification d'un message
 | |
|                 await self.edit_message(**content)
 | |
|             case 'delete_message':
 | |
|                 # Suppression d'un message
 | |
|                 await self.delete_message(**content)
 | |
|             case 'fetch_messages':
 | |
|                 # Récupération des messages d'un canal (ou d'une partie)
 | |
|                 await self.fetch_messages(**content)
 | |
|             case 'mark_read':
 | |
|                 # Marquage de messages comme lus
 | |
|                 await self.mark_read(**content)
 | |
|             case 'start_private_chat':
 | |
|                 # Démarrage d'une conversation privée avec un⋅e autre utilisateur⋅rice
 | |
|                 await self.start_private_chat(**content)
 | |
|             case unknown:
 | |
|                 # Type inconnu, on soulève une erreur
 | |
|                 raise ValueError(f"Unknown message type: {unknown}")
 | |
| 
 | |
|     async def fetch_channels(self) -> None:
 | |
|         """
 | |
|         L'utilisateur⋅rice demande à récupérer la liste des canaux disponibles.
 | |
|         On lui renvoie alors la liste des canaux qui lui sont accessibles en lecture,
 | |
|         en fournissant nom, catégorie, permission de lecture et nombre de messages non lus.
 | |
|         """
 | |
|         user = self.scope['user']
 | |
| 
 | |
|         # Récupération des canaux accessibles en lecture, avec le nombre de messages non lus
 | |
|         channels = self.read_channels.prefetch_related('invited') \
 | |
|             .annotate(total_messages=Count('messages', distinct=True)) \
 | |
|             .annotate(read_messages=Count('messages', filter=Q(messages__users_read=user), distinct=True)) \
 | |
|             .annotate(unread_messages=F('total_messages') - F('read_messages')).all()
 | |
| 
 | |
|         # Envoi de la liste des canaux
 | |
|         message = {
 | |
|             'type': 'fetch_channels',
 | |
|             'channels': [
 | |
|                 {
 | |
|                     'id': channel.id,
 | |
|                     'name': channel.get_visible_name(user),
 | |
|                     'category': channel.category,
 | |
|                     'read_access': True,
 | |
|                     'write_access': await self.write_channels.acontains(channel),
 | |
|                     'unread_messages': channel.unread_messages,
 | |
|                 }
 | |
|                 async for channel in channels
 | |
|             ]
 | |
|         }
 | |
|         await self.send_json(message)
 | |
| 
 | |
|     async def receive_message(self, channel_id: int, content: str, **kwargs) -> None:
 | |
|         """
 | |
|         L'utilisateur⋅ice a envoyé un message dans un canal.
 | |
|         On vérifie d'abord la permission d'écriture, puis on crée le message et on l'envoie à tou⋅tes les
 | |
|         utilisateur⋅ices abonné⋅es au canal.
 | |
| 
 | |
|         :param channel_id: Identifiant du canal où envoyer le message.
 | |
|         :param content: Contenu du message.
 | |
|         """
 | |
|         user = self.scope['user']
 | |
| 
 | |
|         # Récupération du canal
 | |
|         channel = await Channel.objects.prefetch_related('tournament__pools__juries', 'pool', 'team', 'invited') \
 | |
|             .aget(id=channel_id)
 | |
|         if not await self.write_channels.acontains(channel):
 | |
|             # L'utilisateur⋅ice n'a pas la permission d'écrire dans ce canal, on abandonne
 | |
|             return
 | |
| 
 | |
|         # Création du message
 | |
|         message = await Message.objects.acreate(
 | |
|             author=user,
 | |
|             channel=channel,
 | |
|             content=content,
 | |
|         )
 | |
| 
 | |
|         # Envoi du message à toutes les personnes connectées sur le canal
 | |
|         await self.channel_layer.group_send(f'chat-{channel.id}', {
 | |
|             'type': 'chat.send_message',
 | |
|             'id': message.id,
 | |
|             'channel_id': channel.id,
 | |
|             'timestamp': message.created_at.isoformat(),
 | |
|             'author_id': message.author_id,
 | |
|             'author': await message.aget_author_name(),
 | |
|             'content': message.content,
 | |
|         })
 | |
| 
 | |
|     async def edit_message(self, message_id: int, content: str, **kwargs) -> None:
 | |
|         """
 | |
|         L'utilisateur⋅ice a modifié un message.
 | |
|         On vérifie d'abord que l'utilisateur⋅ice a le droit de modifier le message, puis on modifie le message
 | |
|         et on envoie la modification à tou⋅tes les utilisateur⋅ices abonné⋅es au canal.
 | |
| 
 | |
|         :param message_id: Identifiant du message à modifier.
 | |
|         :param content: Nouveau contenu du message.
 | |
|         """
 | |
|         user = self.scope['user']
 | |
| 
 | |
|         # Récupération du message
 | |
|         message = await Message.objects.aget(id=message_id)
 | |
|         if user.id != message.author_id and not user.is_superuser:
 | |
|             # Seul⋅e l'auteur⋅ice du message ou un⋅e admin peut modifier un message
 | |
|             return
 | |
| 
 | |
|         # Modification du contenu du message
 | |
|         message.content = content
 | |
|         await message.asave()
 | |
| 
 | |
|         # Envoi de la modification à tou⋅tes les personnes connectées sur le canal
 | |
|         await self.channel_layer.group_send(f'chat-{message.channel_id}', {
 | |
|             'type': 'chat.edit_message',
 | |
|             'id': message_id,
 | |
|             'channel_id': message.channel_id,
 | |
|             'content': content,
 | |
|         })
 | |
| 
 | |
|     async def delete_message(self, message_id: int, **kwargs) -> None:
 | |
|         """
 | |
|         L'utilisateur⋅ice a supprimé un message.
 | |
|         On vérifie d'abord que l'utilisateur⋅ice a le droit de supprimer le message, puis on supprime le message
 | |
|         et on envoie la suppression à tou⋅tes les utilisateur⋅ices abonné⋅es au canal.
 | |
| 
 | |
|         :param message_id: Identifiant du message à supprimer.
 | |
|         """
 | |
|         user = self.scope['user']
 | |
| 
 | |
|         # Récupération du message
 | |
|         message = await Message.objects.aget(id=message_id)
 | |
|         if user.id != message.author_id and not user.is_superuser:
 | |
|             return
 | |
| 
 | |
|         # Suppression effective du message
 | |
|         await message.adelete()
 | |
| 
 | |
|         # Envoi de la suppression à tou⋅tes les personnes connectées sur le canal
 | |
|         await self.channel_layer.group_send(f'chat-{message.channel_id}', {
 | |
|             'type': 'chat.delete_message',
 | |
|             'id': message_id,
 | |
|             'channel_id': message.channel_id,
 | |
|         })
 | |
| 
 | |
|     async def fetch_messages(self, channel_id: int, offset: int = 0, limit: int = 50, **_kwargs) -> None:
 | |
|         """
 | |
|         L'utilisateur⋅ice demande à récupérer les messages d'un canal.
 | |
|         On vérifie la permission de lecture, puis on renvoie les messages demandés.
 | |
| 
 | |
|         :param channel_id: Identifiant du canal où récupérer les messages.
 | |
|         :param offset: Décalage pour la pagination, à partir du dernier message.
 | |
|                        Par défaut : 0, on commence au dernier message.
 | |
|         :param limit: Nombre de messages à récupérer. Par défaut, on récupère 50 messages.
 | |
|         """
 | |
|         # Récupération du canal
 | |
|         channel = await Channel.objects.aget(id=channel_id)
 | |
|         if not await self.read_channels.acontains(channel):
 | |
|             # L'utilisateur⋅rice n'a pas la permission de lire ce canal, on abandonne
 | |
|             return
 | |
| 
 | |
|         limit = min(limit, 200)  # On limite le nombre de messages à 200 maximum
 | |
| 
 | |
|         # Récupération des messages, avec un indicateur de lecture pour l'utilisateur⋅ice courant⋅e
 | |
|         messages = Message.objects \
 | |
|             .filter(channel=channel) \
 | |
|             .annotate(read=Count('users_read', filter=Q(users_read=self.scope['user']))) \
 | |
|             .order_by('-created_at')[offset:offset + limit].all()
 | |
| 
 | |
|         # Envoi de la liste des messages, en les renvoyant dans l'ordre chronologique
 | |
|         await self.send_json({
 | |
|             'type': 'fetch_messages',
 | |
|             'channel_id': channel_id,
 | |
|             'messages': list(reversed([
 | |
|                 {
 | |
|                     'id': message.id,
 | |
|                     'timestamp': message.created_at.isoformat(),
 | |
|                     'author_id': message.author_id,
 | |
|                     'author': await message.aget_author_name(),
 | |
|                     'content': message.content,
 | |
|                     'read': message.read > 0,
 | |
|                 }
 | |
|                 async for message in messages
 | |
|             ]))
 | |
|         })
 | |
| 
 | |
|     async def mark_read(self, message_ids: list[int], **_kwargs) -> None:
 | |
|         """
 | |
|         L'utilisateur⋅ice marque des messages comme lus, après les avoir affichés à l'écran.
 | |
| 
 | |
|         :param message_ids: Liste des identifiants des messages qu'il faut marquer comme lus.
 | |
|         """
 | |
|         # Récupération des messages à marquer comme lus
 | |
|         messages = Message.objects.filter(id__in=message_ids)
 | |
|         async for message in messages.all():
 | |
|             # Ajout de l'utilisateur⋅ice courant⋅e à la liste des personnes ayant lu le message
 | |
|             await message.users_read.aadd(self.scope['user'])
 | |
| 
 | |
|         # Actualisation du nombre de messages non lus par canal
 | |
|         unread_messages_by_channel = Message.objects.exclude(users_read=self.scope['user']).values('channel_id') \
 | |
|             .annotate(unread_messages=Count('channel_id'))
 | |
| 
 | |
|         # Envoi des identifiants des messages non lus et du nombre de messages non lus par canal, actualisés
 | |
|         await self.send_json({
 | |
|             'type': 'mark_read',
 | |
|             'messages': [{'id': message.id, 'channel_id': message.channel_id} async for message in messages.all()],
 | |
|             'unread_messages': {group['channel_id']: group['unread_messages']
 | |
|                                 async for group in unread_messages_by_channel.all()},
 | |
|         })
 | |
| 
 | |
|     async def start_private_chat(self, user_id: int, **kwargs) -> None:
 | |
|         """
 | |
|         L'utilisateur⋅ice souhaite démarrer une conversation privée avec un⋅e autre utilisateur⋅ice.
 | |
|         Pour cela, on récupère le salon privé s'il existe, sinon on en crée un.
 | |
|         Dans le cas d'une création, les deux personnes sont transférées immédiatement dans ce nouveau canal.
 | |
| 
 | |
|         :param user_id: L'utilisateur⋅rice avec qui démarrer la conversation privée.
 | |
|         """
 | |
|         user = self.scope['user']
 | |
|         # Récupération de l'autre utilisateur⋅ice avec qui démarrer la conversation
 | |
|         other_user = await User.objects.aget(id=user_id)
 | |
| 
 | |
|         # Vérification de l'existence d'un salon privé entre les deux personnes
 | |
|         channel_qs = Channel.objects.filter(private=True).filter(invited=user).filter(invited=other_user)
 | |
|         if not await channel_qs.aexists():
 | |
|             # Le salon privé n'existe pas, on le crée alors
 | |
|             channel = await Channel.objects.acreate(
 | |
|                 name=f"{user.first_name} {user.last_name}, {other_user.first_name} {other_user.last_name}",
 | |
|                 category=Channel.ChannelCategory.PRIVATE,
 | |
|                 private=True,
 | |
|             )
 | |
|             await channel.invited.aset([user, other_user])
 | |
| 
 | |
|             # On s'ajoute au salon privé
 | |
|             await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name)
 | |
| 
 | |
|             if user != other_user:
 | |
|                 # On transfère l'autre utilisateur⋅ice dans le salon privé
 | |
|                 await self.channel_layer.group_send(f"user-{other_user.id}", {
 | |
|                     'type': 'chat.start_private_chat',
 | |
|                     'channel': {
 | |
|                         'id': channel.id,
 | |
|                         'name': f"{user.first_name} {user.last_name}",
 | |
|                         'category': channel.category,
 | |
|                         'read_access': True,
 | |
|                         'write_access': True,
 | |
|                     }
 | |
|                 })
 | |
|         else:
 | |
|             # Récupération dudit salon privé
 | |
|             channel = await channel_qs.afirst()
 | |
| 
 | |
|         # Invitation de l'autre utilisateur⋅rice à rejoindre le salon privé
 | |
|         await self.channel_layer.group_send(f"user-{user.id}", {
 | |
|             'type': 'chat.start_private_chat',
 | |
|             'channel': {
 | |
|                 'id': channel.id,
 | |
|                 'name': f"{other_user.first_name} {other_user.last_name}",
 | |
|                 'category': channel.category,
 | |
|                 'read_access': True,
 | |
|                 'write_access': True,
 | |
|             }
 | |
|         })
 | |
| 
 | |
|     async def chat_send_message(self, message) -> None:
 | |
|         """
 | |
|         Envoi d'un message à tou⋅tes les personnes connectées sur un canal.
 | |
|         :param message: Dictionnaire contenant les informations du message à envoyer,
 | |
|                 contenant l'identifiant du message "id", l'identifiant du canal "channel_id",
 | |
|                 l'heure de création "timestamp", l'identifiant de l'auteur "author_id",
 | |
|                 le nom de l'auteur "author" et le contenu du message "content".
 | |
|         """
 | |
|         await self.send_json({'type': 'send_message', 'id': message['id'], 'channel_id': message['channel_id'],
 | |
|                               'timestamp': message['timestamp'], 'author': message['author'],
 | |
|                               'content': message['content']})
 | |
| 
 | |
|     async def chat_edit_message(self, message) -> None:
 | |
|         """
 | |
|         Envoi d'une modification de message à tou⋅tes les personnes connectées sur un canal.
 | |
|         :param message: Dictionnaire contenant les informations du message à modifier,
 | |
|                 contenant l'identifiant du message "id", l'identifiant du canal "channel_id"
 | |
|                 et le nouveau contenu "content".
 | |
|         """
 | |
|         await self.send_json({'type': 'edit_message', 'id': message['id'], 'channel_id': message['channel_id'],
 | |
|                               'content': message['content']})
 | |
| 
 | |
|     async def chat_delete_message(self, message) -> None:
 | |
|         """
 | |
|         Envoi d'une suppression de message à tou⋅tes les personnes connectées sur un canal.
 | |
|         :param message: Dictionnaire contenant les informations du message à supprimer,
 | |
|                 contenant l'identifiant du message "id" et l'identifiant du canal "channel_id".
 | |
|         """
 | |
|         await self.send_json({'type': 'delete_message', 'id': message['id'], 'channel_id': message['channel_id']})
 | |
| 
 | |
|     async def chat_start_private_chat(self, message) -> None:
 | |
|         """
 | |
|         Envoi d'un message pour démarrer une conversation privée à une personne connectée.
 | |
|         :param message: Dictionnaire contenant les informations du nouveau canal privé.
 | |
|         """
 | |
|         await self.channel_layer.group_add(f"chat-{message['channel']['id']}", self.channel_name)
 | |
|         await self.send_json({'type': 'start_private_chat', 'channel': message['channel']})
 |