plateforme-tfjm2/chat/consumers.py

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']})