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