Compare commits
	
		
			28 Commits
		
	
	
		
			docs
			...
			b820306e2e
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | b820306e2e | ||
|  | 756f2074b3 | ||
|  | 6385e53425 | ||
|  | 32b816a4de | ||
|  | 41ccbcb277 | ||
|  | 2438bb9bcc | ||
|  | ddd2280ae4 | ||
|  | 4958628e40 | ||
|  | 731d309305 | ||
|  | af9dcebca9 | ||
|  | 9c118c7142 | ||
|  | 14878fce86 | ||
|  | 13f6b1972f | ||
|  | 59f5667f52 | ||
|  | be6e5b03c9 | ||
|  | 363ed35fa1 | ||
|  | a50e865ef0 | ||
|  | badbb2567e | ||
|  | 39085a6303 | ||
|  | bb137509e1 | ||
|  | 727aa8b6d6 | ||
|  | ee15ea04d5 | ||
|  | c20554e01a | ||
|  | 4026fe53c3 | ||
|  | 1abe463575 | ||
|  | 5b0081a531 | ||
|  | 06c82a239d | ||
|  | f8725cf8a9 | 
| @@ -3,12 +3,10 @@ FROM python:3.12-alpine | |||||||
| ENV PYTHONUNBUFFERED 1 | ENV PYTHONUNBUFFERED 1 | ||||||
| ENV DJANGO_ALLOW_ASYNC_UNSAFE 1 | ENV DJANGO_ALLOW_ASYNC_UNSAFE 1 | ||||||
|  |  | ||||||
| RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libxml2-dev libxslt-dev npm postgresql-dev libmagic texlive texmf-dist-latexextra | RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libxml2-dev libxslt-dev postgresql-dev libmagic texlive texmf-dist-latexextra | ||||||
|  |  | ||||||
| RUN apk add --no-cache bash | RUN apk add --no-cache bash | ||||||
|  |  | ||||||
| RUN npm install -g yuglify |  | ||||||
|  |  | ||||||
| RUN mkdir /code /code/docs | RUN mkdir /code /code/docs | ||||||
| WORKDIR /code | WORKDIR /code | ||||||
| COPY requirements.txt /code/requirements.txt | COPY requirements.txt /code/requirements.txt | ||||||
|   | |||||||
| @@ -8,9 +8,6 @@ from .models import Channel, Message | |||||||
|  |  | ||||||
| @admin.register(Channel) | @admin.register(Channel) | ||||||
| class ChannelAdmin(admin.ModelAdmin): | class ChannelAdmin(admin.ModelAdmin): | ||||||
|     """ |  | ||||||
|     Modèle d'administration des canaux de chat. |  | ||||||
|     """ |  | ||||||
|     list_display = ('name', 'category', 'read_access', 'write_access', 'tournament', 'private',) |     list_display = ('name', 'category', 'read_access', 'write_access', 'tournament', 'private',) | ||||||
|     list_filter = ('category', 'read_access', 'write_access', 'tournament', 'private',) |     list_filter = ('category', 'read_access', 'write_access', 'tournament', 'private',) | ||||||
|     search_fields = ('name', 'tournament__name', 'team__name', 'team__trigram',) |     search_fields = ('name', 'tournament__name', 'team__name', 'team__trigram',) | ||||||
| @@ -19,10 +16,7 @@ class ChannelAdmin(admin.ModelAdmin): | |||||||
|  |  | ||||||
| @admin.register(Message) | @admin.register(Message) | ||||||
| class MessageAdmin(admin.ModelAdmin): | class MessageAdmin(admin.ModelAdmin): | ||||||
|     """ |  | ||||||
|     Modèle d'administration des messages de chat. |  | ||||||
|     """ |  | ||||||
|     list_display = ('channel', 'author', 'created_at', 'updated_at', 'content',) |     list_display = ('channel', 'author', 'created_at', 'updated_at', 'content',) | ||||||
|     list_filter = ('channel', 'created_at', 'updated_at',) |     list_filter = ('channel', 'created_at', 'updated_at',) | ||||||
|     search_fields = ('author__username', 'author__first_name', 'author__last_name', 'content',) |     search_fields = ('author__username', 'author__first_name', 'author__last_name', 'content',) | ||||||
|     autocomplete_fields = ('channel', 'author', 'users_read',) |     autocomplete_fields = ('channel', 'author',) | ||||||
|   | |||||||
| @@ -3,7 +3,6 @@ | |||||||
|  |  | ||||||
| from channels.generic.websocket import AsyncJsonWebsocketConsumer | from channels.generic.websocket import AsyncJsonWebsocketConsumer | ||||||
| from django.contrib.auth.models import User | from django.contrib.auth.models import User | ||||||
| from django.db.models import Count, F, Q |  | ||||||
| from registration.models import Registration | from registration.models import Registration | ||||||
|  |  | ||||||
| from .models import Channel, Message | from .models import Channel, Message | ||||||
| @@ -11,101 +10,75 @@ from .models import Channel, Message | |||||||
|  |  | ||||||
| class ChatConsumer(AsyncJsonWebsocketConsumer): | class ChatConsumer(AsyncJsonWebsocketConsumer): | ||||||
|     """ |     """ | ||||||
|     Ce consommateur gère les connexions WebSocket pour le chat. |     This consumer manages the websocket of the chat interface. | ||||||
|     """ |     """ | ||||||
|     async def connect(self) -> None: |     async def connect(self) -> None: | ||||||
|         """ |         """ | ||||||
|         Cette fonction est appelée lorsqu'un nouveau websocket tente de se connecter au serveur. |         This function is called when a new websocket is trying to connect to the server. | ||||||
|         On n'accept que si c'est un⋅e utilisateur⋅rice connecté⋅e. |         We accept only if this is a user of a team of the associated tournament, or a volunteer | ||||||
|  |         of the tournament. | ||||||
|         """ |         """ | ||||||
|         if '_fake_user_id' in self.scope['session']: |         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']) |             self.scope['user'] = await User.objects.aget(pk=self.scope['session']['_fake_user_id']) | ||||||
|  |  | ||||||
|         # Récupération de l'utilisateur⋅rice courant⋅e |         # Fetch the registration of the current user | ||||||
|         user = self.scope['user'] |         user = self.scope['user'] | ||||||
|         if user.is_anonymous: |         if user.is_anonymous: | ||||||
|             # L'utilisateur⋅rice n'est pas connecté⋅e |             # User is not authenticated | ||||||
|             await self.close() |             await self.close() | ||||||
|             return |             return | ||||||
|  |  | ||||||
|         reg = await Registration.objects.aget(user_id=user.id) |         reg = await Registration.objects.aget(user_id=user.id) | ||||||
|         self.registration = reg |         self.registration = reg | ||||||
|  |  | ||||||
|         # Acceptation de la connexion |         # Accept the connection | ||||||
|         await self.accept() |         await self.accept() | ||||||
|  |  | ||||||
|         # Récupération des canaux accessibles en lecture et/ou en écriture |         channels = await Channel.get_accessible_channels(user, 'read') | ||||||
|         self.read_channels = await Channel.get_accessible_channels(user, 'read') |         async for channel in channels.all(): | ||||||
|         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) |             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) |         await self.channel_layer.group_add(f"user-{user.id}", self.channel_name) | ||||||
|  |  | ||||||
|     async def disconnect(self, close_code: int) -> None: |     async def disconnect(self, close_code) -> None: | ||||||
|         """ |         """ | ||||||
|         Cette fonction est appelée lorsqu'un websocket se déconnecte du serveur. |         Called when the websocket got disconnected, for any reason. | ||||||
|         :param close_code: Le code d'erreur. |         :param close_code: The error code. | ||||||
|         """ |         """ | ||||||
|         if self.scope['user'].is_anonymous: |         if self.scope['user'].is_anonymous: | ||||||
|             # L'utilisateur⋅rice n'était pas connecté⋅e, on ne fait rien |             # User is not authenticated | ||||||
|             return |             return | ||||||
|  |  | ||||||
|         async for channel in self.read_channels.all(): |         channels = await Channel.get_accessible_channels(self.scope['user'], 'read') | ||||||
|             # Désabonnement des canaux de diffusion Websocket liés aux canaux de chat |         async for channel in channels.all(): | ||||||
|             await self.channel_layer.group_discard(f"chat-{channel.id}", self.channel_name) |             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) |         await self.channel_layer.group_discard(f"user-{self.scope['user'].id}", self.channel_name) | ||||||
|  |  | ||||||
|     async def receive_json(self, content: dict, **kwargs) -> None: |     async def receive_json(self, content, **kwargs): | ||||||
|         """ |         """ | ||||||
|         Appelée lorsque le client nous envoie des données, décodées depuis du JSON. |         Called when the client sends us some data, parsed as JSON. | ||||||
|         :param content: Les données envoyées par le client, décodées depuis du JSON. Doit contenir un champ 'type'. |         :param content: The sent data, decoded from JSON text. Must content a `type` field. | ||||||
|         """ |         """ | ||||||
|         match content['type']: |         match content['type']: | ||||||
|             case 'fetch_channels': |             case 'fetch_channels': | ||||||
|                 # Demande de récupération des canaux disponibles |  | ||||||
|                 await self.fetch_channels() |                 await self.fetch_channels() | ||||||
|             case 'send_message': |             case 'send_message': | ||||||
|                 # Envoi d'un message dans un canal |  | ||||||
|                 await self.receive_message(**content) |                 await self.receive_message(**content) | ||||||
|             case 'edit_message': |             case 'edit_message': | ||||||
|                 # Modification d'un message |  | ||||||
|                 await self.edit_message(**content) |                 await self.edit_message(**content) | ||||||
|             case 'delete_message': |             case 'delete_message': | ||||||
|                 # Suppression d'un message |  | ||||||
|                 await self.delete_message(**content) |                 await self.delete_message(**content) | ||||||
|             case 'fetch_messages': |             case 'fetch_messages': | ||||||
|                 # Récupération des messages d'un canal (ou d'une partie) |  | ||||||
|                 await self.fetch_messages(**content) |                 await self.fetch_messages(**content) | ||||||
|             case 'mark_read': |  | ||||||
|                 # Marquage de messages comme lus |  | ||||||
|                 await self.mark_read(**content) |  | ||||||
|             case 'start_private_chat': |             case 'start_private_chat': | ||||||
|                 # Démarrage d'une conversation privée avec un⋅e autre utilisateur⋅rice |  | ||||||
|                 await self.start_private_chat(**content) |                 await self.start_private_chat(**content) | ||||||
|             case unknown: |             case unknown: | ||||||
|                 # Type inconnu, on soulève une erreur |                 print("Unknown message type:", unknown) | ||||||
|                 raise ValueError(f"Unknown message type: {unknown}") |  | ||||||
|  |  | ||||||
|     async def fetch_channels(self) -> None: |     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'] |         user = self.scope['user'] | ||||||
|  |  | ||||||
|         # Récupération des canaux accessibles en lecture, avec le nombre de messages non lus |         read_channels = await Channel.get_accessible_channels(user, 'read') | ||||||
|         channels = self.read_channels.prefetch_related('invited') \ |         write_channels = await Channel.get_accessible_channels(user, 'write') | ||||||
|             .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 = { |         message = { | ||||||
|             'type': 'fetch_channels', |             'type': 'fetch_channels', | ||||||
|             'channels': [ |             'channels': [ | ||||||
| @@ -114,40 +87,27 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): | |||||||
|                     'name': channel.get_visible_name(user), |                     'name': channel.get_visible_name(user), | ||||||
|                     'category': channel.category, |                     'category': channel.category, | ||||||
|                     'read_access': True, |                     'read_access': True, | ||||||
|                     'write_access': await self.write_channels.acontains(channel), |                     'write_access': await write_channels.acontains(channel), | ||||||
|                     'unread_messages': channel.unread_messages, |  | ||||||
|                 } |                 } | ||||||
|                 async for channel in channels |                 async for channel in read_channels.prefetch_related('invited').all() | ||||||
|             ] |             ] | ||||||
|         } |         } | ||||||
|         await self.send_json(message) |         await self.send_json(message) | ||||||
|  |  | ||||||
|     async def receive_message(self, channel_id: int, content: str, **kwargs) -> None: |     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'] |         user = self.scope['user'] | ||||||
|  |  | ||||||
|         # Récupération du canal |  | ||||||
|         channel = await Channel.objects.prefetch_related('tournament__pools__juries', 'pool', 'team', 'invited') \ |         channel = await Channel.objects.prefetch_related('tournament__pools__juries', 'pool', 'team', 'invited') \ | ||||||
|             .aget(id=channel_id) |             .aget(id=channel_id) | ||||||
|         if not await self.write_channels.acontains(channel): |         write_channels = await Channel.get_accessible_channels(user, 'write') | ||||||
|             # L'utilisateur⋅ice n'a pas la permission d'écrire dans ce canal, on abandonne |         if not await write_channels.acontains(channel): | ||||||
|             return |             return | ||||||
|  |  | ||||||
|         # Création du message |  | ||||||
|         message = await Message.objects.acreate( |         message = await Message.objects.acreate( | ||||||
|             author=user, |             author=user, | ||||||
|             channel=channel, |             channel=channel, | ||||||
|             content=content, |             content=content, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         # Envoi du message à toutes les personnes connectées sur le canal |  | ||||||
|         await self.channel_layer.group_send(f'chat-{channel.id}', { |         await self.channel_layer.group_send(f'chat-{channel.id}', { | ||||||
|             'type': 'chat.send_message', |             'type': 'chat.send_message', | ||||||
|             'id': message.id, |             'id': message.id, | ||||||
| @@ -159,27 +119,14 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): | |||||||
|         }) |         }) | ||||||
|  |  | ||||||
|     async def edit_message(self, message_id: int, content: str, **kwargs) -> None: |     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) |         message = await Message.objects.aget(id=message_id) | ||||||
|  |         user = self.scope['user'] | ||||||
|         if user.id != message.author_id and not user.is_superuser: |         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 |             return | ||||||
|  |  | ||||||
|         # Modification du contenu du message |  | ||||||
|         message.content = content |         message.content = content | ||||||
|         await message.asave() |         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}', { |         await self.channel_layer.group_send(f'chat-{message.channel_id}', { | ||||||
|             'type': 'chat.edit_message', |             'type': 'chat.edit_message', | ||||||
|             'id': message_id, |             'id': message_id, | ||||||
| @@ -188,24 +135,13 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): | |||||||
|         }) |         }) | ||||||
|  |  | ||||||
|     async def delete_message(self, message_id: int, **kwargs) -> None: |     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) |         message = await Message.objects.aget(id=message_id) | ||||||
|  |         user = self.scope['user'] | ||||||
|         if user.id != message.author_id and not user.is_superuser: |         if user.id != message.author_id and not user.is_superuser: | ||||||
|             return |             return | ||||||
|  |  | ||||||
|         # Suppression effective du message |  | ||||||
|         await message.adelete() |         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}', { |         await self.channel_layer.group_send(f'chat-{message.channel_id}', { | ||||||
|             'type': 'chat.delete_message', |             'type': 'chat.delete_message', | ||||||
|             'id': message_id, |             'id': message_id, | ||||||
| @@ -213,30 +149,14 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): | |||||||
|         }) |         }) | ||||||
|  |  | ||||||
|     async def fetch_messages(self, channel_id: int, offset: int = 0, limit: int = 50, **_kwargs) -> None: |     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) |         channel = await Channel.objects.aget(id=channel_id) | ||||||
|         if not await self.read_channels.acontains(channel): |         read_channels = await Channel.get_accessible_channels(self.scope['user'], 'read') | ||||||
|             # L'utilisateur⋅rice n'a pas la permission de lire ce canal, on abandonne |         if not await read_channels.acontains(channel): | ||||||
|             return |             return | ||||||
|  |  | ||||||
|         limit = min(limit, 200)  # On limite le nombre de messages à 200 maximum |         limit = min(limit, 200)  # Fetch only maximum 200 messages at the time | ||||||
|  |  | ||||||
|         # Récupération des messages, avec un indicateur de lecture pour l'utilisateur⋅ice courant⋅e |         messages = Message.objects.filter(channel=channel).order_by('-created_at')[offset:offset + limit].all() | ||||||
|         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({ |         await self.send_json({ | ||||||
|             'type': 'fetch_messages', |             'type': 'fetch_messages', | ||||||
|             'channel_id': channel_id, |             'channel_id': channel_id, | ||||||
| @@ -247,64 +167,27 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): | |||||||
|                     'author_id': message.author_id, |                     'author_id': message.author_id, | ||||||
|                     'author': await message.aget_author_name(), |                     'author': await message.aget_author_name(), | ||||||
|                     'content': message.content, |                     'content': message.content, | ||||||
|                     'read': message.read > 0, |  | ||||||
|                 } |                 } | ||||||
|                 async for message in messages |                 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: |     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'] |         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) |         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) |         channel_qs = Channel.objects.filter(private=True).filter(invited=user).filter(invited=other_user) | ||||||
|         if not await channel_qs.aexists(): |         if not await channel_qs.aexists(): | ||||||
|             # Le salon privé n'existe pas, on le crée alors |  | ||||||
|             channel = await Channel.objects.acreate( |             channel = await Channel.objects.acreate( | ||||||
|                 name=f"{user.first_name} {user.last_name}, {other_user.first_name} {other_user.last_name}", |                 name=f"{user.first_name} {user.last_name}, {other_user.first_name} {other_user.last_name}", | ||||||
|                 category=Channel.ChannelCategory.PRIVATE, |                 category=Channel.ChannelCategory.PRIVATE, | ||||||
|                 private=True, |                 private=True, | ||||||
|             ) |             ) | ||||||
|             await channel.invited.aset([user, other_user]) |             await channel.invited.aset([user, other_user]) | ||||||
|  |             await channel.asave() | ||||||
|  |  | ||||||
|             # On s'ajoute au salon privé |  | ||||||
|             await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name) |             await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name) | ||||||
|  |  | ||||||
|             if user != other_user: |             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}", { |                 await self.channel_layer.group_send(f"user-{other_user.id}", { | ||||||
|                     'type': 'chat.start_private_chat', |                     'type': 'chat.start_private_chat', | ||||||
|                     'channel': { |                     'channel': { | ||||||
| @@ -316,10 +199,8 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): | |||||||
|                     } |                     } | ||||||
|                 }) |                 }) | ||||||
|         else: |         else: | ||||||
|             # Récupération dudit salon privé |  | ||||||
|             channel = await channel_qs.afirst() |             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}", { |         await self.channel_layer.group_send(f"user-{user.id}", { | ||||||
|             'type': 'chat.start_private_chat', |             'type': 'chat.start_private_chat', | ||||||
|             'channel': { |             'channel': { | ||||||
| @@ -332,39 +213,18 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): | |||||||
|         }) |         }) | ||||||
|  |  | ||||||
|     async def chat_send_message(self, message) -> None: |     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'], |         await self.send_json({'type': 'send_message', 'id': message['id'], 'channel_id': message['channel_id'], | ||||||
|                               'timestamp': message['timestamp'], 'author': message['author'], |                               'timestamp': message['timestamp'], 'author': message['author'], | ||||||
|                               'content': message['content']}) |                               'content': message['content']}) | ||||||
|  |  | ||||||
|     async def chat_edit_message(self, message) -> None: |     async def chat_edit_message(self, message) -> None: | ||||||
|         """ |         print(message) | ||||||
|         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'], |         await self.send_json({'type': 'edit_message', 'id': message['id'], 'channel_id': message['channel_id'], | ||||||
|                               'content': message['content']}) |                               'content': message['content']}) | ||||||
|  |  | ||||||
|     async def chat_delete_message(self, message) -> None: |     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']}) |         await self.send_json({'type': 'delete_message', 'id': message['id'], 'channel_id': message['channel_id']}) | ||||||
|  |  | ||||||
|     async def chat_start_private_chat(self, message) -> None: |     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.channel_layer.group_add(f"chat-{message['channel']['id']}", self.channel_name) | ||||||
|         await self.send_json({'type': 'start_private_chat', 'channel': message['channel']}) |         await self.send_json({'type': 'start_private_chat', 'channel': message['channel']}) | ||||||
|   | |||||||
| @@ -10,18 +10,9 @@ from ...models import Channel | |||||||
|  |  | ||||||
|  |  | ||||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||||
|     """ |  | ||||||
|     Cette commande permet de créer les canaux de chat pour les tournois et les équipes. |  | ||||||
|     Différents canaux sont créés pour chaque tournoi, puis pour chaque poule. |  | ||||||
|     Enfin, un canal de communication par équipe est créé. |  | ||||||
|     """ |  | ||||||
|     help = "Create chat channels for tournaments and teams." |  | ||||||
|  |  | ||||||
|     def handle(self, *args, **kwargs): |     def handle(self, *args, **kwargs): | ||||||
|         activate('fr') |         activate('fr') | ||||||
|  |  | ||||||
|         # Création de canaux généraux, d'annonces, d'aide jurys et orgas, etc. |  | ||||||
|         # Le canal d'annonces est accessibles à tous⋅tes, mais seul⋅es les admins peuvent y écrire. |  | ||||||
|         Channel.objects.update_or_create( |         Channel.objects.update_or_create( | ||||||
|             name="Annonces", |             name="Annonces", | ||||||
|             defaults=dict( |             defaults=dict( | ||||||
| @@ -31,7 +22,6 @@ class Command(BaseCommand): | |||||||
|             ), |             ), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         # Un canal d'aide pour les bénévoles est dédié. |  | ||||||
|         Channel.objects.update_or_create( |         Channel.objects.update_or_create( | ||||||
|             name="Aide jurys et orgas", |             name="Aide jurys et orgas", | ||||||
|             defaults=dict( |             defaults=dict( | ||||||
| @@ -41,7 +31,6 @@ class Command(BaseCommand): | |||||||
|             ), |             ), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         # Un canal de discussion générale en lien avec le tournoi est accessible librement. |  | ||||||
|         Channel.objects.update_or_create( |         Channel.objects.update_or_create( | ||||||
|             name="Général", |             name="Général", | ||||||
|             defaults=dict( |             defaults=dict( | ||||||
| @@ -51,8 +40,6 @@ class Command(BaseCommand): | |||||||
|             ), |             ), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         # Un canal de discussion entre participant⋅es est accessible à tous⋅tes, |  | ||||||
|         # dont l'objectif est de faciliter la mise en relation entre élèves afin de constituer une équipe. |  | ||||||
|         Channel.objects.update_or_create( |         Channel.objects.update_or_create( | ||||||
|             name="Je cherche une équipe", |             name="Je cherche une équipe", | ||||||
|             defaults=dict( |             defaults=dict( | ||||||
| @@ -62,7 +49,6 @@ class Command(BaseCommand): | |||||||
|             ), |             ), | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         # Un canal de discussion libre est accessible pour tous⋅tes. |  | ||||||
|         Channel.objects.update_or_create( |         Channel.objects.update_or_create( | ||||||
|             name="Détente", |             name="Détente", | ||||||
|             defaults=dict( |             defaults=dict( | ||||||
| @@ -73,10 +59,6 @@ class Command(BaseCommand): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         for tournament in Tournament.objects.all(): |         for tournament in Tournament.objects.all(): | ||||||
|             # Pour chaque tournoi, on crée un canal d'annonces, un canal général et un de détente, |  | ||||||
|             # qui sont comme les canaux généraux du même nom mais réservés aux membres du tournoi concerné. |  | ||||||
|             # Les membres d'un tournoi sont les organisateur⋅rices, les juré⋅es d'une poule du tournoi |  | ||||||
|             # ainsi que les membres d'une équipe inscrite au tournoi et qui est validée. |  | ||||||
|             Channel.objects.update_or_create( |             Channel.objects.update_or_create( | ||||||
|                 name=f"{tournament.name} - Annonces", |                 name=f"{tournament.name} - Annonces", | ||||||
|                 defaults=dict( |                 defaults=dict( | ||||||
| @@ -107,7 +89,6 @@ class Command(BaseCommand): | |||||||
|                 ), |                 ), | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|             # Un canal réservé à tous⋅tes les juré⋅es du tournoi est créé. |  | ||||||
|             Channel.objects.update_or_create( |             Channel.objects.update_or_create( | ||||||
|                 name=f"{tournament.name} - Juré⋅es", |                 name=f"{tournament.name} - Juré⋅es", | ||||||
|                 defaults=dict( |                 defaults=dict( | ||||||
| @@ -119,7 +100,6 @@ class Command(BaseCommand): | |||||||
|             ) |             ) | ||||||
|  |  | ||||||
|             if tournament.remote: |             if tournament.remote: | ||||||
|                 # Dans le cadre d'un tournoi distanciel, un canal pour les président⋅es de jury est créé. |  | ||||||
|                 Channel.objects.update_or_create( |                 Channel.objects.update_or_create( | ||||||
|                     name=f"{tournament.name} - Président⋅es de jury", |                     name=f"{tournament.name} - Président⋅es de jury", | ||||||
|                     defaults=dict( |                     defaults=dict( | ||||||
| @@ -131,8 +111,6 @@ class Command(BaseCommand): | |||||||
|                 ) |                 ) | ||||||
|  |  | ||||||
|                 for pool in tournament.pools.all(): |                 for pool in tournament.pools.all(): | ||||||
|                     # Pour chaque poule d'un tournoi distanciel, on crée un canal pour les membres de la poule |  | ||||||
|                     # (équipes et juré⋅es), et un pour les juré⋅es uniquement. |  | ||||||
|                     Channel.objects.update_or_create( |                     Channel.objects.update_or_create( | ||||||
|                         name=f"{tournament.name} - Poule {pool.short_name}", |                         name=f"{tournament.name} - Poule {pool.short_name}", | ||||||
|                         defaults=dict( |                         defaults=dict( | ||||||
| @@ -154,7 +132,6 @@ class Command(BaseCommand): | |||||||
|                     ) |                     ) | ||||||
|  |  | ||||||
|         for team in Team.objects.filter(participation__valid=True).all(): |         for team in Team.objects.filter(participation__valid=True).all(): | ||||||
|             # Chaque équipe validée a le droit à son canal de communication. |  | ||||||
|             Channel.objects.update_or_create( |             Channel.objects.update_or_create( | ||||||
|                 name=f"Équipe {team.trigram}", |                 name=f"Équipe {team.trigram}", | ||||||
|                 defaults=dict( |                 defaults=dict( | ||||||
|   | |||||||
| @@ -1,26 +0,0 @@ | |||||||
| # Generated by Django 5.0.3 on 2024-04-28 18:52 |  | ||||||
|  |  | ||||||
| from django.conf import settings |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("chat", "0002_alter_channel_options_channel_category"), |  | ||||||
|         migrations.swappable_dependency(settings.AUTH_USER_MODEL), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AddField( |  | ||||||
|             model_name="message", |  | ||||||
|             name="users_read", |  | ||||||
|             field=models.ManyToManyField( |  | ||||||
|                 blank=True, |  | ||||||
|                 help_text="Users who have read the message.", |  | ||||||
|                 related_name="+", |  | ||||||
|                 to=settings.AUTH_USER_MODEL, |  | ||||||
|                 verbose_name="users read", |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
| @@ -1,94 +0,0 @@ | |||||||
| # Generated by Django 5.0.6 on 2024-05-26 20:08 |  | ||||||
|  |  | ||||||
| from django.db import migrations, models |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): |  | ||||||
|  |  | ||||||
|     dependencies = [ |  | ||||||
|         ("chat", "0003_message_users_read"), |  | ||||||
|     ] |  | ||||||
|  |  | ||||||
|     operations = [ |  | ||||||
|         migrations.AlterField( |  | ||||||
|             model_name="channel", |  | ||||||
|             name="category", |  | ||||||
|             field=models.CharField( |  | ||||||
|                 choices=[ |  | ||||||
|                     ("general", "General channels"), |  | ||||||
|                     ("tournament", "Tournament channels"), |  | ||||||
|                     ("team", "Team channels"), |  | ||||||
|                     ("private", "Private channels"), |  | ||||||
|                 ], |  | ||||||
|                 default="general", |  | ||||||
|                 help_text="Category of the channel, between general channels, tournament-specific channels, team channels or private channels. Will be used to sort channels in the channel list.", |  | ||||||
|                 max_length=255, |  | ||||||
|                 verbose_name="category", |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|         migrations.AlterField( |  | ||||||
|             model_name="channel", |  | ||||||
|             name="name", |  | ||||||
|             field=models.CharField( |  | ||||||
|                 help_text="Visible name of the channel.", |  | ||||||
|                 max_length=255, |  | ||||||
|                 verbose_name="name", |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|         migrations.AlterField( |  | ||||||
|             model_name="channel", |  | ||||||
|             name="read_access", |  | ||||||
|             field=models.CharField( |  | ||||||
|                 choices=[ |  | ||||||
|                     ("anonymous", "Everyone, including anonymous users"), |  | ||||||
|                     ("authenticated", "Authenticated users"), |  | ||||||
|                     ("volunteer", "All volunteers"), |  | ||||||
|                     ("tournament", "All members of a given tournament"), |  | ||||||
|                     ("organizer", "Tournament organizers only"), |  | ||||||
|                     ( |  | ||||||
|                         "jury_president", |  | ||||||
|                         "Tournament organizers and jury presidents of the tournament", |  | ||||||
|                     ), |  | ||||||
|                     ("jury", "Jury members of the pool"), |  | ||||||
|                     ("pool", "Jury members and participants of the pool"), |  | ||||||
|                     ( |  | ||||||
|                         "team", |  | ||||||
|                         "Members of the team and organizers of concerned tournaments", |  | ||||||
|                     ), |  | ||||||
|                     ("private", "Private, reserved to explicit authorized users"), |  | ||||||
|                     ("admin", "Admin users"), |  | ||||||
|                 ], |  | ||||||
|                 help_text="Permission type that is required to read the messages of the channels.", |  | ||||||
|                 max_length=16, |  | ||||||
|                 verbose_name="read permission", |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|         migrations.AlterField( |  | ||||||
|             model_name="channel", |  | ||||||
|             name="write_access", |  | ||||||
|             field=models.CharField( |  | ||||||
|                 choices=[ |  | ||||||
|                     ("anonymous", "Everyone, including anonymous users"), |  | ||||||
|                     ("authenticated", "Authenticated users"), |  | ||||||
|                     ("volunteer", "All volunteers"), |  | ||||||
|                     ("tournament", "All members of a given tournament"), |  | ||||||
|                     ("organizer", "Tournament organizers only"), |  | ||||||
|                     ( |  | ||||||
|                         "jury_president", |  | ||||||
|                         "Tournament organizers and jury presidents of the tournament", |  | ||||||
|                     ), |  | ||||||
|                     ("jury", "Jury members of the pool"), |  | ||||||
|                     ("pool", "Jury members and participants of the pool"), |  | ||||||
|                     ( |  | ||||||
|                         "team", |  | ||||||
|                         "Members of the team and organizers of concerned tournaments", |  | ||||||
|                     ), |  | ||||||
|                     ("private", "Private, reserved to explicit authorized users"), |  | ||||||
|                     ("admin", "Admin users"), |  | ||||||
|                 ], |  | ||||||
|                 help_text="Permission type that is required to write a message to a channel.", |  | ||||||
|                 max_length=16, |  | ||||||
|                 verbose_name="write permission", |  | ||||||
|             ), |  | ||||||
|         ), |  | ||||||
|     ] |  | ||||||
							
								
								
									
										104
									
								
								chat/models.py
									
									
									
									
									
								
							
							
						
						| @@ -13,11 +13,6 @@ from tfjm.permissions import PermissionType | |||||||
|  |  | ||||||
|  |  | ||||||
| class Channel(models.Model): | class Channel(models.Model): | ||||||
|     """ |  | ||||||
|     Ce modèle représente un canal de chat, défini par son nom, sa catégorie, les permissions de lecture et d'écriture |  | ||||||
|     requises pour accéder au canal, et éventuellement un tournoi, une poule ou une équipe associée. |  | ||||||
|     """ |  | ||||||
|  |  | ||||||
|     class ChannelCategory(models.TextChoices): |     class ChannelCategory(models.TextChoices): | ||||||
|         GENERAL = 'general', _("General channels") |         GENERAL = 'general', _("General channels") | ||||||
|         TOURNAMENT = 'tournament', _("Tournament channels") |         TOURNAMENT = 'tournament', _("Tournament channels") | ||||||
| @@ -27,7 +22,6 @@ class Channel(models.Model): | |||||||
|     name = models.CharField( |     name = models.CharField( | ||||||
|         max_length=255, |         max_length=255, | ||||||
|         verbose_name=_("name"), |         verbose_name=_("name"), | ||||||
|         help_text=_("Visible name of the channel."), |  | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     category = models.CharField( |     category = models.CharField( | ||||||
| @@ -35,22 +29,18 @@ class Channel(models.Model): | |||||||
|         verbose_name=_("category"), |         verbose_name=_("category"), | ||||||
|         choices=ChannelCategory, |         choices=ChannelCategory, | ||||||
|         default=ChannelCategory.GENERAL, |         default=ChannelCategory.GENERAL, | ||||||
|         help_text=_("Category of the channel, between general channels, tournament-specific channels, team channels " |  | ||||||
|                     "or private channels. Will be used to sort channels in the channel list."), |  | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     read_access = models.CharField( |     read_access = models.CharField( | ||||||
|         max_length=16, |         max_length=16, | ||||||
|         verbose_name=_("read permission"), |         verbose_name=_("read permission"), | ||||||
|         choices=PermissionType, |         choices=PermissionType, | ||||||
|         help_text=_("Permission type that is required to read the messages of the channels."), |  | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     write_access = models.CharField( |     write_access = models.CharField( | ||||||
|         max_length=16, |         max_length=16, | ||||||
|         verbose_name=_("write permission"), |         verbose_name=_("write permission"), | ||||||
|         choices=PermissionType, |         choices=PermissionType, | ||||||
|         help_text=_("Permission type that is required to write a message to a channel."), |  | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     tournament = models.ForeignKey( |     tournament = models.ForeignKey( | ||||||
| @@ -102,20 +92,10 @@ class Channel(models.Model): | |||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     def get_visible_name(self, user: User) -> str: |     def get_visible_name(self, user: User) -> str: | ||||||
|         """ |  | ||||||
|         Renvoie le nom du channel tel qu'il est visible pour l'utilisateur⋅rice donné. |  | ||||||
|         Dans le cas d'un canal classique, renvoie directement le nom. |  | ||||||
|         Dans le cas d'un canal privé, renvoie la liste des personnes membres du canal, |  | ||||||
|         à l'exception de la personne connectée, afin de ne pas afficher son propre nom. |  | ||||||
|         Dans le cas d'un chat avec uniquement soi-même, on affiche que notre propre nom. |  | ||||||
|         """ |  | ||||||
|         if self.private: |         if self.private: | ||||||
|             # Le canal est privé, on renvoie la liste des personnes membres du canal |  | ||||||
|             # à l'exception de soi-même (sauf si on est la seule personne dans le canal) |  | ||||||
|             users = [f"{u.first_name} {u.last_name}" for u in self.invited.all() if u != user] \ |             users = [f"{u.first_name} {u.last_name}" for u in self.invited.all() if u != user] \ | ||||||
|                     or [f"{user.first_name} {user.last_name}"] |                     or [f"{user.first_name} {user.last_name}"] | ||||||
|             return ", ".join(users) |             return ", ".join(users) | ||||||
|         # Le canal est public, on renvoie directement le nom |  | ||||||
|         return self.name |         return self.name | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
| @@ -123,77 +103,39 @@ class Channel(models.Model): | |||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     async def get_accessible_channels(user: User, permission_type: str = 'read') -> QuerySet["Channel"]: |     async def get_accessible_channels(user: User, permission_type: str = 'read') -> QuerySet["Channel"]: | ||||||
|         """ |  | ||||||
|         Renvoie les canaux auxquels l'utilisateur⋅rice donné a accès, en lecture ou en écriture. |  | ||||||
|  |  | ||||||
|         Types de permissions : |  | ||||||
|         ANONYMOUS : Tout le monde, y compris les utilisateur⋅rices non connecté⋅es |  | ||||||
|         AUTHENTICATED : Toustes les utilisateur⋅rices connecté⋅es |  | ||||||
|         VOLUNTEER : Toustes les bénévoles |  | ||||||
|         TOURNAMENT_MEMBER : Toustes les membres d'un tournoi donné (orgas, juré⋅es, participant⋅es) |  | ||||||
|         TOURNAMENT_ORGANIZER : Les organisateur⋅rices d'un tournoi donné |  | ||||||
|         TOURNAMENT_JURY_PRESIDENT : Les organisateur⋅rices et les président⋅es de jury d'un tournoi donné |  | ||||||
|         JURY_MEMBER : Les membres du jury d'une poule donnée, ou les organisateur⋅rices du tournoi |  | ||||||
|         POOL_MEMBER : Les membres du jury et les participant⋅es d'une poule donnée, ou les organisateur⋅rices du tournoi |  | ||||||
|         TEAM_MEMBER : Les membres d'une équipe donnée |  | ||||||
|         PRIVATE : Les utilisateur⋅rices explicitement invité⋅es |  | ||||||
|         ADMIN : Les utilisateur⋅rices administrateur⋅rices (qui ont accès à tout) |  | ||||||
|  |  | ||||||
|         Les canaux privés sont utilisés pour les messages privés, et ne sont pas affichés aux admins. |  | ||||||
|  |  | ||||||
|         :param user: L'utilisateur⋅rice dont on veut récupérer la liste des canaux. |  | ||||||
|         :param permission_type: Le type de permission concerné (read ou write). |  | ||||||
|         :return: Le Queryset des canaux autorisés. |  | ||||||
|         """ |  | ||||||
|         permission_type = 'write_access' if 'write' in permission_type.lower() else 'read_access' |         permission_type = 'write_access' if 'write' in permission_type.lower() else 'read_access' | ||||||
|  |  | ||||||
|         qs = Channel.objects.none() |         qs = Channel.objects.none() | ||||||
|         if user.is_anonymous: |         if user.is_anonymous: | ||||||
|             # Les utilisateur⋅rices non connecté⋅es ont accès aux canaux publics pour toustes |  | ||||||
|             return Channel.objects.filter(**{permission_type: PermissionType.ANONYMOUS}) |             return Channel.objects.filter(**{permission_type: PermissionType.ANONYMOUS}) | ||||||
|  |  | ||||||
|         # Les utilisateur⋅rices connecté⋅es ont accès aux canaux publics pour les personnes connectées |  | ||||||
|         qs |= Channel.objects.filter(**{permission_type: PermissionType.AUTHENTICATED}) |         qs |= Channel.objects.filter(**{permission_type: PermissionType.AUTHENTICATED}) | ||||||
|         registration = await Registration.objects.prefetch_related('user').aget(user_id=user.id) |         registration = await Registration.objects.prefetch_related('user').aget(user_id=user.id) | ||||||
|  |  | ||||||
|         if registration.is_admin: |         if registration.is_admin: | ||||||
|             # Les administrateur⋅rices ont accès à tous les canaux, sauf les canaux privés sont iels ne sont pas membres |  | ||||||
|             return Channel.objects.prefetch_related('invited').exclude(~Q(invited=user) & Q(private=True)).all() |             return Channel.objects.prefetch_related('invited').exclude(~Q(invited=user) & Q(private=True)).all() | ||||||
|  |  | ||||||
|         if registration.is_volunteer: |         if registration.is_volunteer: | ||||||
|             registration = await VolunteerRegistration.objects \ |             registration = await VolunteerRegistration.objects \ | ||||||
|                 .prefetch_related('jury_in__tournament', 'organized_tournaments').aget(user_id=user.id) |                 .prefetch_related('jury_in__tournament', 'organized_tournaments').aget(user_id=user.id) | ||||||
|  |  | ||||||
|             # Les bénévoles ont accès aux canaux pour bénévoles |  | ||||||
|             qs |= Channel.objects.filter(**{permission_type: PermissionType.VOLUNTEER}) |             qs |= Channel.objects.filter(**{permission_type: PermissionType.VOLUNTEER}) | ||||||
|  |  | ||||||
|             # Iels ont accès aux tournois dont iels sont organisateur⋅rices ou juré⋅es |  | ||||||
|             # pour la permission TOURNAMENT_MEMBER |  | ||||||
|             qs |= Channel.objects.filter(Q(tournament__in=registration.interesting_tournaments), |             qs |= Channel.objects.filter(Q(tournament__in=registration.interesting_tournaments), | ||||||
|                                          **{permission_type: PermissionType.TOURNAMENT_MEMBER}) |                                          **{permission_type: PermissionType.TOURNAMENT_MEMBER}) | ||||||
|  |  | ||||||
|             # Iels ont accès aux canaux pour les organisateur⋅rices des tournois dont iels sont organisateur⋅rices |  | ||||||
|             # pour la permission TOURNAMENT_ORGANIZER |  | ||||||
|             qs |= Channel.objects.filter(Q(tournament__in=registration.organized_tournaments.all()), |             qs |= Channel.objects.filter(Q(tournament__in=registration.organized_tournaments.all()), | ||||||
|                                          **{permission_type: PermissionType.TOURNAMENT_ORGANIZER}) |                                          **{permission_type: PermissionType.TOURNAMENT_ORGANIZER}) | ||||||
|  |  | ||||||
|             # Iels ont accès aux canaux pour les organisateur⋅rices et président⋅es de jury des tournois dont iels sont |  | ||||||
|             # organisateur⋅rices ou juré⋅es pour la permission TOURNAMENT_JURY_PRESIDENT |  | ||||||
|             qs |= Channel.objects.filter(Q(tournament__pools__in=registration.pools_presided.all()) |             qs |= Channel.objects.filter(Q(tournament__pools__in=registration.pools_presided.all()) | ||||||
|                                          | Q(tournament__in=registration.organized_tournaments.all()), |                                          | Q(tournament__in=registration.organized_tournaments.all()), | ||||||
|                                          **{permission_type: PermissionType.TOURNAMENT_JURY_PRESIDENT}) |                                          **{permission_type: PermissionType.TOURNAMENT_JURY_PRESIDENT}) | ||||||
|  |  | ||||||
|             # Iels ont accès aux canaux pour les juré⋅es des poules dont iels sont juré⋅es |  | ||||||
|             # ou les organisateur⋅rices des tournois dont iels sont organisateur⋅rices |  | ||||||
|             # pour la permission JURY_MEMBER |  | ||||||
|             qs |= Channel.objects.filter(Q(pool__in=registration.jury_in.all()) |             qs |= Channel.objects.filter(Q(pool__in=registration.jury_in.all()) | ||||||
|                                          | Q(pool__tournament__in=registration.organized_tournaments.all()) |                                          | Q(pool__tournament__in=registration.organized_tournaments.all()) | ||||||
|                                          | Q(pool__tournament__pools__in=registration.pools_presided.all()), |                                          | Q(pool__tournament__pools__in=registration.pools_presided.all()), | ||||||
|                                          **{permission_type: PermissionType.JURY_MEMBER}) |                                          **{permission_type: PermissionType.JURY_MEMBER}) | ||||||
|  |  | ||||||
|             # Iels ont accès aux canaux pour les juré⋅es et participant⋅es des poules dont iels sont juré⋅es |  | ||||||
|             # ou les organisateur⋅rices des tournois dont iels sont organisateur⋅rices |  | ||||||
|             # pour la permission POOL_MEMBER |  | ||||||
|             qs |= Channel.objects.filter(Q(pool__in=registration.jury_in.all()) |             qs |= Channel.objects.filter(Q(pool__in=registration.jury_in.all()) | ||||||
|                                          | Q(pool__tournament__in=registration.organized_tournaments.all()) |                                          | Q(pool__tournament__in=registration.organized_tournaments.all()) | ||||||
|                                          | Q(pool__tournament__pools__in=registration.pools_presided.all()), |                                          | Q(pool__tournament__pools__in=registration.pools_presided.all()), | ||||||
| @@ -209,20 +151,15 @@ class Channel(models.Model): | |||||||
|             if team.participation.final: |             if team.participation.final: | ||||||
|                 tournaments.append(await Tournament.objects.aget(final=True)) |                 tournaments.append(await Tournament.objects.aget(final=True)) | ||||||
|  |  | ||||||
|             # Les participant⋅es ont accès aux canaux généraux pour le tournoi dont iels sont membres |  | ||||||
|             # Cela comprend la finale s'iels sont finalistes |  | ||||||
|             qs |= Channel.objects.filter(Q(tournament__in=tournaments), |             qs |= Channel.objects.filter(Q(tournament__in=tournaments), | ||||||
|                                          **{permission_type: PermissionType.TOURNAMENT_MEMBER}) |                                          **{permission_type: PermissionType.TOURNAMENT_MEMBER}) | ||||||
|  |  | ||||||
|             # Iels ont accès aux canaux généraux pour les poules dont iels sont participant⋅es |  | ||||||
|             qs |= Channel.objects.filter(Q(pool__in=team.participation.pools.all()), |             qs |= Channel.objects.filter(Q(pool__in=team.participation.pools.all()), | ||||||
|                                          **{permission_type: PermissionType.POOL_MEMBER}) |                                          **{permission_type: PermissionType.POOL_MEMBER}) | ||||||
|  |  | ||||||
|             # Iels ont accès aux canaux propres à leur équipe |  | ||||||
|             qs |= Channel.objects.filter(Q(team=team), |             qs |= Channel.objects.filter(Q(team=team), | ||||||
|                                          **{permission_type: PermissionType.TEAM_MEMBER}) |                                          **{permission_type: PermissionType.TEAM_MEMBER}) | ||||||
|  |  | ||||||
|         # Les utilisateur⋅rices ont de plus accès aux messages privés qui leur sont adressés |  | ||||||
|         qs |= Channel.objects.filter(invited=user).prefetch_related('invited') |         qs |= Channel.objects.filter(invited=user).prefetch_related('invited') | ||||||
|  |  | ||||||
|         return qs |         return qs | ||||||
| @@ -234,12 +171,6 @@ class Channel(models.Model): | |||||||
|  |  | ||||||
|  |  | ||||||
| class Message(models.Model): | class Message(models.Model): | ||||||
|     """ |  | ||||||
|     Ce modèle représente un message de chat. |  | ||||||
|     Un message appartient à un canal, et est défini par son contenu, son auteur⋅rice, sa date de création et sa date |  | ||||||
|     de dernière modification. |  | ||||||
|     De plus, on garde en mémoire les utilisateur⋅rices qui ont lu le message. |  | ||||||
|     """ |  | ||||||
|     channel = models.ForeignKey( |     channel = models.ForeignKey( | ||||||
|         Channel, |         Channel, | ||||||
|         on_delete=models.CASCADE, |         on_delete=models.CASCADE, | ||||||
| @@ -269,82 +200,55 @@ class Message(models.Model): | |||||||
|         verbose_name=_("content"), |         verbose_name=_("content"), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     users_read = models.ManyToManyField( |     def get_author_name(self): | ||||||
|         'auth.User', |  | ||||||
|         verbose_name=_("users read"), |  | ||||||
|         related_name='+', |  | ||||||
|         blank=True, |  | ||||||
|         help_text=_("Users who have read the message."), |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     def get_author_name(self) -> str: |  | ||||||
|         """ |  | ||||||
|         Renvoie le nom de l'auteur⋅rice du message, en fonction de son rôle dans l'organisation |  | ||||||
|         dans le cadre d'un⋅e bénévole, ou de son équipe dans le cadre d'un⋅e participant⋅e. |  | ||||||
|         """ |  | ||||||
|         registration = self.author.registration |         registration = self.author.registration | ||||||
|  |  | ||||||
|         author_name = f"{self.author.first_name} {self.author.last_name}" |         author_name = f"{self.author.first_name} {self.author.last_name}" | ||||||
|         if registration.is_volunteer: |         if registration.is_volunteer: | ||||||
|             if registration.is_admin: |             if registration.is_admin: | ||||||
|                 # Les administrateur⋅rices ont le suffixe (CNO) |  | ||||||
|                 author_name += " (CNO)" |                 author_name += " (CNO)" | ||||||
|  |  | ||||||
|             if self.channel.pool: |             if self.channel.pool: | ||||||
|                 if registration == self.channel.pool.jury_president: |                 if registration == self.channel.pool.jury_president: | ||||||
|                     # Læ président⋅e de jury de la poule a le suffixe (P. jury) |  | ||||||
|                     author_name += " (P. jury)" |                     author_name += " (P. jury)" | ||||||
|                 elif registration in self.channel.pool.juries.all(): |                 elif registration in self.channel.pool.juries.all(): | ||||||
|                     # Les juré⋅es de la poule ont le suffixe (Juré⋅e) |  | ||||||
|                     author_name += " (Juré⋅e)" |                     author_name += " (Juré⋅e)" | ||||||
|                 elif registration in self.channel.pool.tournament.organizers.all(): |                 elif registration in self.channel.pool.tournament.organizers.all(): | ||||||
|                     # Les organisateur⋅rices du tournoi ont le suffixe (CRO) |  | ||||||
|                     author_name += " (CRO)" |                     author_name += " (CRO)" | ||||||
|                 else: |                 else: | ||||||
|                     # Les éventuel⋅les autres bénévoles ont le suffixe (Bénévole) |  | ||||||
|                     author_name += " (Bénévole)" |                     author_name += " (Bénévole)" | ||||||
|             elif self.channel.tournament: |             elif self.channel.tournament: | ||||||
|                 if registration in self.channel.tournament.organizers.all(): |                 if registration in self.channel.tournament.organizers.all(): | ||||||
|                     # Les organisateur⋅rices du tournoi ont le suffixe (CRO) |  | ||||||
|                     author_name += " (CRO)" |                     author_name += " (CRO)" | ||||||
|                 elif any([registration.id == pool.jury_president |                 elif any([registration.id == pool.jury_president | ||||||
|                           for pool in self.channel.tournament.pools.all()]): |                           for pool in self.channel.tournament.pools.all()]): | ||||||
|                     # Les président⋅es de jury des poules ont le suffixe (P. jury) |  | ||||||
|                     # mentionnant l'ensemble des poules qu'iels président |  | ||||||
|                     pools = ", ".join([pool.short_name |                     pools = ", ".join([pool.short_name | ||||||
|                                        for pool in self.channel.tournament.pools.all() |                                        for pool in self.channel.tournament.pools.all() | ||||||
|                                        if pool.jury_president == registration]) |                                        if pool.jury_president == registration]) | ||||||
|                     author_name += f" (P. jury {pools})" |                     author_name += f" (P. jury {pools})" | ||||||
|                 elif any([pool.juries.contains(registration) |                 elif any([pool.juries.contains(registration) | ||||||
|                           for pool in self.channel.tournament.pools.all()]): |                           for pool in self.channel.tournament.pools.all()]): | ||||||
|                     # Les juré⋅es des poules ont le suffixe (Juré⋅e) |  | ||||||
|                     # mentionnant l'ensemble des poules auxquelles iels participent |  | ||||||
|                     pools = ", ".join([pool.short_name |                     pools = ", ".join([pool.short_name | ||||||
|                                        for pool in self.channel.tournament.pools.all() |                                        for pool in self.channel.tournament.pools.all() | ||||||
|                                        if pool.juries.acontains(registration)]) |                                        if pool.juries.acontains(registration)]) | ||||||
|                     author_name += f" (Juré⋅e {pools})" |                     author_name += f" (Juré⋅e {pools})" | ||||||
|                 else: |                 else: | ||||||
|                     # Les éventuel⋅les autres bénévoles ont le suffixe (Bénévole) |  | ||||||
|                     author_name += " (Bénévole)" |                     author_name += " (Bénévole)" | ||||||
|             else: |             else: | ||||||
|                 if registration.organized_tournaments.exists(): |                 if registration.organized_tournaments.exists(): | ||||||
|                     # Les organisateur⋅rices de tournois ont le suffixe (CRO) mentionnant les tournois organisés |  | ||||||
|                     tournaments = ", ".join([tournament.name |                     tournaments = ", ".join([tournament.name | ||||||
|                                              for tournament in registration.organized_tournaments.all()]) |                                              for tournament in registration.organized_tournaments.all()]) | ||||||
|                     author_name += f" (CRO {tournaments})" |                     author_name += f" (CRO {tournaments})" | ||||||
|                 if Pool.objects.filter(jury_president=registration).exists(): |                 if Pool.objects.filter(jury_president=registration).exists(): | ||||||
|                     # Les président⋅es de jury ont le suffixe (P. jury) mentionnant les tournois présidés |  | ||||||
|                     tournaments = Tournament.objects.filter(pools__jury_president=registration).distinct() |                     tournaments = Tournament.objects.filter(pools__jury_president=registration).distinct() | ||||||
|                     tournaments = ", ".join([tournament.name for tournament in tournaments]) |                     tournaments = ", ".join([tournament.name for tournament in tournaments]) | ||||||
|                     author_name += f" (P. jury {tournaments})" |                     author_name += f" (P. jury {tournaments})" | ||||||
|                 elif registration.jury_in.exists(): |                 elif registration.jury_in.exists(): | ||||||
|                     # Les juré⋅es ont le suffixe (Juré⋅e) mentionnant les tournois auxquels iels participent |  | ||||||
|                     tournaments = Tournament.objects.filter(pools__juries=registration).distinct() |                     tournaments = Tournament.objects.filter(pools__juries=registration).distinct() | ||||||
|                     tournaments = ", ".join([tournament.name for tournament in tournaments]) |                     tournaments = ", ".join([tournament.name for tournament in tournaments]) | ||||||
|                     author_name += f" (Juré⋅e {tournaments})" |                     author_name += f" (Juré⋅e {tournaments})" | ||||||
|         else: |         else: | ||||||
|             if registration.team_id: |             if registration.team_id: | ||||||
|                 # Le trigramme de l'équipe de læ participant⋅e est ajouté en suffixe |  | ||||||
|                 team = Team.objects.get(id=registration.team_id) |                 team = Team.objects.get(id=registration.team_id) | ||||||
|                 author_name += f" ({team.trigram})" |                 author_name += f" ({team.trigram})" | ||||||
|             else: |             else: | ||||||
| @@ -352,11 +256,7 @@ class Message(models.Model): | |||||||
|  |  | ||||||
|         return author_name |         return author_name | ||||||
|  |  | ||||||
|     async def aget_author_name(self) -> str: |     async def aget_author_name(self): | ||||||
|         """ |  | ||||||
|         Fonction asynchrone pour récupérer le nom de l'auteur⋅rice du message. |  | ||||||
|         Voir `get_author_name` pour plus de détails. |  | ||||||
|         """ |  | ||||||
|         return await sync_to_async(self.get_author_name)() |         return await sync_to_async(self.get_author_name)() | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|   | |||||||
| @@ -7,14 +7,8 @@ from tfjm.permissions import PermissionType | |||||||
|  |  | ||||||
|  |  | ||||||
| def create_tournament_channels(instance: Tournament, **_kwargs): | def create_tournament_channels(instance: Tournament, **_kwargs): | ||||||
|     """ |  | ||||||
|     Lorsqu'un tournoi est créé, on crée les canaux de chat associés. |  | ||||||
|     On crée notamment un canal d'annonces (accessible en écriture uniquement aux orgas), |  | ||||||
|     un canal général, un de détente, un pour les juré⋅es et un pour les président⋅es de jury. |  | ||||||
|     """ |  | ||||||
|     tournament = instance |     tournament = instance | ||||||
|  |  | ||||||
|     # Création du canal « Tournoi - Annonces » |  | ||||||
|     Channel.objects.update_or_create( |     Channel.objects.update_or_create( | ||||||
|         name=f"{tournament.name} - Annonces", |         name=f"{tournament.name} - Annonces", | ||||||
|         defaults=dict( |         defaults=dict( | ||||||
| @@ -25,7 +19,6 @@ def create_tournament_channels(instance: Tournament, **_kwargs): | |||||||
|         ), |         ), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     # Création du canal « Tournoi - Général » |  | ||||||
|     Channel.objects.update_or_create( |     Channel.objects.update_or_create( | ||||||
|         name=f"{tournament.name} - Général", |         name=f"{tournament.name} - Général", | ||||||
|         defaults=dict( |         defaults=dict( | ||||||
| @@ -36,7 +29,6 @@ def create_tournament_channels(instance: Tournament, **_kwargs): | |||||||
|         ), |         ), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     # Création du canal « Tournoi - Détente » |  | ||||||
|     Channel.objects.update_or_create( |     Channel.objects.update_or_create( | ||||||
|         name=f"{tournament.name} - Détente", |         name=f"{tournament.name} - Détente", | ||||||
|         defaults=dict( |         defaults=dict( | ||||||
| @@ -47,7 +39,6 @@ def create_tournament_channels(instance: Tournament, **_kwargs): | |||||||
|         ), |         ), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     # Création du canal « Tournoi - Juré⋅es » |  | ||||||
|     Channel.objects.update_or_create( |     Channel.objects.update_or_create( | ||||||
|         name=f"{tournament.name} - Juré⋅es", |         name=f"{tournament.name} - Juré⋅es", | ||||||
|         defaults=dict( |         defaults=dict( | ||||||
| @@ -59,7 +50,6 @@ def create_tournament_channels(instance: Tournament, **_kwargs): | |||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     if tournament.remote: |     if tournament.remote: | ||||||
|         # Création du canal « Tournoi - Président⋅es de jury » dans le cas d'un tournoi distanciel |  | ||||||
|         Channel.objects.update_or_create( |         Channel.objects.update_or_create( | ||||||
|             name=f"{tournament.name} - Président⋅es de jury", |             name=f"{tournament.name} - Président⋅es de jury", | ||||||
|             defaults=dict( |             defaults=dict( | ||||||
| @@ -72,17 +62,10 @@ def create_tournament_channels(instance: Tournament, **_kwargs): | |||||||
|  |  | ||||||
|  |  | ||||||
| def create_pool_channels(instance: Pool, **_kwargs): | def create_pool_channels(instance: Pool, **_kwargs): | ||||||
|     """ |  | ||||||
|     Lorsqu'une poule est créée, on crée les canaux de chat associés. |  | ||||||
|     On crée notamment un canal pour les membres de la poule et un pour les juré⋅es. |  | ||||||
|     Cela ne concerne que les tournois distanciels. |  | ||||||
|     """ |  | ||||||
|     pool = instance |     pool = instance | ||||||
|     tournament = pool.tournament |     tournament = pool.tournament | ||||||
|  |  | ||||||
|     if tournament.remote: |     if tournament.remote: | ||||||
|         # Dans le cadre d'un tournoi distanciel, on crée un canal pour les membres de la poule |  | ||||||
|         # et un pour les juré⋅es de la poule. |  | ||||||
|         Channel.objects.update_or_create( |         Channel.objects.update_or_create( | ||||||
|             name=f"{tournament.name} - Poule {pool.short_name}", |             name=f"{tournament.name} - Poule {pool.short_name}", | ||||||
|             defaults=dict( |             defaults=dict( | ||||||
| @@ -105,9 +88,6 @@ def create_pool_channels(instance: Pool, **_kwargs): | |||||||
|  |  | ||||||
|  |  | ||||||
| def create_team_channel(instance: Participation, **_kwargs): | def create_team_channel(instance: Participation, **_kwargs): | ||||||
|     """ |  | ||||||
|     Lorsqu'une équipe est validée, on crée un canal de chat associé. |  | ||||||
|     """ |  | ||||||
|     if instance.valid: |     if instance.valid: | ||||||
|         Channel.objects.update_or_create( |         Channel.objects.update_or_create( | ||||||
|             name=f"Équipe {instance.team.trigram}", |             name=f"Équipe {instance.team.trigram}", | ||||||
|   | |||||||
							
								
								
									
										464
									
								
								chat/static/chat.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,464 @@ | |||||||
|  | (async () => { | ||||||
|  |     // check notification permission | ||||||
|  |     // This is useful to alert people that they should do something | ||||||
|  |     await Notification.requestPermission() | ||||||
|  | })() | ||||||
|  |  | ||||||
|  | const MAX_MESSAGES = 50 | ||||||
|  |  | ||||||
|  | const channel_categories = ['general', 'tournament', 'team', 'private'] | ||||||
|  | let channels = {} | ||||||
|  | let messages = {} | ||||||
|  | let selected_channel_id = null | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Display a new notification with the given title and the given body. | ||||||
|  |  * @param title The title of the notification | ||||||
|  |  * @param body The body of the notification | ||||||
|  |  * @param timeout The time (in milliseconds) after that the notification automatically closes. 0 to make indefinite. Default to 5000 ms. | ||||||
|  |  * @return Notification | ||||||
|  |  */ | ||||||
|  | function showNotification(title, body, timeout = 5000) { | ||||||
|  |     Notification.requestPermission().then((status) => { | ||||||
|  |         if (status === 'granted') | ||||||
|  |             new Notification(title, {'body': body, 'icon': "/static/tfjm-192.png"}) | ||||||
|  |     }) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function selectChannel(channel_id) { | ||||||
|  |     let channel = channels[channel_id] | ||||||
|  |     if (!channel) { | ||||||
|  |         console.error('Channel not found:', channel_id) | ||||||
|  |         return | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     selected_channel_id = channel_id | ||||||
|  |  | ||||||
|  |     window.history.replaceState({}, null, `#channel-${channel['id']}`) | ||||||
|  |  | ||||||
|  |     let channelTitle = document.getElementById('channel-title') | ||||||
|  |     channelTitle.innerText = channel['name'] | ||||||
|  |  | ||||||
|  |     let messageInput = document.getElementById('input-message') | ||||||
|  |     messageInput.disabled = !channel['write_access'] | ||||||
|  |  | ||||||
|  |     redrawMessages() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function sendMessage() { | ||||||
|  |     let messageInput = document.getElementById('input-message') | ||||||
|  |     let message = messageInput.value | ||||||
|  |     messageInput.value = '' | ||||||
|  |  | ||||||
|  |     if (!message) { | ||||||
|  |         return | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     socket.send(JSON.stringify({ | ||||||
|  |         'type': 'send_message', | ||||||
|  |         'channel_id': selected_channel_id, | ||||||
|  |         'content': message, | ||||||
|  |     })) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function setChannels(new_channels) { | ||||||
|  |     channels = {} | ||||||
|  |     let categoryLists = {} | ||||||
|  |     for (let category of channel_categories) { | ||||||
|  |         categoryLists[category] = document.getElementById(`nav-${category}-channels-tab`) | ||||||
|  |         categoryLists[category].innerHTML = '' | ||||||
|  |         categoryLists[category].parentElement.classList.add('d-none') | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     for (let channel of new_channels) { | ||||||
|  |         channels[channel['id']] = channel | ||||||
|  |         if (!messages[channel['id']]) | ||||||
|  |             messages[channel['id']] = new Map() | ||||||
|  |  | ||||||
|  |         let categoryList = categoryLists[channel['category']] | ||||||
|  |         categoryList.parentElement.classList.remove('d-none') | ||||||
|  |  | ||||||
|  |         let navItem = document.createElement('li') | ||||||
|  |         navItem.classList.add('list-group-item') | ||||||
|  |         navItem.id = `tab-channel-${channel['id']}` | ||||||
|  |         navItem.setAttribute('data-bs-dismiss', 'offcanvas') | ||||||
|  |         navItem.onclick = () => selectChannel(channel['id']) | ||||||
|  |         categoryList.appendChild(navItem) | ||||||
|  |  | ||||||
|  |         let channelButton = document.createElement('button') | ||||||
|  |         channelButton.classList.add('nav-link') | ||||||
|  |         channelButton.type = 'button' | ||||||
|  |         channelButton.innerText = channel['name'] | ||||||
|  |         navItem.appendChild(channelButton) | ||||||
|  |  | ||||||
|  |         fetchMessages(channel['id']) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) { | ||||||
|  |         if (window.location.hash) { | ||||||
|  |             let channel_id = parseInt(window.location.hash.substring(9)) | ||||||
|  |             if (channels[channel_id]) | ||||||
|  |                 selectChannel(channel_id) | ||||||
|  |             else | ||||||
|  |                 selectChannel(Object.keys(channels)[0]) | ||||||
|  |         } | ||||||
|  |         else | ||||||
|  |             selectChannel(Object.keys(channels)[0]) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function receiveMessage(message) { | ||||||
|  |     let scrollableContent = document.getElementById('chat-messages') | ||||||
|  |     let isScrolledToBottom = scrollableContent.scrollHeight - scrollableContent.clientHeight <= scrollableContent.scrollTop + 1 | ||||||
|  |  | ||||||
|  |     messages[message['channel_id']].set(message['id'], message) | ||||||
|  |     redrawMessages() | ||||||
|  |  | ||||||
|  |     // Scroll to bottom if the user was already at the bottom | ||||||
|  |     if (isScrolledToBottom) | ||||||
|  |         scrollableContent.scrollTop = scrollableContent.scrollHeight - scrollableContent.clientHeight | ||||||
|  |  | ||||||
|  |     if (message['content'].includes("@everyone")) | ||||||
|  |         showNotification(channels[message['channel_id']]['name'], `${message['author']} : ${message['content']}`) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function editMessage(data) { | ||||||
|  |     messages[data['channel_id']].get(data['id'])['content'] = data['content'] | ||||||
|  |     redrawMessages() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function deleteMessage(data) { | ||||||
|  |     messages[data['channel_id']].delete(data['id']) | ||||||
|  |     redrawMessages() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function fetchMessages(channel_id, offset = 0, limit = MAX_MESSAGES) { | ||||||
|  |     socket.send(JSON.stringify({ | ||||||
|  |         'type': 'fetch_messages', | ||||||
|  |         'channel_id': channel_id, | ||||||
|  |         'offset': offset, | ||||||
|  |         'limit': limit, | ||||||
|  |     })) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function fetchPreviousMessages() { | ||||||
|  |     let channel_id = selected_channel_id | ||||||
|  |     let offset = messages[channel_id].size | ||||||
|  |     fetchMessages(channel_id, offset, MAX_MESSAGES) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function receiveFetchedMessages(data) { | ||||||
|  |     let channel_id = data['channel_id'] | ||||||
|  |     let new_messages = data['messages'] | ||||||
|  |  | ||||||
|  |     if (!messages[channel_id]) | ||||||
|  |         messages[channel_id] = new Map() | ||||||
|  |  | ||||||
|  |     for (let message of new_messages) | ||||||
|  |         messages[channel_id].set(message['id'], message) | ||||||
|  |  | ||||||
|  |     // Sort messages by timestamp | ||||||
|  |     messages[channel_id] = new Map([...messages[channel_id].values()] | ||||||
|  |       .sort((a, b) => new Date(a['timestamp']) - new Date(b['timestamp'])) | ||||||
|  |       .map(message => [message['id'], message])) | ||||||
|  |  | ||||||
|  |     redrawMessages() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function startPrivateChat(data) { | ||||||
|  |     let channel = data['channel'] | ||||||
|  |     if (!channel) { | ||||||
|  |         console.error('Private chat not found:', data) | ||||||
|  |         return | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (!channels[channel['id']]) { | ||||||
|  |         channels[channel['id']] = channel | ||||||
|  |         messages[channel['id']] = new Map() | ||||||
|  |         setChannels(Object.values(channels)) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     selectChannel(channel['id']) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function redrawMessages() { | ||||||
|  |     let messageList = document.getElementById('message-list') | ||||||
|  |     messageList.innerHTML = '' | ||||||
|  |  | ||||||
|  |     let lastMessage = null | ||||||
|  |     let lastContentDiv = null | ||||||
|  |  | ||||||
|  |     for (let message of messages[selected_channel_id].values()) { | ||||||
|  |         if (lastMessage && lastMessage['author'] === message['author']) { | ||||||
|  |             let lastTimestamp = new Date(lastMessage['timestamp']) | ||||||
|  |             let newTimestamp = new Date(message['timestamp']) | ||||||
|  |             if ((newTimestamp - lastTimestamp) / 1000 < 60 * 10) { | ||||||
|  |                 let messageContentDiv = document.createElement('div') | ||||||
|  |                 lastContentDiv.appendChild(messageContentDiv) | ||||||
|  |                 let messageContentSpan = document.createElement('span') | ||||||
|  |                 messageContentSpan.innerText = message['content'] | ||||||
|  |                 messageContentDiv.appendChild(messageContentSpan) | ||||||
|  |  | ||||||
|  |                 registerMessageContextMenu(message, messageContentSpan) | ||||||
|  |                 continue | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let messageElement = document.createElement('li') | ||||||
|  |         messageElement.classList.add('list-group-item') | ||||||
|  |         messageList.appendChild(messageElement) | ||||||
|  |  | ||||||
|  |         let authorDiv = document.createElement('div') | ||||||
|  |         messageElement.appendChild(authorDiv) | ||||||
|  |  | ||||||
|  |         let authorSpan = document.createElement('span') | ||||||
|  |         authorSpan.classList.add('text-muted', 'fw-bold') | ||||||
|  |         authorSpan.innerText = message['author'] | ||||||
|  |         authorDiv.appendChild(authorSpan) | ||||||
|  |  | ||||||
|  |         registerSendPrivateMessageContextMenu(message, authorSpan) | ||||||
|  |  | ||||||
|  |         let dateSpan = document.createElement('span') | ||||||
|  |         dateSpan.classList.add('text-muted', 'float-end') | ||||||
|  |         dateSpan.innerText = new Date(message['timestamp']).toLocaleString() | ||||||
|  |         authorDiv.appendChild(dateSpan) | ||||||
|  |  | ||||||
|  |         let contentDiv = document.createElement('div') | ||||||
|  |         messageElement.appendChild(contentDiv) | ||||||
|  |  | ||||||
|  |         let messageContentDiv = document.createElement('div') | ||||||
|  |         contentDiv.appendChild(messageContentDiv) | ||||||
|  |         let messageContentSpan = document.createElement('span') | ||||||
|  |         messageContentSpan.innerText = message['content'] | ||||||
|  |         messageContentDiv.appendChild(messageContentSpan) | ||||||
|  |  | ||||||
|  |         registerMessageContextMenu(message, messageContentSpan) | ||||||
|  |  | ||||||
|  |         lastMessage = message | ||||||
|  |         lastContentDiv = contentDiv | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     let fetchMoreButton = document.getElementById('fetch-previous-messages') | ||||||
|  |     if (!messages[selected_channel_id].size || messages[selected_channel_id].size % MAX_MESSAGES !== 0) | ||||||
|  |         fetchMoreButton.classList.add('d-none') | ||||||
|  |     else | ||||||
|  |         fetchMoreButton.classList.remove('d-none') | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function removeAllPopovers() { | ||||||
|  |     for (let popover of document.querySelectorAll('*[aria-describedby*="popover"]')) { | ||||||
|  |         let instance = bootstrap.Popover.getInstance(popover) | ||||||
|  |         if (instance) | ||||||
|  |             instance.dispose() | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function registerSendPrivateMessageContextMenu(message, element) { | ||||||
|  |     element.addEventListener('contextmenu', (menu_event) => { | ||||||
|  |         menu_event.preventDefault() | ||||||
|  |         removeAllPopovers() | ||||||
|  |         const popover = bootstrap.Popover.getOrCreateInstance(element, { | ||||||
|  |             'title': message['author'], | ||||||
|  |             'content': `<a id="send-private-message-link-${message['id']}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>`, | ||||||
|  |             'html': true, | ||||||
|  |         }) | ||||||
|  |         popover.show() | ||||||
|  |  | ||||||
|  |         document.getElementById('send-private-message-link-' + message['id']).addEventListener('click', event => { | ||||||
|  |             event.preventDefault() | ||||||
|  |             popover.dispose() | ||||||
|  |             socket.send(JSON.stringify({ | ||||||
|  |                 'type': 'start_private_chat', | ||||||
|  |                 'user_id': message['author_id'], | ||||||
|  |             })) | ||||||
|  |         }) | ||||||
|  |     }) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function registerMessageContextMenu(message, element) { | ||||||
|  |     element.addEventListener('contextmenu', (menu_event) => { | ||||||
|  |         menu_event.preventDefault() | ||||||
|  |         removeAllPopovers() | ||||||
|  |         let content = `<a id="send-private-message-link-msg-${message['id']}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>` | ||||||
|  |  | ||||||
|  |         let has_right_to_edit = message['author_id'] === USER_ID || IS_ADMIN | ||||||
|  |         if (has_right_to_edit) { | ||||||
|  |             content += `<hr class="my-1">` | ||||||
|  |             content += `<a id="edit-message-${message['id']}" class="nav-link" href="#" tabindex="0">Modifier</a>` | ||||||
|  |             content += `<a id="delete-message-${message['id']}" class="nav-link" href="#" tabindex="0">Supprimer</a>` | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         const popover = bootstrap.Popover.getOrCreateInstance(element, { | ||||||
|  |             'content': content, | ||||||
|  |             'html': true, | ||||||
|  |             'placement': 'bottom', | ||||||
|  |         }) | ||||||
|  |         popover.show() | ||||||
|  |  | ||||||
|  |         document.getElementById('send-private-message-link-msg-' + message['id']).addEventListener('click', event => { | ||||||
|  |             event.preventDefault() | ||||||
|  |             popover.dispose() | ||||||
|  |             socket.send(JSON.stringify({ | ||||||
|  |                 'type': 'start_private_chat', | ||||||
|  |                 'user_id': message['author_id'], | ||||||
|  |             })) | ||||||
|  |         }) | ||||||
|  |  | ||||||
|  |         if (has_right_to_edit) { | ||||||
|  |             document.getElementById('edit-message-' + message['id']).addEventListener('click', event => { | ||||||
|  |                 event.preventDefault() | ||||||
|  |                 popover.dispose() | ||||||
|  |                 let new_message = prompt("Modifier le message", message['content']) | ||||||
|  |                 if (new_message) { | ||||||
|  |                     socket.send(JSON.stringify({ | ||||||
|  |                         'type': 'edit_message', | ||||||
|  |                         'message_id': message['id'], | ||||||
|  |                         'content': new_message, | ||||||
|  |                     })) | ||||||
|  |                 } | ||||||
|  |             }) | ||||||
|  |  | ||||||
|  |             document.getElementById('delete-message-' + message['id']).addEventListener('click', event => { | ||||||
|  |                 event.preventDefault() | ||||||
|  |                 popover.dispose() | ||||||
|  |                 if (confirm("Supprimer le message ?")) { | ||||||
|  |                     socket.send(JSON.stringify({ | ||||||
|  |                         'type': 'delete_message', | ||||||
|  |                         'message_id': message['id'], | ||||||
|  |                     })) | ||||||
|  |                 } | ||||||
|  |             }) | ||||||
|  |         } | ||||||
|  |     }) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function toggleFullscreen() { | ||||||
|  |     let chatContainer = document.getElementById('chat-container') | ||||||
|  |     if (!chatContainer.getAttribute('data-fullscreen')) { | ||||||
|  |         chatContainer.setAttribute('data-fullscreen', 'true') | ||||||
|  |         chatContainer.classList.add('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3') | ||||||
|  |         window.history.replaceState({}, null, `?fullscreen=1#channel-${selected_channel_id}`) | ||||||
|  |     } | ||||||
|  |     else { | ||||||
|  |         chatContainer.removeAttribute('data-fullscreen') | ||||||
|  |         chatContainer.classList.remove('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3') | ||||||
|  |         window.history.replaceState({}, null, `?fullscreen=0#channel-${selected_channel_id}`) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | document.addEventListener('DOMContentLoaded', () => { | ||||||
|  |     document.addEventListener('click', removeAllPopovers) | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Process the received data from the server. | ||||||
|  |      * @param data The received message | ||||||
|  |      */ | ||||||
|  |     function processMessage(data) { | ||||||
|  |         switch (data['type']) { | ||||||
|  |             case 'fetch_channels': | ||||||
|  |                 setChannels(data['channels']) | ||||||
|  |                 break | ||||||
|  |             case 'send_message': | ||||||
|  |                 receiveMessage(data) | ||||||
|  |                 break | ||||||
|  |             case 'edit_message': | ||||||
|  |                 editMessage(data) | ||||||
|  |                 break | ||||||
|  |             case 'delete_message': | ||||||
|  |                 deleteMessage(data) | ||||||
|  |                 break | ||||||
|  |             case 'fetch_messages': | ||||||
|  |                 receiveFetchedMessages(data) | ||||||
|  |                 break | ||||||
|  |             case 'start_private_chat': | ||||||
|  |                 startPrivateChat(data) | ||||||
|  |                 break | ||||||
|  |             default: | ||||||
|  |                 console.log(data) | ||||||
|  |                 console.error('Unknown message type:', data['type']) | ||||||
|  |                 break | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function setupSocket(nextDelay = 1000) { | ||||||
|  |         // Open a global websocket | ||||||
|  |         socket = new WebSocket( | ||||||
|  |             (document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/ws/chat/' | ||||||
|  |         ) | ||||||
|  |         let socketOpen = false | ||||||
|  |  | ||||||
|  |         // Listen on websockets and process messages from the server | ||||||
|  |         socket.addEventListener('message', e => { | ||||||
|  |             // Parse received data as JSON | ||||||
|  |             const data = JSON.parse(e.data) | ||||||
|  |  | ||||||
|  |             processMessage(data) | ||||||
|  |         }) | ||||||
|  |  | ||||||
|  |         // Manage errors | ||||||
|  |         socket.addEventListener('close', e => { | ||||||
|  |             console.error('Chat socket closed unexpectedly, restarting…') | ||||||
|  |             setTimeout(() => setupSocket(socketOpen ? 1000 : 2 * nextDelay), nextDelay) | ||||||
|  |         }) | ||||||
|  |  | ||||||
|  |         socket.addEventListener('open', e => { | ||||||
|  |             socketOpen = true | ||||||
|  |             socket.send(JSON.stringify({ | ||||||
|  |                 'type': 'fetch_channels', | ||||||
|  |             })) | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function setupSwipeOffscreen() { | ||||||
|  |         const offcanvas = new bootstrap.Offcanvas(document.getElementById('channelSelector')) | ||||||
|  |  | ||||||
|  |         let lastX = null | ||||||
|  |         document.addEventListener('touchstart', (event) => { | ||||||
|  |             if (event.touches.length === 1) | ||||||
|  |                 lastX = event.touches[0].clientX | ||||||
|  |         }) | ||||||
|  |         document.addEventListener('touchmove', (event) => { | ||||||
|  |             if (event.touches.length === 1 && lastX !== null) { | ||||||
|  |                 const diff = event.touches[0].clientX - lastX | ||||||
|  |                 if (diff > window.innerWidth / 10 && lastX < window.innerWidth / 4) { | ||||||
|  |                     offcanvas.show() | ||||||
|  |                     lastX = null | ||||||
|  |                 } | ||||||
|  |                 else if (diff < -window.innerWidth / 10) { | ||||||
|  |                     offcanvas.hide() | ||||||
|  |                     lastX = null | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |         document.addEventListener('touchend', () => { | ||||||
|  |             lastX = null | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     function setupPWAPrompt() { | ||||||
|  |         let deferredPrompt = null | ||||||
|  |  | ||||||
|  |         window.addEventListener("beforeinstallprompt", (e) => { | ||||||
|  |             e.preventDefault() | ||||||
|  |             deferredPrompt = e | ||||||
|  |             let btn = document.getElementById('install-app-home-screen') | ||||||
|  |             let alert = document.getElementById('alert-download-chat-app') | ||||||
|  |             btn.classList.remove('d-none') | ||||||
|  |             alert.classList.remove('d-none') | ||||||
|  |             btn.onclick = function () { | ||||||
|  |                 deferredPrompt.prompt() | ||||||
|  |                 deferredPrompt.userChoice.then((choiceResult) => { | ||||||
|  |                     if (choiceResult.outcome === 'accepted') { | ||||||
|  |                         deferredPrompt = null | ||||||
|  |                         btn.classList.add('d-none') | ||||||
|  |                         alert.classList.add('d-none') | ||||||
|  |                     } | ||||||
|  |                 }) | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     setupSocket() | ||||||
|  |     setupSwipeOffscreen() | ||||||
|  |     setupPWAPrompt() | ||||||
|  | }) | ||||||
| @@ -4,19 +4,19 @@ | |||||||
|     "display": "standalone", |     "display": "standalone", | ||||||
|     "icons": [ |     "icons": [ | ||||||
|         { |         { | ||||||
|             "src": "/static/tfjm/img/tfjm-square.svg", |             "src": "tfjm-square.svg", | ||||||
|             "sizes": "any", |             "sizes": "any", | ||||||
|             "type": "image/svg+xml", |             "type": "image/svg+xml", | ||||||
|             "purpose": "maskable" |             "purpose": "maskable" | ||||||
|         }, |         }, | ||||||
|         { |         { | ||||||
|             "src": "/static/tfjm/img/tfjm-512.png", |             "src": "tfjm-512.png", | ||||||
|             "sizes": "512x512", |             "sizes": "512x512", | ||||||
|             "type": "image/png", |             "type": "image/png", | ||||||
|             "purpose": "maskable" |             "purpose": "maskable" | ||||||
|         }, |         }, | ||||||
|         { |         { | ||||||
|             "src": "/static/tfjm/img/tfjm-192.png", |             "src": "tfjm-192.png", | ||||||
|             "sizes": "192x192", |             "sizes": "192x192", | ||||||
|             "type": "image/png", |             "type": "image/png", | ||||||
|             "purpose": "maskable" |             "purpose": "maskable" | ||||||
| @@ -1,912 +0,0 @@ | |||||||
| (async () => { |  | ||||||
|     // Vérification de la permission pour envoyer des notifications |  | ||||||
|     // C'est utile pour prévenir les utilisateur⋅rices de l'arrivée de nouveaux messages les mentionnant |  | ||||||
|     await Notification.requestPermission() |  | ||||||
| })() |  | ||||||
|  |  | ||||||
| const MAX_MESSAGES = 50  // Nombre maximal de messages à charger à la fois |  | ||||||
|  |  | ||||||
| const channel_categories = ['general', 'tournament', 'team', 'private']  // Liste des catégories de canaux |  | ||||||
| let channels = {}  // Liste des canaux disponibles |  | ||||||
| let messages = {}  // Liste des messages reçus par canal |  | ||||||
| let selected_channel_id = null  // Canal courant |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Affiche une nouvelle notification avec le titre donné et le contenu donné. |  | ||||||
|  * @param title Le titre de la notification |  | ||||||
|  * @param body Le contenu de la notification |  | ||||||
|  * @param timeout La durée (en millisecondes) après laquelle la notification se ferme automatiquement. |  | ||||||
|  *                Définir à 0 (défaut) pour la rendre infinie. |  | ||||||
|  * @return Notification |  | ||||||
|  */ |  | ||||||
| function showNotification(title, body, timeout = 0) { |  | ||||||
|     Notification.requestPermission().then((status) => { |  | ||||||
|         if (status === 'granted') { |  | ||||||
|             // On envoie la notification que si la permission a été donnée |  | ||||||
|             let notif = new Notification(title, {'body': body, 'icon': "/static/tfjm-192.png"}) |  | ||||||
|             if (timeout > 0) |  | ||||||
|                 setTimeout(() => notif.close(), timeout) |  | ||||||
|             return notif |  | ||||||
|         } |  | ||||||
|     }) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Sélectionne le canal courant à afficher sur l'interface de chat. |  | ||||||
|  * Va alors définir le canal courant et mettre à jour les messages affichés. |  | ||||||
|  * @param channel_id L'identifiant du canal à afficher. |  | ||||||
|  */ |  | ||||||
| function selectChannel(channel_id) { |  | ||||||
|     let channel = channels[channel_id] |  | ||||||
|     if (!channel) { |  | ||||||
|         // Le canal n'existe pas |  | ||||||
|         console.error('Channel not found:', channel_id) |  | ||||||
|         return |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     selected_channel_id = channel_id |  | ||||||
|     // On stocke dans le stockage local l'identifiant du canal |  | ||||||
|     // pour pouvoir rouvrir le dernier canal ouvert dans le futur |  | ||||||
|     localStorage.setItem('chat.last-channel-id', channel_id) |  | ||||||
|  |  | ||||||
|     // Définition du titre du contenu |  | ||||||
|     let channelTitle = document.getElementById('channel-title') |  | ||||||
|     channelTitle.innerText = channel.name |  | ||||||
|  |  | ||||||
|     // Si on a pas le droit d'écrire dans le canal, on désactive l'input de message |  | ||||||
|     // On l'active sinon |  | ||||||
|     let messageInput = document.getElementById('input-message') |  | ||||||
|     messageInput.disabled = !channel.write_access |  | ||||||
|  |  | ||||||
|     // On redessine la liste des messages à partir des messages stockés |  | ||||||
|     redrawMessages() |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * On récupère le message écrit par l'utilisateur⋅rice dans le champ de texte idoine, |  | ||||||
|  * et on le transmet ensuite au serveur. |  | ||||||
|  * Il ne s'affiche pas instantanément sur l'interface, |  | ||||||
|  * mais seulement une fois que le serveur aura validé et retransmis le message. |  | ||||||
|  */ |  | ||||||
| function sendMessage() { |  | ||||||
|     // Récupération du message à envoyer |  | ||||||
|     let messageInput = document.getElementById('input-message') |  | ||||||
|     let message = messageInput.value |  | ||||||
|     // On efface le champ de texte après avoir récupéré le message |  | ||||||
|     messageInput.value = '' |  | ||||||
|  |  | ||||||
|     if (!message) { |  | ||||||
|         return |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Envoi du message au serveur |  | ||||||
|     socket.send(JSON.stringify({ |  | ||||||
|         'type': 'send_message', |  | ||||||
|         'channel_id': selected_channel_id, |  | ||||||
|         'content': message, |  | ||||||
|     })) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Met à jour la liste des canaux disponibles, à partir de la liste récupérée du serveur. |  | ||||||
|  * @param new_channels La liste des canaux à afficher. |  | ||||||
|  *                     Chaque canal doit être un objet avec les clés `id`, `name`, `category` |  | ||||||
|  *                     `read_access`, `write_access` et `unread_messages`, correspondant à l'identifiant du canal, |  | ||||||
|  *                     son nom, sa catégorie, la permission de lecture, d'écriture et le nombre de messages non lus. |  | ||||||
|  */ |  | ||||||
| function setChannels(new_channels) { |  | ||||||
|     channels = {} |  | ||||||
|     for (let category of channel_categories) { |  | ||||||
|         // On commence par vider la liste des canaux sélectionnables |  | ||||||
|         let categoryList = document.getElementById(`nav-${category}-channels-tab`) |  | ||||||
|         categoryList.innerHTML = '' |  | ||||||
|         categoryList.parentElement.classList.add('d-none') |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     for (let channel of new_channels) |  | ||||||
|         // On ajoute chaque canal à la liste des canaux |  | ||||||
|         addChannel(channel) |  | ||||||
|  |  | ||||||
|     if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) { |  | ||||||
|         // Si aucun canal n'a encore été sélectionné et qu'il y a des canaux disponibles, |  | ||||||
|         // on commence par vérifier si on a stocké un canal précédemment sélectionné et on l'affiche si c'est le cas |  | ||||||
|         // Sinon, on affiche le premier canal disponible |  | ||||||
|         let last_channel_id = parseInt(localStorage.getItem('chat.last-channel-id')) |  | ||||||
|         if (last_channel_id && channels[last_channel_id]) |  | ||||||
|             selectChannel(last_channel_id) |  | ||||||
|         else |  | ||||||
|             selectChannel(Object.keys(channels)[0]) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Ajoute un canal à la liste des canaux disponibles. |  | ||||||
|  * @param channel Le canal à ajouter. Doit être un objet avec les clés `id`, `name`, `category`, |  | ||||||
|  *               `read_access`, `write_access` et `unread_messages`, correspondant à l'identifiant du canal, |  | ||||||
|  *               son nom, sa catégorie, la permission de lecture, d'écriture et le nombre de messages non lus. |  | ||||||
|  */ |  | ||||||
| async function addChannel(channel) { |  | ||||||
|     channels[channel.id] = channel |  | ||||||
|     if (!messages[channel.id]) |  | ||||||
|         messages[channel.id] = new Map() |  | ||||||
|  |  | ||||||
|     // On récupère la liste des canaux de la catégorie concernée |  | ||||||
|     let categoryList = document.getElementById(`nav-${channel.category}-channels-tab`) |  | ||||||
|     // On la rend visible si elle ne l'était pas déjà |  | ||||||
|     categoryList.parentElement.classList.remove('d-none') |  | ||||||
|  |  | ||||||
|     // On crée un nouvel élément de liste pour la catégorie concernant le canal |  | ||||||
|     let navItem = document.createElement('li') |  | ||||||
|     navItem.classList.add('list-group-item', 'tab-channel') |  | ||||||
|     navItem.id = `tab-channel-${channel.id}` |  | ||||||
|     navItem.setAttribute('data-bs-dismiss', 'offcanvas') |  | ||||||
|     navItem.onclick = () => selectChannel(channel.id) |  | ||||||
|     categoryList.appendChild(navItem) |  | ||||||
|  |  | ||||||
|     // L'élément est cliquable afin de sélectionner le canal |  | ||||||
|     let channelButton = document.createElement('button') |  | ||||||
|     channelButton.classList.add('nav-link') |  | ||||||
|     channelButton.type = 'button' |  | ||||||
|     channelButton.innerText = channel.name |  | ||||||
|     navItem.appendChild(channelButton) |  | ||||||
|  |  | ||||||
|     // Affichage du nombre de messages non lus |  | ||||||
|     let unreadBadge = document.createElement('span') |  | ||||||
|     unreadBadge.classList.add('badge', 'rounded-pill', 'text-bg-light', 'ms-2') |  | ||||||
|     unreadBadge.id = `unread-messages-${channel.id}` |  | ||||||
|     unreadBadge.innerText = channel.unread_messages || 0 |  | ||||||
|     if (!channel.unread_messages) |  | ||||||
|         unreadBadge.classList.add('d-none') |  | ||||||
|     channelButton.appendChild(unreadBadge) |  | ||||||
|  |  | ||||||
|     // Si on veut trier les canaux par nombre décroissant de messages non lus, |  | ||||||
|     // on définit l'ordre de l'élément (propriété CSS) en fonction du nombre de messages non lus |  | ||||||
|     if (document.getElementById('sort-by-unread-switch').checked) |  | ||||||
|         navItem.style.order = `${-channel.unread_messages}` |  | ||||||
|  |  | ||||||
|     // On demande enfin à récupérer les derniers messages du canal en question afin de les stocker / afficher |  | ||||||
|     fetchMessages(channel.id) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Un⋅e utilisateur⋅rice a envoyé un message, qui a été retransmis par le serveur. |  | ||||||
|  * On le stocke alors et on l'affiche sur l'interface si nécessaire. |  | ||||||
|  * On affiche également une notification si le message contient une mention pour tout le monde. |  | ||||||
|  * @param message Le message qui a été transmis. Doit être un objet avec |  | ||||||
|  *                les clés `id`, `channel_id`, `author`, `author_id`, `content` et `timestamp`, |  | ||||||
|  *                correspondant à l'identifiant du message, du canal, le nom de l'auteur⋅rice et l'heure d'envoi. |  | ||||||
|  */ |  | ||||||
| function receiveMessage(message) { |  | ||||||
|     // On vérifie si la barre de défilement est tout en bas |  | ||||||
|     let scrollableContent = document.getElementById('chat-messages') |  | ||||||
|     let isScrolledToBottom = scrollableContent.scrollHeight - scrollableContent.clientHeight <= scrollableContent.scrollTop + 1 |  | ||||||
|  |  | ||||||
|     // On stocke le message dans la liste des messages du canal concerné |  | ||||||
|     // et on redessine les messages affichés si on est dans le canal concerné |  | ||||||
|     messages[message.channel_id].set(message.id, message) |  | ||||||
|     if (message.channel_id === selected_channel_id) |  | ||||||
|         redrawMessages() |  | ||||||
|  |  | ||||||
|     // Si la barre de défilement était tout en bas, alors on la remet tout en bas après avoir redessiné les messages |  | ||||||
|     if (isScrolledToBottom) |  | ||||||
|         scrollableContent.scrollTop = scrollableContent.scrollHeight - scrollableContent.clientHeight |  | ||||||
|  |  | ||||||
|     // On ajoute un à la liste des messages non lus du canal (il pourra être lu plus tard) |  | ||||||
|     updateUnreadBadge(message.channel_id, channels[message.channel_id].unread_messages + 1) |  | ||||||
|  |  | ||||||
|     // Si le message contient une mention à @everyone, alors on envoie une notification (si la permission est donnée) |  | ||||||
|     if (message.content.includes("@everyone")) |  | ||||||
|         showNotification(channels[message.channel_id].name, `${message.author} : ${message.content}`) |  | ||||||
|  |  | ||||||
|     // On envoie un événement personnalisé pour indiquer que les messages ont été mis à jour |  | ||||||
|     // Permettant entre autres de marquer le message comme lu si c'est le cas |  | ||||||
|     document.getElementById('message-list').dispatchEvent(new CustomEvent('updatemessages')) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Un message a été modifié, et le serveur nous a transmis les nouvelles informations. |  | ||||||
|  * @param data Le nouveau message qui a été modifié. |  | ||||||
|  */ |  | ||||||
| function editMessage(data) { |  | ||||||
|     // On met à jour le contenu du message |  | ||||||
|     messages[data.channel_id].get(data.id).content = data.content |  | ||||||
|     // Si le message appartient au canal courant, on redessine les messages |  | ||||||
|     if (data.channel_id === selected_channel_id) |  | ||||||
|         redrawMessages() |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Un message a été supprimé, et le serveur nous a transmis les informations. |  | ||||||
|  * @param data Le message qui a été supprimé. |  | ||||||
|  */ |  | ||||||
| function deleteMessage(data) { |  | ||||||
|     // On supprime le message de la liste des messages du canal concerné |  | ||||||
|     messages[data.channel_id].delete(data.id) |  | ||||||
|     // Si le message appartient au canal courant, on redessine les messages |  | ||||||
|     if (data.channel_id === selected_channel_id) |  | ||||||
|         redrawMessages() |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Demande au serveur de récupérer les messages du canal donné. |  | ||||||
|  * @param channel_id L'identifiant du canal dont on veut récupérer les messages. |  | ||||||
|  * @param offset Le décalage à partir duquel on veut récupérer les messages, |  | ||||||
|  *               correspond au nombre de messages en mémoire. |  | ||||||
|  * @param limit Le nombre maximal de messages à récupérer. |  | ||||||
|  */ |  | ||||||
| function fetchMessages(channel_id, offset = 0, limit = MAX_MESSAGES) { |  | ||||||
|     // Envoi de la requête au serveur avec les différents paramètres |  | ||||||
|     socket.send(JSON.stringify({ |  | ||||||
|         'type': 'fetch_messages', |  | ||||||
|         'channel_id': channel_id, |  | ||||||
|         'offset': offset, |  | ||||||
|         'limit': limit, |  | ||||||
|     })) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Demande au serveur de récupérer les messages précédents du canal courant. |  | ||||||
|  * Par défaut, on récupère `MAX_MESSAGES` messages avant tous ceux qui ont été reçus sur ce canal. |  | ||||||
|  */ |  | ||||||
| function fetchPreviousMessages() { |  | ||||||
|     let channel_id = selected_channel_id |  | ||||||
|     let offset = messages[channel_id].size |  | ||||||
|     fetchMessages(channel_id, offset, MAX_MESSAGES) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * L'utilisateur⋅rice a demandé à récupérer une partie des messages d'un canal. |  | ||||||
|  * Cette fonction est alors appelée lors du retour du serveur. |  | ||||||
|  * @param data Dictionnaire contenant l'identifiant du canal concerné, et la liste des messages récupérés. |  | ||||||
|  */ |  | ||||||
| function receiveFetchedMessages(data) { |  | ||||||
|     // Récupération du canal concerné ainsi que des nouveaux messages à mémoriser |  | ||||||
|     let channel_id = data.channel_id |  | ||||||
|     let new_messages = data.messages |  | ||||||
|  |  | ||||||
|     if (!messages[channel_id]) |  | ||||||
|         messages[channel_id] = new Map() |  | ||||||
|  |  | ||||||
|     // Ajout des nouveaux messages à la liste des messages du canal |  | ||||||
|     for (let message of new_messages) |  | ||||||
|         messages[channel_id].set(message.id, message) |  | ||||||
|  |  | ||||||
|     // On trie les messages reçus par date et heure d'envoi |  | ||||||
|     messages[channel_id] = new Map([...messages[channel_id].values()] |  | ||||||
|       .sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)) |  | ||||||
|       .map(message => [message.id, message])) |  | ||||||
|  |  | ||||||
|     // Enfin, si le canal concerné est le canal courant, on redessine les messages |  | ||||||
|     if (channel_id === selected_channel_id) |  | ||||||
|         redrawMessages() |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * L'utilisateur⋅rice a indiqué au serveur que des messages ont été lus. |  | ||||||
|  * Cette fonction est appelée en retour, pour confirmer, et stocke quels messages ont été lus |  | ||||||
|  * et combien de messages sont non lus par canal. |  | ||||||
|  * @param data Dictionnaire contenant une clé `read`, contenant la liste des identifiants des messages |  | ||||||
|  *             marqués comme lus avec leur canal respectif, et une clé `unread_messages` contenant le nombre |  | ||||||
|  *             de messages non lus par canal. |  | ||||||
|  */ |  | ||||||
| function markMessageAsRead(data) { |  | ||||||
|     for (let message of data.messages) { |  | ||||||
|         // Récupération du message à marquer comme lu |  | ||||||
|         let stored_message = messages[message.channel_id].get(message.id) |  | ||||||
|         // Marquage du message comme lu |  | ||||||
|         if (stored_message) |  | ||||||
|             stored_message.read = true |  | ||||||
|     } |  | ||||||
|     // Actualisation des badges contenant le nombre de messages non lus par canal |  | ||||||
|     updateUnreadBadges(data.unread_messages) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Mise à jour des badges contenant le nombre de messages non lus par canal. |  | ||||||
|  * @param unreadMessages Dictionnaire des nombres de messages non lus par canal (identifiés par leurs identifiants) |  | ||||||
|  */ |  | ||||||
| function updateUnreadBadges(unreadMessages) { |  | ||||||
|     for (let channel of Object.values(channels)) { |  | ||||||
|         // Récupération du nombre de messages non lus pour le canal en question et mise à jour du badge pour ce canal |  | ||||||
|         updateUnreadBadge(channel.id, unreadMessages[channel.id] || 0) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Mise à jour du badge du nombre de messages non lus d'un canal. |  | ||||||
|  * Actualise sa visibilité. |  | ||||||
|  * @param channel_id Identifiant du canal concerné. |  | ||||||
|  * @param unreadMessagesCount Nombre de messages non lus du canal. |  | ||||||
|  */ |  | ||||||
| function updateUnreadBadge(channel_id, unreadMessagesCount = 0) { |  | ||||||
|     // Vaut true si on veut trier les canaux par nombre de messages non lus ou non |  | ||||||
|     const sortByUnread = document.getElementById('sort-by-unread-switch').checked |  | ||||||
|  |  | ||||||
|     // Récupération du canal concerné |  | ||||||
|     let channel = channels[channel_id] |  | ||||||
|  |  | ||||||
|     // Récupération du nombre de messages non lus pour le canal en question, que l'on stocke |  | ||||||
|     channel.unread_messages = unreadMessagesCount |  | ||||||
|  |  | ||||||
|     // On met à jour le badge du canal contenant le nombre de messages non lus |  | ||||||
|     let unreadBadge = document.getElementById(`unread-messages-${channel.id}`) |  | ||||||
|     unreadBadge.innerText = unreadMessagesCount.toString() |  | ||||||
|  |  | ||||||
|     // Le badge est visible si et seulement si il y a au moins un message non lu |  | ||||||
|     if (unreadMessagesCount) |  | ||||||
|         unreadBadge.classList.remove('d-none') |  | ||||||
|     else |  | ||||||
|         unreadBadge.classList.add('d-none') |  | ||||||
|  |  | ||||||
|     // S'il faut trier les canaux par nombre de messages non lus, on ajoute la propriété CSS correspondante |  | ||||||
|     if (sortByUnread) |  | ||||||
|         document.getElementById(`tab-channel-${channel.id}`).style.order = `${-unreadMessagesCount}` |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * La création d'un canal privé entre deux personnes a été demandée. |  | ||||||
|  * Cette fonction est appelée en réponse du serveur. |  | ||||||
|  * Le canal est ajouté à la liste s'il est nouveau, et automatiquement sélectionné. |  | ||||||
|  * @param data Dictionnaire contenant une unique clé `channel` correspondant aux informations du canal privé. |  | ||||||
|  */ |  | ||||||
| function startPrivateChat(data) { |  | ||||||
|     // Récupération du canal |  | ||||||
|     let channel = data.channel |  | ||||||
|     if (!channel) { |  | ||||||
|         console.error('Private chat not found:', data) |  | ||||||
|         return |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (!channels[channel.id]) { |  | ||||||
|         // Si le canal n'est pas récupéré, on l'ajoute à la liste |  | ||||||
|         channels[channel.id] = channel |  | ||||||
|         messages[channel.id] = new Map() |  | ||||||
|         addChannel(channel) |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Sélection immédiate du canal privé |  | ||||||
|     selectChannel(channel.id) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Met à jour le composant correspondant à la liste des messages du canal sélectionné. |  | ||||||
|  * Le conteneur est d'abord réinitialisé, puis les messages sont affichés un à un à partir de ceux stockés. |  | ||||||
|  */ |  | ||||||
| function redrawMessages() { |  | ||||||
|     // Récupération du composant HTML <ul> correspondant à la liste des messages affichés |  | ||||||
|     let messageList = document.getElementById('message-list') |  | ||||||
|     // On commence par le vider |  | ||||||
|     messageList.innerHTML = '' |  | ||||||
|  |  | ||||||
|     let lastMessage = null |  | ||||||
|     let lastContentDiv = null |  | ||||||
|  |  | ||||||
|     for (let message of messages[selected_channel_id].values()) { |  | ||||||
|         if (lastMessage && lastMessage.author === message.author) { |  | ||||||
|             // Si le message est écrit par læ même auteur⋅rice que le message précédent, |  | ||||||
|             // alors on les groupe ensemble |  | ||||||
|             let lastTimestamp = new Date(lastMessage.timestamp) |  | ||||||
|             let newTimestamp = new Date(message.timestamp) |  | ||||||
|             if ((newTimestamp - lastTimestamp) / 1000 < 60 * 10) { |  | ||||||
|                 // Les messages sont groupés uniquement s'il y a une différence maximale de 10 minutes |  | ||||||
|                 // entre le premier message du groupe et celui en étude |  | ||||||
|                 // On ajoute alors le contenu du message en cours dans le dernier div de message |  | ||||||
|                 let messageContentDiv = document.createElement('div') |  | ||||||
|                 messageContentDiv.classList.add('message') |  | ||||||
|                 messageContentDiv.setAttribute('data-message-id', message.id) |  | ||||||
|                 lastContentDiv.appendChild(messageContentDiv) |  | ||||||
|                 let messageContentSpan = document.createElement('span') |  | ||||||
|                 messageContentSpan.innerHTML = markdownToHTML(message.content) |  | ||||||
|                 messageContentDiv.appendChild(messageContentSpan) |  | ||||||
|  |  | ||||||
|                 // Enregistrement du menu contextuel pour le message permettant la modification, la suppression |  | ||||||
|                 // et l'envoi de messages privés |  | ||||||
|                 registerMessageContextMenu(message, messageContentDiv, messageContentSpan) |  | ||||||
|                 continue |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Création de l'élément <li> pour le bloc de messages |  | ||||||
|         let messageElement = document.createElement('li') |  | ||||||
|         messageElement.classList.add('list-group-item') |  | ||||||
|         messageList.appendChild(messageElement) |  | ||||||
|  |  | ||||||
|         // Ajout d'un div contenant le nom de l'auteur⋅rice du message ainsi que la date et heure d'envoi |  | ||||||
|         let authorDiv = document.createElement('div') |  | ||||||
|         messageElement.appendChild(authorDiv) |  | ||||||
|  |  | ||||||
|         // Ajout du nom de l'auteur⋅rice du message |  | ||||||
|         let authorSpan = document.createElement('span') |  | ||||||
|         authorSpan.classList.add('text-muted', 'fw-bold') |  | ||||||
|         authorSpan.innerText = message.author |  | ||||||
|         authorDiv.appendChild(authorSpan) |  | ||||||
|  |  | ||||||
|         // Ajout de la date du message |  | ||||||
|         let dateSpan = document.createElement('span') |  | ||||||
|         dateSpan.classList.add('text-muted', 'float-end') |  | ||||||
|         dateSpan.innerText = new Date(message.timestamp).toLocaleString() |  | ||||||
|         authorDiv.appendChild(dateSpan) |  | ||||||
|  |  | ||||||
|         // Enregistrement du menu contextuel pour le message permettant l'envoi de messages privés à l'auteur⋅rice |  | ||||||
|         registerSendPrivateMessageContextMenu(message, authorDiv, authorSpan) |  | ||||||
|  |  | ||||||
|         let contentDiv = document.createElement('div') |  | ||||||
|         messageElement.appendChild(contentDiv) |  | ||||||
|  |  | ||||||
|         // Ajout du contenu du message |  | ||||||
|         // Le contenu est mis dans un span lui-même inclus dans un div, |  | ||||||
|         let messageContentDiv = document.createElement('div') |  | ||||||
|         messageContentDiv.classList.add('message') |  | ||||||
|         messageContentDiv.setAttribute('data-message-id', message.id) |  | ||||||
|         contentDiv.appendChild(messageContentDiv) |  | ||||||
|         let messageContentSpan = document.createElement('span') |  | ||||||
|         messageContentSpan.innerHTML = markdownToHTML(message.content) |  | ||||||
|         messageContentDiv.appendChild(messageContentSpan) |  | ||||||
|  |  | ||||||
|         // Enregistrement du menu contextuel pour le message permettant la modification, la suppression |  | ||||||
|         // et l'envoi de messages privés |  | ||||||
|         registerMessageContextMenu(message, messageContentDiv, messageContentSpan) |  | ||||||
|  |  | ||||||
|         lastMessage = message |  | ||||||
|         lastContentDiv = contentDiv |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Le bouton « Afficher les messages précédents » est affiché si et seulement si |  | ||||||
|     // il y a des messages à récupérer (c'est-à-dire si le nombre de messages récupérés est un multiple de MAX_MESSAGES) |  | ||||||
|     let fetchMoreButton = document.getElementById('fetch-previous-messages') |  | ||||||
|     if (!messages[selected_channel_id].size || messages[selected_channel_id].size % MAX_MESSAGES !== 0) |  | ||||||
|         fetchMoreButton.classList.add('d-none') |  | ||||||
|     else |  | ||||||
|         fetchMoreButton.classList.remove('d-none') |  | ||||||
|  |  | ||||||
|     // On envoie un événement personnalisé pour indiquer que les messages ont été mis à jour |  | ||||||
|     // Permettant entre autres de marquer les messages visibles comme lus si c'est le cas |  | ||||||
|     messageList.dispatchEvent(new CustomEvent('updatemessages')) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Convertit un texte écrit en Markdown en HTML. |  | ||||||
|  * Les balises Markdown suivantes sont supportées : |  | ||||||
|  * - Souligné : `_texte_` |  | ||||||
|  * - Gras : `**texte**` |  | ||||||
|  * - Italique : `*texte*` |  | ||||||
|  * - Code : `` `texte` `` |  | ||||||
|  * - Les liens sont automatiquement convertis |  | ||||||
|  * - Les esperluettes, guillemets et chevrons sont échappés. |  | ||||||
|  * @param text Le texte écrit en Markdown. |  | ||||||
|  * @return {string} Le texte converti en HTML. |  | ||||||
|  */ |  | ||||||
| function markdownToHTML(text) { |  | ||||||
|     // On échape certains caractères spéciaux (esperluettes, chevrons, guillemets) |  | ||||||
|     let safeText = text.replace(/&/g, "&") |  | ||||||
|          .replace(/</g, "<") |  | ||||||
|          .replace(/>/g, ">") |  | ||||||
|          .replace(/"/g, """) |  | ||||||
|          .replace(/'/g, "'") |  | ||||||
|     let lines = safeText.split('\n') |  | ||||||
|     let htmlLines = [] |  | ||||||
|     for (let line of lines) { |  | ||||||
|         // Pour chaque ligne, on remplace le Markdown par un équivalent HTML (pour ce qui est supporté) |  | ||||||
|         let htmlLine = line |  | ||||||
|             .replaceAll(/_(.*)_/gim, '<span class="text-decoration-underline">$1</span>')  // Souligné |  | ||||||
|             .replaceAll(/\*\*(.*)\*\*/gim, '<span class="fw-bold">$1</span>')  // Gras |  | ||||||
|             .replaceAll(/\*(.*)\*/gim, '<span class="fst-italic">$1</span>')  // Italique |  | ||||||
|             .replaceAll(/`(.*)`/gim, '<pre>$1</pre>')  // Code |  | ||||||
|             .replaceAll(/(https?:\/\/\S+)/g, '<a href="$1" target="_blank">$1</a>')  // Liens |  | ||||||
|         htmlLines.push(htmlLine) |  | ||||||
|     } |  | ||||||
|     // On joint enfin toutes les lignes par des balises de saut de ligne |  | ||||||
|     return htmlLines.join('<br>') |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Ferme toutes les popovers ouvertes. |  | ||||||
|  */ |  | ||||||
| function removeAllPopovers() { |  | ||||||
|     for (let popover of document.querySelectorAll('*[aria-describedby*="popover"]')) { |  | ||||||
|         let instance = bootstrap.Popover.getInstance(popover) |  | ||||||
|         if (instance) |  | ||||||
|             instance.dispose() |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Enregistrement du menu contextuel pour un⋅e auteur⋅rice de message, |  | ||||||
|  * donnant la possibilité d'envoyer un message privé. |  | ||||||
|  * @param message Le message écrit par l'auteur⋅rice du bloc en question. |  | ||||||
|  * @param div Le bloc contenant le nom de l'auteur⋅rice et de la date d'envoi du message. |  | ||||||
|  *            Un clic droit sur lui affichera le menu contextuel. |  | ||||||
|  * @param span Le span contenant le nom de l'auteur⋅rice. |  | ||||||
|  *             Il désignera l'emplacement d'affichage du popover. |  | ||||||
|  */ |  | ||||||
| function registerSendPrivateMessageContextMenu(message, div, span) { |  | ||||||
|     // Enregistrement de l'écouteur d'événement pour le clic droit |  | ||||||
|     div.addEventListener('contextmenu', (menu_event) => { |  | ||||||
|         // On empêche le menu traditionnel de s'afficher |  | ||||||
|         menu_event.preventDefault() |  | ||||||
|         // On retire toutes les popovers déjà ouvertes |  | ||||||
|         removeAllPopovers() |  | ||||||
|  |  | ||||||
|         // On crée le popover contenant le lien pour envoyer un message privé, puis on l'affiche |  | ||||||
|         const popover = bootstrap.Popover.getOrCreateInstance(span, { |  | ||||||
|             'title': message.author, |  | ||||||
|             'content': `<a id="send-private-message-link-${message.id}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>`, |  | ||||||
|             'html': true, |  | ||||||
|         }) |  | ||||||
|         popover.show() |  | ||||||
|  |  | ||||||
|         // Lorsqu'on clique sur le lien, on ferme le popover |  | ||||||
|         // et on demande à ouvrir le canal privé avec l'auteur⋅rice du message |  | ||||||
|         document.getElementById('send-private-message-link-' + message.id).addEventListener('click', event => { |  | ||||||
|             event.preventDefault() |  | ||||||
|             popover.dispose() |  | ||||||
|             socket.send(JSON.stringify({ |  | ||||||
|                 'type': 'start_private_chat', |  | ||||||
|                 'user_id': message.author_id, |  | ||||||
|             })) |  | ||||||
|         }) |  | ||||||
|     }) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Enregistrement du menu contextuel pour un message, |  | ||||||
|  * donnant la possibilité de modifier ou de supprimer le message, ou d'envoyer un message privé à l'auteur⋅rice. |  | ||||||
|  * @param message Le message en question. |  | ||||||
|  * @param div Le bloc contenant le contenu du message. |  | ||||||
|  *            Un clic droit sur lui affichera le menu contextuel. |  | ||||||
|  * @param span Le span contenant le contenu du message. |  | ||||||
|  *             Il désignera l'emplacement d'affichage du popover. |  | ||||||
|  */ |  | ||||||
| function registerMessageContextMenu(message, div, span) { |  | ||||||
|     // Enregistrement de l'écouteur d'événement pour le clic droit |  | ||||||
|     div.addEventListener('contextmenu', (menu_event) => { |  | ||||||
|         // On empêche le menu traditionnel de s'afficher |  | ||||||
|         menu_event.preventDefault() |  | ||||||
|         // On retire toutes les popovers déjà ouvertes |  | ||||||
|         removeAllPopovers() |  | ||||||
|  |  | ||||||
|         // On crée le popover contenant les liens pour modifier, supprimer le message ou envoyer un message privé. |  | ||||||
|         let content = `<a id="send-private-message-link-msg-${message.id}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>` |  | ||||||
|  |  | ||||||
|         // On ne peut modifier ou supprimer un message que si on est l'auteur⋅rice ou que l'on est administrateur⋅rice. |  | ||||||
|         let has_right_to_edit = message.author_id === USER_ID || IS_ADMIN |  | ||||||
|         if (has_right_to_edit) { |  | ||||||
|             content += `<hr class="my-1">` |  | ||||||
|             content += `<a id="edit-message-${message.id}" class="nav-link" href="#" tabindex="0">Modifier</a>` |  | ||||||
|             content += `<a id="delete-message-${message.id}" class="nav-link" href="#" tabindex="0">Supprimer</a>` |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         const popover = bootstrap.Popover.getOrCreateInstance(span, { |  | ||||||
|             'content': content, |  | ||||||
|             'html': true, |  | ||||||
|             'placement': 'bottom', |  | ||||||
|         }) |  | ||||||
|         popover.show() |  | ||||||
|  |  | ||||||
|         // Lorsqu'on clique sur le lien d'envoi de message privé, on ferme le popover |  | ||||||
|         // et on demande à ouvrir le canal privé avec l'auteur⋅rice du message |  | ||||||
|         document.getElementById('send-private-message-link-msg-' + message.id).addEventListener('click', event => { |  | ||||||
|             event.preventDefault() |  | ||||||
|             popover.dispose() |  | ||||||
|             socket.send(JSON.stringify({ |  | ||||||
|                 'type': 'start_private_chat', |  | ||||||
|                 'user_id': message.author_id, |  | ||||||
|             })) |  | ||||||
|         }) |  | ||||||
|  |  | ||||||
|         if (has_right_to_edit) { |  | ||||||
|             // Si on a le droit de modifier ou supprimer le message, on enregistre les écouteurs d'événements |  | ||||||
|             // Le bouton de modification de message ouvre une boîte de dialogue pour modifier le message |  | ||||||
|             document.getElementById('edit-message-' + message.id).addEventListener('click', event => { |  | ||||||
|                 event.preventDefault() |  | ||||||
|                 // Fermeture du popover |  | ||||||
|                 popover.dispose() |  | ||||||
|  |  | ||||||
|                 // Ouverture d'une boîte de diaologue afin de modifier le message |  | ||||||
|                 let new_message = prompt("Modifier le message", message.content) |  | ||||||
|                 if (new_message) { |  | ||||||
|                     // Si le message a été modifié, on envoie la demande de modification au serveur |  | ||||||
|                     socket.send(JSON.stringify({ |  | ||||||
|                         'type': 'edit_message', |  | ||||||
|                         'message_id': message.id, |  | ||||||
|                         'content': new_message, |  | ||||||
|                     })) |  | ||||||
|                 } |  | ||||||
|             }) |  | ||||||
|  |  | ||||||
|             // Le bouton de suppression de message demande une confirmation avant de supprimer le message |  | ||||||
|             document.getElementById('delete-message-' + message.id).addEventListener('click', event => { |  | ||||||
|                 event.preventDefault() |  | ||||||
|                 // Fermeture du popover |  | ||||||
|                 popover.dispose() |  | ||||||
|  |  | ||||||
|                 // Demande de confirmation avant de supprimer le message |  | ||||||
|                 if (confirm(`Supprimer le message ?\n${message.content}`)) { |  | ||||||
|                     socket.send(JSON.stringify({ |  | ||||||
|                         'type': 'delete_message', |  | ||||||
|                         'message_id': message.id, |  | ||||||
|                     })) |  | ||||||
|                 } |  | ||||||
|             }) |  | ||||||
|         } |  | ||||||
|     }) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Passe le chat en version plein écran, ou l'inverse si c'est déjà le cas. |  | ||||||
|  */ |  | ||||||
| function toggleFullscreen() { |  | ||||||
|     let chatContainer = document.getElementById('chat-container') |  | ||||||
|     if (!chatContainer.getAttribute('data-fullscreen')) { |  | ||||||
|         // Le chat n'est pas en plein écran. |  | ||||||
|         // On le passe en plein écran en le plaçant en avant plan en position absolue |  | ||||||
|         // prenant toute la hauteur et toute la largeur |  | ||||||
|         chatContainer.setAttribute('data-fullscreen', 'true') |  | ||||||
|         chatContainer.classList.add('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3') |  | ||||||
|         window.history.replaceState({}, null, `?fullscreen=1`) |  | ||||||
|     } |  | ||||||
|     else { |  | ||||||
|         // Le chat est déjà en plein écran. On retire les tags CSS correspondant au plein écran. |  | ||||||
|         chatContainer.removeAttribute('data-fullscreen') |  | ||||||
|         chatContainer.classList.remove('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3') |  | ||||||
|         window.history.replaceState({}, null, `?fullscreen=0`) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| document.addEventListener('DOMContentLoaded', () => { |  | ||||||
|     // Lorsqu'on effectue le moindre clic, on ferme les éventuelles popovers ouvertes |  | ||||||
|     document.addEventListener('click', removeAllPopovers) |  | ||||||
|  |  | ||||||
|     // Lorsqu'on change entre le tri des canaux par ordre alphabétique et par nombre de messages non lus, |  | ||||||
|     // on met à jour l'ordre des canaux |  | ||||||
|     document.getElementById('sort-by-unread-switch').addEventListener('change', event => { |  | ||||||
|         const sortByUnread = event.target.checked |  | ||||||
|         for (let channel of Object.values(channels)) { |  | ||||||
|             let item = document.getElementById(`tab-channel-${channel.id}`) |  | ||||||
|             if (sortByUnread) |  | ||||||
|                 // Si on trie par nombre de messages non lus, |  | ||||||
|                 // on définit l'ordre de l'élément en fonction du nombre de messages non lus |  | ||||||
|                 // à l'aide d'une propriété CSS |  | ||||||
|                 item.style.order = `${-channel.unread_messages}` |  | ||||||
|             else |  | ||||||
|                 // Sinon, les canaux sont de base triés par ordre alphabétique |  | ||||||
|                 item.style.removeProperty('order') |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // On stocke le mode de tri dans le stockage local |  | ||||||
|         localStorage.setItem('chat.sort-by-unread', sortByUnread) |  | ||||||
|     }) |  | ||||||
|  |  | ||||||
|     // On récupère le mode de tri des canaux depuis le stockage local |  | ||||||
|     if (localStorage.getItem('chat.sort-by-unread') === 'true') { |  | ||||||
|         document.getElementById('sort-by-unread-switch').checked = true |  | ||||||
|         document.getElementById('sort-by-unread-switch').dispatchEvent(new Event('change')) |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * Des données sont reçues depuis le serveur. Elles sont traitées dans cette fonction, |  | ||||||
|      * qui a pour but de trier et de répartir dans d'autres sous-fonctions. |  | ||||||
|      * @param data Le message reçu. |  | ||||||
|      */ |  | ||||||
|     function processMessage(data) { |  | ||||||
|         // On traite le message en fonction de son type |  | ||||||
|         switch (data.type) { |  | ||||||
|             case 'fetch_channels': |  | ||||||
|                 setChannels(data.channels) |  | ||||||
|                 break |  | ||||||
|             case 'send_message': |  | ||||||
|                 receiveMessage(data) |  | ||||||
|                 break |  | ||||||
|             case 'edit_message': |  | ||||||
|                 editMessage(data) |  | ||||||
|                 break |  | ||||||
|             case 'delete_message': |  | ||||||
|                 deleteMessage(data) |  | ||||||
|                 break |  | ||||||
|             case 'fetch_messages': |  | ||||||
|                 receiveFetchedMessages(data) |  | ||||||
|                 break |  | ||||||
|             case 'mark_read': |  | ||||||
|                 markMessageAsRead(data) |  | ||||||
|                 break |  | ||||||
|             case 'start_private_chat': |  | ||||||
|                 startPrivateChat(data) |  | ||||||
|                 break |  | ||||||
|             default: |  | ||||||
|                 // Le type de message est inconnu. On affiche une erreur dans la console. |  | ||||||
|                 console.log(data) |  | ||||||
|                 console.error('Unknown message type:', data.type) |  | ||||||
|                 break |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * Configuration du socket de chat, permettant de communiquer avec le serveur. |  | ||||||
|      * @param nextDelay Correspond au délai de reconnexion en cas d'erreur. |  | ||||||
|      *                  Augmente exponentiellement en cas d'erreurs répétées, |  | ||||||
|      *                  et se réinitialise à 1s en cas de connexion réussie. |  | ||||||
|      */ |  | ||||||
|     function setupSocket(nextDelay = 1000) { |  | ||||||
|         // Ouverture du socket |  | ||||||
|         socket = new WebSocket( |  | ||||||
|             (document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/ws/chat/' |  | ||||||
|         ) |  | ||||||
|         let socketOpen = false |  | ||||||
|  |  | ||||||
|         // Écoute des messages reçus depuis le serveur |  | ||||||
|         socket.addEventListener('message', e => { |  | ||||||
|             // Analyse du message reçu en tant que JSON |  | ||||||
|             const data = JSON.parse(e.data) |  | ||||||
|  |  | ||||||
|             // Traite le message reçu |  | ||||||
|             processMessage(data) |  | ||||||
|         }) |  | ||||||
|  |  | ||||||
|         // En cas d'erreur, on affiche un message et on réessaie de se connecter après un certain délai |  | ||||||
|         // Ce délai double après chaque erreur répétée, jusqu'à un maximum de 2 minutes |  | ||||||
|         socket.addEventListener('close', e => { |  | ||||||
|             console.error('Chat socket closed unexpectedly, restarting…') |  | ||||||
|             setTimeout(() => setupSocket(Math.max(socketOpen ? 1000 : 2 * nextDelay, 120000)), nextDelay) |  | ||||||
|         }) |  | ||||||
|  |  | ||||||
|         // En cas de connexion réussie, on demande au serveur les derniers messages pour chaque canal |  | ||||||
|         socket.addEventListener('open', e => { |  | ||||||
|             socketOpen = true |  | ||||||
|             socket.send(JSON.stringify({ |  | ||||||
|                 'type': 'fetch_channels', |  | ||||||
|             })) |  | ||||||
|         }) |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * Configuration du swipe pour ouvrir et fermer le sélecteur de canaux. |  | ||||||
|      * Fonctionne a priori uniquement sur les écrans tactiles. |  | ||||||
|      * Lorsqu'on swipe de la gauche vers la droite, depuis le côté gauche de l'écran, on ouvre le sélecteur de canaux. |  | ||||||
|      * Quand on swipe de la droite vers la gauche, on ferme le sélecteur de canaux. |  | ||||||
|      */ |  | ||||||
|     function setupSwipeOffscreen() { |  | ||||||
|         // Récupération du sélecteur de canaux |  | ||||||
|         const offcanvas = new bootstrap.Offcanvas(document.getElementById('channelSelector')) |  | ||||||
|  |  | ||||||
|         // L'écran a été touché. On récupère la coordonnée X de l'emplacement touché. |  | ||||||
|         let lastX = null |  | ||||||
|         document.addEventListener('touchstart', (event) => { |  | ||||||
|             if (event.touches.length === 1) |  | ||||||
|                 lastX = event.touches[0].clientX |  | ||||||
|         }) |  | ||||||
|  |  | ||||||
|         // Le doigt a été déplacé. Selon le nouvel emplacement du doigt, on ouvre ou on ferme le sélecteur de canaux. |  | ||||||
|         document.addEventListener('touchmove', (event) => { |  | ||||||
|             if (event.touches.length === 1 && lastX !== null) { |  | ||||||
|                 // L'écran a été touché à un seul doigt, et on a déjà récupéré la coordonnée X touchée. |  | ||||||
|                 const diff = event.touches[0].clientX - lastX |  | ||||||
|                 if (diff > window.innerWidth / 10 && lastX < window.innerWidth / 4) { |  | ||||||
|                     // Si le déplacement correspond à au moins 10 % de la largeur de l'écran vers la droite |  | ||||||
|                     // et que le point de départ se trouve dans le quart gauche de l'écran, alors on ouvre le sélecteur |  | ||||||
|                     offcanvas.show() |  | ||||||
|                     lastX = null |  | ||||||
|                 } |  | ||||||
|                 else if (diff < -window.innerWidth / 10) { |  | ||||||
|                     // Si le déplacement correspond à au moins 10 % de la largeur de l'écran vers la gauche, |  | ||||||
|                     // alors on ferme le sélecteur |  | ||||||
|                     offcanvas.hide() |  | ||||||
|                     lastX = null |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }) |  | ||||||
|  |  | ||||||
|         // Le doigt a été relâché. On réinitialise la coordonnée X touchée. |  | ||||||
|         document.addEventListener('touchend', () => { |  | ||||||
|             lastX = null |  | ||||||
|         }) |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * Configuration du suivi de lecture des messages. |  | ||||||
|      * Lorsque l'utilisateur⋅rice scrolle dans la fenêtre de chat, on vérifie quels sont les messages qui sont |  | ||||||
|      * visibles à l'écran, et on les marque comme lus. |  | ||||||
|      */ |  | ||||||
|     function setupReadTracker() { |  | ||||||
|         // Récupération du conteneur de messages |  | ||||||
|         const scrollableContent = document.getElementById('chat-messages') |  | ||||||
|         const messagesList = document.getElementById('message-list') |  | ||||||
|         let markReadBuffer = [] |  | ||||||
|         let markReadTimeout = null |  | ||||||
|  |  | ||||||
|         // Lorsqu'on scrolle, on récupère les anciens messages si on est tout en haut, |  | ||||||
|         // et on marque les messages visibles comme lus |  | ||||||
|         scrollableContent.addEventListener('scroll', () => { |  | ||||||
|             if (scrollableContent.clientHeight - scrollableContent.scrollTop === scrollableContent.scrollHeight |  | ||||||
|                 && !document.getElementById('fetch-previous-messages').classList.contains('d-none')) { |  | ||||||
|                 // Si l'utilisateur⋅rice est en haut du chat, on récupère la liste des anciens messages |  | ||||||
|                 fetchPreviousMessages()} |  | ||||||
|  |  | ||||||
|             // On marque les messages visibles comme lus |  | ||||||
|             markVisibleMessagesAsRead() |  | ||||||
|         }) |  | ||||||
|  |  | ||||||
|         // Lorsque les messages stockés sont mis à jour, on vérifie quels sont les messages visibles à marquer comme lus |  | ||||||
|         messagesList.addEventListener('updatemessages', () => markVisibleMessagesAsRead()) |  | ||||||
|  |  | ||||||
|         /** |  | ||||||
|          * Marque les messages visibles à l'écran comme lus. |  | ||||||
|          * On récupère pour cela les coordonnées du conteneur de messages ainsi que les coordonnées de chaque message |  | ||||||
|          * et on vérifie si le message est visible à l'écran. Si c'est le cas, on le marque comme lu. |  | ||||||
|          * Après 3 secondes d'attente après qu'aucun message n'ait été lu, |  | ||||||
|          * on envoie la liste des messages lus au serveur. |  | ||||||
|          */ |  | ||||||
|         function markVisibleMessagesAsRead() { |  | ||||||
|             // Récupération des coordonnées visibles du conteneur de messages |  | ||||||
|             let viewport = scrollableContent.getBoundingClientRect() |  | ||||||
|  |  | ||||||
|             for (let item of messagesList.querySelectorAll('.message')) { |  | ||||||
|                 let message = messages[selected_channel_id].get(parseInt(item.getAttribute('data-message-id'))) |  | ||||||
|                 if (!message.read) { |  | ||||||
|                     // Si le message n'a pas déjà été lu, on récupère ses coordonnées |  | ||||||
|                     let rect = item.getBoundingClientRect() |  | ||||||
|                     if (rect.top >= viewport.top && rect.bottom <= viewport.bottom) { |  | ||||||
|                         // Si les coordonnées sont entièrement incluses dans le rectangle visible, on le marque comme lu |  | ||||||
|                         // et comme étant à envoyer au serveur |  | ||||||
|                         message.read = true |  | ||||||
|                         markReadBuffer.push(message.id) |  | ||||||
|                         if (markReadTimeout) |  | ||||||
|                             clearTimeout(markReadTimeout) |  | ||||||
|                         // 3 secondes après qu'aucun nouveau message n'ait été rajouté, on envoie la liste des messages |  | ||||||
|                         // lus au serveur |  | ||||||
|                         markReadTimeout = setTimeout(() => { |  | ||||||
|                             socket.send(JSON.stringify({ |  | ||||||
|                                 'type': 'mark_read', |  | ||||||
|                                 'message_ids': markReadBuffer, |  | ||||||
|                             })) |  | ||||||
|                             markReadBuffer = [] |  | ||||||
|                             markReadTimeout = null |  | ||||||
|                         }, 3000) |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // On considère les messages d'ores-et-déjà visibles comme lus |  | ||||||
|         markVisibleMessagesAsRead() |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * Configuration de la demande d'installation de l'application en tant qu'application web progressive (PWA). |  | ||||||
|      * Lorsque l'utilisateur⋅rice arrive sur la page, on lui propose de télécharger l'application |  | ||||||
|      * pour l'ajouter à son écran d'accueil. |  | ||||||
|      * Fonctionne uniquement sur les navigateurs compatibles. |  | ||||||
|      */ |  | ||||||
|     function setupPWAPrompt() { |  | ||||||
|         let deferredPrompt = null |  | ||||||
|  |  | ||||||
|         window.addEventListener("beforeinstallprompt", (e) => { |  | ||||||
|             // Une demande d'installation a été faite. On commence par empêcher l'action par défaut. |  | ||||||
|             e.preventDefault() |  | ||||||
|             deferredPrompt = e |  | ||||||
|  |  | ||||||
|             // L'installation est possible, on rend visible le bouton de téléchargement |  | ||||||
|             // ainsi que le message qui indique c'est possible. |  | ||||||
|             let btn = document.getElementById('install-app-home-screen') |  | ||||||
|             let alert = document.getElementById('alert-download-chat-app') |  | ||||||
|             btn.classList.remove('d-none') |  | ||||||
|             alert.classList.remove('d-none') |  | ||||||
|             btn.onclick = function () { |  | ||||||
|                 // Lorsque le bouton de téléchargement est cliqué, on lance l'installation du PWA. |  | ||||||
|                 deferredPrompt.prompt() |  | ||||||
|                 deferredPrompt.userChoice.then((choiceResult) => { |  | ||||||
|                     if (choiceResult.outcome === 'accepted') { |  | ||||||
|                         // Si l'installation a été acceptée, on masque le bouton de téléchargement. |  | ||||||
|                         deferredPrompt = null |  | ||||||
|                         btn.classList.add('d-none') |  | ||||||
|                         alert.classList.add('d-none') |  | ||||||
|                     } |  | ||||||
|                 }) |  | ||||||
|             } |  | ||||||
|         }) |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     setupSocket()  // Configuration du Websocket |  | ||||||
|     setupSwipeOffscreen()  // Configuration du swipe sur les écrans tactiles pour le sélecteur de canaux |  | ||||||
|     setupReadTracker()  // Configuration du suivi de lecture des messages |  | ||||||
|     setupPWAPrompt()  // Configuration de l'installateur d'application en tant qu'application web progressive |  | ||||||
| }) |  | ||||||
| @@ -2,11 +2,9 @@ | |||||||
|  |  | ||||||
| {% load static %} | {% load static %} | ||||||
| {% load i18n %} | {% load i18n %} | ||||||
| {% load pipeline %} |  | ||||||
|  |  | ||||||
| {% block extracss %} | {% block extracss %} | ||||||
|     {# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #} |     <link rel="manifest" href="{% static "chat.webmanifest" %}"> | ||||||
|     <link rel="manifest" href="{% static "tfjm/chat.webmanifest" %}"> |  | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block content-title %}{% endblock %} | {% block content-title %}{% endblock %} | ||||||
| @@ -16,6 +14,6 @@ | |||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block extrajavascript %} | {% block extrajavascript %} | ||||||
|     {# Ce script contient toutes les données pour la gestion du chat. #} |     {# This script contains all data for the chat management #} | ||||||
|     {% javascript 'chat' %} |     <script src="{% static 'chat.js' %}"></script> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|   | |||||||
| @@ -1,40 +1,28 @@ | |||||||
| {% load i18n %} | {% load i18n %} | ||||||
|  |  | ||||||
| <noscript> | <noscript> | ||||||
|     {# Le chat fonctionne à l'aide d'un script JavaScript, sans JavaScript activé il n'est pas possible d'utiliser le chat. #} |  | ||||||
|     {% trans "JavaScript must be enabled on your browser to access chat." %} |     {% trans "JavaScript must be enabled on your browser to access chat." %} | ||||||
| </noscript> | </noscript> | ||||||
| <div class="offcanvas offcanvas-start" tabindex="-1" id="channelSelector" aria-labelledby="offcanvasTitle"> | <div class="offcanvas offcanvas-start" tabindex="-1" id="channelSelector" aria-labelledby="offcanvasTitle"> | ||||||
|     <div class="offcanvas-header"> |     <div class="offcanvas-header"> | ||||||
|         {# Titre du sélecteur de canaux #} |  | ||||||
|         <h3 class="offcanvas-title" id="offcanvasTitle">{% trans "Chat channels" %}</h3> |         <h3 class="offcanvas-title" id="offcanvasTitle">{% trans "Chat channels" %}</h3> | ||||||
|         <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button> |         <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button> | ||||||
|     </div> |     </div> | ||||||
|     <div class="offcanvas-body"> |     <div class="offcanvas-body"> | ||||||
|         {# Contenu du sélecteur de canaux #} |  | ||||||
|         <div class="form-switch form-switch"> |  | ||||||
|             <input class="form-check-input" type="checkbox" role="switch" id="sort-by-unread-switch"> |  | ||||||
|             <label class="form-check-label" for="sort-by-unread-switch">{% trans "Sort by unread messages" %}</label> |  | ||||||
|         </div> |  | ||||||
|         <ul class="list-group list-group-flush" id="nav-channels-tab"> |         <ul class="list-group list-group-flush" id="nav-channels-tab"> | ||||||
|             {# Liste des différentes catégories, avec les canaux par catégorie #} |  | ||||||
|             <li class="list-group-item d-none"> |             <li class="list-group-item d-none"> | ||||||
|                 {# Canaux généraux #} |  | ||||||
|                 <h4>{% trans "General channels" %}</h4> |                 <h4>{% trans "General channels" %}</h4> | ||||||
|                 <ul class="list-group list-group-flush" id="nav-general-channels-tab"></ul> |                 <ul class="list-group list-group-flush" id="nav-general-channels-tab"></ul> | ||||||
|             </li> |             </li> | ||||||
|             <li class="list-group-item d-none"> |             <li class="list-group-item d-none"> | ||||||
|                 {# Canaux liés à un tournoi #} |  | ||||||
|                 <h4>{% trans "Tournament channels" %}</h4> |                 <h4>{% trans "Tournament channels" %}</h4> | ||||||
|                 <ul class="list-group list-group-flush" id="nav-tournament-channels-tab"></ul> |                 <ul class="list-group list-group-flush" id="nav-tournament-channels-tab"></ul> | ||||||
|             </li> |             </li> | ||||||
|             <li class="list-group-item d-none"> |             <li class="list-group-item d-none"> | ||||||
|                 {# Canaux d'équipes #} |  | ||||||
|                 <h4>{% trans "Team channels" %}</h4> |                 <h4>{% trans "Team channels" %}</h4> | ||||||
|                 <ul class="list-group list-group-flush" id="nav-team-channels-tab"></ul> |                 <ul class="list-group list-group-flush" id="nav-team-channels-tab"></ul> | ||||||
|             </li> |             </li> | ||||||
|             <li class="list-group-item d-none"> |             <li class="list-group-item d-none"> | ||||||
|                 {# Échanges privés #} |  | ||||||
|                 <h4>{% trans "Private channels" %}</h4> |                 <h4>{% trans "Private channels" %}</h4> | ||||||
|                 <ul class="list-group list-group-flush" id="nav-private-channels-tab"></ul> |                 <ul class="list-group list-group-flush" id="nav-private-channels-tab"></ul> | ||||||
|             </li> |             </li> | ||||||
| @@ -43,41 +31,32 @@ | |||||||
| </div> | </div> | ||||||
|  |  | ||||||
| <div class="alert alert-info d-none" id="alert-download-chat-app"> | <div class="alert alert-info d-none" id="alert-download-chat-app"> | ||||||
|     {# Lorsque l'application du chat est installable (par exemple sur un Chrome sur Android), on affiche le message qui indique que c'est bien possible. #} |  | ||||||
|     {% trans "You can install a shortcut to the chat on your home screen using the download button on the header." %} |     {% trans "You can install a shortcut to the chat on your home screen using the download button on the header." %} | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| {# Conteneur principal du chat. #} |  | ||||||
| {# Lorsque le chat est en plein écran, on le place en coordonnées absolues, occupant tout l'espace de l'écran. #} |  | ||||||
| <div class="card tab-content w-100 mh-100{% if request.GET.fullscreen == '1' or fullscreen %} position-absolute top-0 start-0 vh-100 z-3{% endif %}" | <div class="card tab-content w-100 mh-100{% if request.GET.fullscreen == '1' or fullscreen %} position-absolute top-0 start-0 vh-100 z-3{% endif %}" | ||||||
|      style="height: 95vh" id="chat-container"> |      style="height: 95vh" id="chat-container"> | ||||||
|     <div class="card-header"> |     <div class="card-header"> | ||||||
|         <h3> |         <h3> | ||||||
|         {% if fullscreen %} |         {% if fullscreen %} | ||||||
|             {# Lorsque le chat est en plein écran, on affiche le bouton de déconnexion. #} |             {# Logout button must be present in a form. The form must includes the whole line. #} | ||||||
|             {# Le bouton de déconnexion doit être présent dans un formulaire. Le formulaire doit inclure toute la ligne. #} |  | ||||||
|             <form action="{% url 'chat:logout' %}" method="post"> |             <form action="{% url 'chat:logout' %}" method="post"> | ||||||
|                 {% csrf_token %} |                 {% csrf_token %} | ||||||
|         {% endif %} |         {% endif %} | ||||||
|             {# Bouton qui permet d'ouvrir le sélecteur de canaux #} |  | ||||||
|             <button class="navbar-toggler" type="button" data-bs-toggle="offcanvas" data-bs-target="#channelSelector" |             <button class="navbar-toggler" type="button" data-bs-toggle="offcanvas" data-bs-target="#channelSelector" | ||||||
|                     aria-controls="channelSelector" aria-expanded="false" aria-label="Toggle channel selector"> |                     aria-controls="channelSelector" aria-expanded="false" aria-label="Toggle channel selector"> | ||||||
|                 <span class="navbar-toggler-icon"></span> |                 <span class="navbar-toggler-icon"></span> | ||||||
|             </button> |             </button> | ||||||
|  |             <span id="channel-title"></span> | ||||||
|             <span id="channel-title"></span> {# Titre du canal sélectionné #} |  | ||||||
|             {% if not fullscreen %} |             {% if not fullscreen %} | ||||||
|                 {# Dans le cas où on est pas uniquement en plein écran (cas de l'application), on affiche les boutons pour passer en ou quitter le mode plein écran. #} |  | ||||||
|                 <button class="btn float-end" type="button" onclick="toggleFullscreen()" title="{% trans "Toggle fullscreen mode" %}"> |                 <button class="btn float-end" type="button" onclick="toggleFullscreen()" title="{% trans "Toggle fullscreen mode" %}"> | ||||||
|                     <i class="fas fa-expand"></i> |                     <i class="fas fa-expand"></i> | ||||||
|                 </button> |                 </button> | ||||||
|             {% else %} |             {% else %} | ||||||
|                 {# Le bouton de déconnexion n'est affiché que sur l'application. #} |  | ||||||
|                 <button class="btn float-end" title="{% trans "Log out" %}"> |                 <button class="btn float-end" title="{% trans "Log out" %}"> | ||||||
|                     <i class="fas fa-sign-out-alt"></i> |                     <i class="fas fa-sign-out-alt"></i> | ||||||
|                 </button> |                 </button> | ||||||
|             {% endif %} |             {% endif %} | ||||||
|             {# On affiche le bouton d'installation uniquement dans le cas où l'application est installable sur l'écran d'accueil. #} |  | ||||||
|             <button class="btn float-end d-none" type="button" id="install-app-home-screen" title="{% trans "Install app on home screen" %}"> |             <button class="btn float-end d-none" type="button" id="install-app-home-screen" title="{% trans "Install app on home screen" %}"> | ||||||
|                 <i class="fas fa-download"></i> |                 <i class="fas fa-download"></i> | ||||||
|             </button> |             </button> | ||||||
| @@ -86,12 +65,8 @@ | |||||||
|             {% endif %} |             {% endif %} | ||||||
|         </h3> |         </h3> | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
|     {# Contenu de la carte, contenant la liste des messages. La liste des messages est affichée à l'envers pour avoir un scroll plus cohérent. #} |  | ||||||
|     <div class="card-body d-flex flex-column-reverse flex-grow-0 overflow-y-scroll" id="chat-messages"> |     <div class="card-body d-flex flex-column-reverse flex-grow-0 overflow-y-scroll" id="chat-messages"> | ||||||
|         {# Correspond à la liste des messages à afficher. #} |  | ||||||
|         <ul class="list-group list-group-flush" id="message-list"></ul> |         <ul class="list-group list-group-flush" id="message-list"></ul> | ||||||
|         {# S'il y a des messages à récupérer, on affiche un lien qui permet de récupérer les anciens messages. #} |  | ||||||
|         <div class="text-center d-none" id="fetch-previous-messages"> |         <div class="text-center d-none" id="fetch-previous-messages"> | ||||||
|             <a href="#" class="nav-link" onclick="event.preventDefault(); fetchPreviousMessages()"> |             <a href="#" class="nav-link" onclick="event.preventDefault(); fetchPreviousMessages()"> | ||||||
|                 {% trans "Fetch previous messages…" %} |                 {% trans "Fetch previous messages…" %} | ||||||
| @@ -99,16 +74,12 @@ | |||||||
|             <hr> |             <hr> | ||||||
|         </div> |         </div> | ||||||
|     </div> |     </div> | ||||||
|      |  | ||||||
|     {# Pied de la carte, contenant le formulaire pour envoyer un message. #} |  | ||||||
|     <div class="card-footer mt-auto"> |     <div class="card-footer mt-auto"> | ||||||
|         {# Lorsqu'on souhaite envoyer un message, on empêche le formulaire de s'envoyer et on envoie le message par websocket. #} |  | ||||||
|         <form onsubmit="event.preventDefault(); sendMessage()"> |         <form onsubmit="event.preventDefault(); sendMessage()"> | ||||||
|             <div class="input-group"> |             <div class="input-group"> | ||||||
|                 <label for="input-message" class="input-group-text"> |                 <label for="input-message" class="input-group-text"> | ||||||
|                     <i class="fas fa-comment"></i> |                     <i class="fas fa-comment"></i> | ||||||
|                 </label> |                 </label> | ||||||
|                 {# Affichage du contrôleur de texte pour rédiger le message à envoyer. #} |  | ||||||
|                 <input type="text" class="form-control" id="input-message" placeholder="{% trans "Send message…" %}" autofocus autocomplete="off"> |                 <input type="text" class="form-control" id="input-message" placeholder="{% trans "Send message…" %}" autofocus autocomplete="off"> | ||||||
|                 <button class="input-group-text btn btn-success" type="submit"> |                 <button class="input-group-text btn btn-success" type="submit"> | ||||||
|                     <i class="fas fa-paper-plane"></i> |                     <i class="fas fa-paper-plane"></i> | ||||||
| @@ -119,8 +90,6 @@ | |||||||
| </div> | </div> | ||||||
|  |  | ||||||
| <script> | <script> | ||||||
|     {# Récupération de l'utilisateur⋅rice courant⋅e afin de pouvoir effectuer des tests plus tard. #} |  | ||||||
|     const USER_ID = {{ request.user.id }} |     const USER_ID = {{ request.user.id }} | ||||||
|     {# Récupération du statut administrateur⋅rice de l'utilisateur⋅rice connecté⋅e afin de pouvoir effectuer des tests plus tard. #} |  | ||||||
|     const IS_ADMIN = {{ request.user.registration.is_admin|yesno:"true,false" }} |     const IS_ADMIN = {{ request.user.registration.is_admin|yesno:"true,false" }} | ||||||
| </script> | </script> | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| {% load i18n pipeline static %} | {% load i18n static %} | ||||||
|  |  | ||||||
| <!DOCTYPE html> | <!DOCTYPE html> | ||||||
| {% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %} | {% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %} | ||||||
| @@ -7,29 +7,28 @@ | |||||||
|     <meta charset="utf-8"> |     <meta charset="utf-8"> | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> |     <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> | ||||||
|     <title> |     <title> | ||||||
|         {% trans "TFJM² Chat" %} |         Chat du TFJM² | ||||||
|     </title> |     </title> | ||||||
|     <meta name="description" content="{% trans "TFJM² Chat" %}"> |     <meta name="description" content="Chat du TFJM²"> | ||||||
|  |  | ||||||
|     {# Favicon #} |     {# Favicon #} | ||||||
|     <link rel="shortcut icon" href="{% static "favicon.ico" %}"> |     <link rel="shortcut icon" href="{% static "favicon.ico" %}"> | ||||||
|     <meta name="theme-color" content="#ffffff"> |     <meta name="theme-color" content="#ffffff"> | ||||||
|  |  | ||||||
|     {# Bootstrap + Font Awesome CSS #} |     {# Bootstrap CSS #} | ||||||
|     {% stylesheet 'bootstrap_fontawesome' %} |     <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}"> | ||||||
|  |     <link rel="stylesheet" href="{% static 'fontawesome/css/all.css' %}"> | ||||||
|  |     <link rel="stylesheet" href="{% static 'fontawesome/css/v4-shims.css' %}"> | ||||||
|  |  | ||||||
|     {# Bootstrap JavaScript #} |     {# Bootstrap JavaScript #} | ||||||
|     {% javascript 'bootstrap' %} |     <script src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script> | ||||||
|  |  | ||||||
|     {# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #} |     <link rel="manifest" href="{% static "chat.webmanifest" %}"> | ||||||
|     <link rel="manifest" href="{% static "tfjm/chat.webmanifest" %}"> |  | ||||||
| </head> | </head> | ||||||
| <body class="d-flex w-100 h-100 flex-column"> | <body class="d-flex w-100 h-100 flex-column"> | ||||||
| {% include "chat/content.html" with fullscreen=True %} | {% include "chat/content.html" with fullscreen=True %} | ||||||
|  |  | ||||||
| {# Inclusion du script permettant de gérer le thème sombre et le thème clair #} | <script src="{% static 'theme.js' %}"></script> | ||||||
| {% javascript 'theme' %} | <script src="{% static 'chat.js' %}"></script> | ||||||
| {# Inclusion du script gérant le chat #} |  | ||||||
| {% javascript 'chat' %} |  | ||||||
| </body> | </body> | ||||||
| </html> | </html> | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| {% load i18n pipeline static %} | {% load i18n static %} | ||||||
|  |  | ||||||
| <!DOCTYPE html> | <!DOCTYPE html> | ||||||
| {% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %} | {% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %} | ||||||
| @@ -7,22 +7,23 @@ | |||||||
|     <meta charset="utf-8"> |     <meta charset="utf-8"> | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> |     <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> | ||||||
|     <title> |     <title> | ||||||
|         {% trans "TFJM² Chat" %} - {% trans "Log in" %} |         Chat du TFJM² - {% trans "Log in" %} | ||||||
|     </title> |     </title> | ||||||
|     <meta name="description" content="{% trans "TFJM² Chat" %}"> |     <meta name="description" content="Chat du TFJM²"> | ||||||
|  |  | ||||||
|     {# Favicon #} |     {# Favicon #} | ||||||
|     <link rel="shortcut icon" href="{% static "favicon.ico" %}"> |     <link rel="shortcut icon" href="{% static "favicon.ico" %}"> | ||||||
|     <meta name="theme-color" content="#ffffff"> |     <meta name="theme-color" content="#ffffff"> | ||||||
|  |  | ||||||
|     {# Bootstrap CSS #} |     {# Bootstrap CSS #} | ||||||
|     {% stylesheet 'bootstrap_fontawesome' %} |     <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}"> | ||||||
|  |     <link rel="stylesheet" href="{% static 'fontawesome/css/all.css' %}"> | ||||||
|  |     <link rel="stylesheet" href="{% static 'fontawesome/css/v4-shims.css' %}"> | ||||||
|  |  | ||||||
|     {# Bootstrap JavaScript #} |     {# Bootstrap JavaScript #} | ||||||
|     {% javascript 'bootstrap' %} |     <script src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script> | ||||||
|  |  | ||||||
|     {# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #} |     <link rel="manifest" href="{% static "chat.webmanifest" %}"> | ||||||
|     <link rel="manifest" href="{% static "tfjm/chat.webmanifest" %}"> |  | ||||||
| </head> | </head> | ||||||
| <body class="d-flex w-100 h-100 flex-column"> | <body class="d-flex w-100 h-100 flex-column"> | ||||||
|     <div class="container"> |     <div class="container"> | ||||||
| @@ -30,7 +31,6 @@ | |||||||
|         {% include "registration/includes/login.html" %} |         {% include "registration/includes/login.html" %} | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
| {# Inclusion du script permettant de gérer le thème sombre et le thème clair #} | <script src="{% static 'theme.js' %}"></script> | ||||||
| {% javascript 'theme' %} |  | ||||||
| </body> | </body> | ||||||
| </html> | </html> | ||||||
|   | |||||||
| @@ -60,7 +60,7 @@ Dans le fichier ``docker-compose.yml``, configurer : | |||||||
|         networks: |         networks: | ||||||
|           - tfjm |           - tfjm | ||||||
|         labels: |         labels: | ||||||
|           - "traefik.http.routers.inscription-tfjm2.rule=Host(`inscription.tfjm.org`, `inscriptions.tfjm.org`, `plateforme.tfjm.org`)" |           - "traefik.http.routers.inscription-tfjm2.rule=Host(`inscription.tfjm.org`, `plateforme.tfjm.org`)" | ||||||
|           - "traefik.http.routers.inscription-tfjm2.entrypoints=websecure" |           - "traefik.http.routers.inscription-tfjm2.entrypoints=websecure" | ||||||
|           - "traefik.http.routers.inscription-tfjm2.tls.certresolver=mytlschallenge" |           - "traefik.http.routers.inscription-tfjm2.tls.certresolver=mytlschallenge" | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,7 +2,6 @@ | |||||||
|  |  | ||||||
| {% load static %} | {% load static %} | ||||||
| {% load i18n %} | {% load i18n %} | ||||||
| {% load pipeline %} |  | ||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
|     {# The navbar to select the tournament #} |     {# The navbar to select the tournament #} | ||||||
| @@ -41,5 +40,5 @@ | |||||||
|     {{ problems|length|json_script:'problems_count' }} |     {{ problems|length|json_script:'problems_count' }} | ||||||
|  |  | ||||||
|     {# This script contains all data for the draw management #} |     {# This script contains all data for the draw management #} | ||||||
|     {% javascript 'draw' %} |     <script src="{% static 'draw.js' %}"></script> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|   | |||||||
| @@ -7,7 +7,7 @@ msgid "" | |||||||
| msgstr "" | msgstr "" | ||||||
| "Project-Id-Version: TFJM\n" | "Project-Id-Version: TFJM\n" | ||||||
| "Report-Msgid-Bugs-To: \n" | "Report-Msgid-Bugs-To: \n" | ||||||
| "POT-Creation-Date: 2024-05-26 22:06+0200\n" | "POT-Creation-Date: 2024-04-28 13:08+0200\n" | ||||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||||
| "Last-Translator: Emmy D'Anello <emmy.danello@animath.fr>\n" | "Last-Translator: Emmy D'Anello <emmy.danello@animath.fr>\n" | ||||||
| "Language-Team: LANGUAGE <LL@li.org>\n" | "Language-Team: LANGUAGE <LL@li.org>\n" | ||||||
| @@ -21,62 +21,40 @@ msgstr "" | |||||||
| msgid "API" | msgid "API" | ||||||
| msgstr "API" | msgstr "API" | ||||||
|  |  | ||||||
| #: chat/models.py:22 chat/templates/chat/content.html:23 | #: chat/models.py:17 | ||||||
| msgid "General channels" | msgid "General channels" | ||||||
| msgstr "Canaux généraux" | msgstr "Canaux généraux" | ||||||
|  |  | ||||||
| #: chat/models.py:23 chat/templates/chat/content.html:28 | #: chat/models.py:18 | ||||||
| msgid "Tournament channels" | msgid "Tournament channels" | ||||||
| msgstr "Canaux de tournois" | msgstr "Canaux de tournois" | ||||||
|  |  | ||||||
| #: chat/models.py:24 chat/templates/chat/content.html:33 | #: chat/models.py:19 | ||||||
| msgid "Team channels" | msgid "Team channels" | ||||||
| msgstr "Canaux d'équipes" | msgstr "Canaux d'équipes" | ||||||
|  |  | ||||||
| #: chat/models.py:25 chat/templates/chat/content.html:38 | #: chat/models.py:20 | ||||||
| msgid "Private channels" | msgid "Private channels" | ||||||
| msgstr "Messages privés" | msgstr "Messages privés" | ||||||
|  |  | ||||||
| #: chat/models.py:29 participation/models.py:35 participation/models.py:263 | #: chat/models.py:24 participation/models.py:35 participation/models.py:263 | ||||||
| #: participation/tables.py:18 participation/tables.py:34 | #: participation/tables.py:18 participation/tables.py:34 | ||||||
| msgid "name" | msgid "name" | ||||||
| msgstr "nom" | msgstr "nom" | ||||||
|  |  | ||||||
| #: chat/models.py:30 | #: chat/models.py:29 | ||||||
| msgid "Visible name of the channel." |  | ||||||
| msgstr "Nom visible du canal." |  | ||||||
|  |  | ||||||
| #: chat/models.py:35 |  | ||||||
| msgid "category" | msgid "category" | ||||||
| msgstr "catégorie" | msgstr "catégorie" | ||||||
|  |  | ||||||
| #: chat/models.py:38 | #: chat/models.py:36 | ||||||
| msgid "" |  | ||||||
| "Category of the channel, between general channels, tournament-specific " |  | ||||||
| "channels, team channels or private channels. Will be used to sort channels " |  | ||||||
| "in the channel list." |  | ||||||
| msgstr "" |  | ||||||
| "Catégorie du canal, entre canaux généraux, canaux spécifiques à un tournoi, " |  | ||||||
| "canaux d'équipe ou messages privés. Sera utilisé pour trier les canaux dans " |  | ||||||
| "la liste des canaux." |  | ||||||
|  |  | ||||||
| #: chat/models.py:44 |  | ||||||
| msgid "read permission" | msgid "read permission" | ||||||
| msgstr "permission de lecture" | msgstr "permission de lecture" | ||||||
|  |  | ||||||
| #: chat/models.py:46 | #: chat/models.py:42 | ||||||
| msgid "Permission type that is required to read the messages of the channels." |  | ||||||
| msgstr "Type de permission nécessaire pour lire les messages des canaux." |  | ||||||
|  |  | ||||||
| #: chat/models.py:51 |  | ||||||
| msgid "write permission" | msgid "write permission" | ||||||
| msgstr "permission d'écriture" | msgstr "permission d'écriture" | ||||||
|  |  | ||||||
| #: chat/models.py:53 | #: chat/models.py:52 draw/admin.py:53 draw/admin.py:71 draw/admin.py:88 | ||||||
| msgid "Permission type that is required to write a message to a channel." |  | ||||||
| msgstr "Type de permission nécessaire pour écrire un message dans un canal." |  | ||||||
|  |  | ||||||
| #: chat/models.py:62 draw/admin.py:53 draw/admin.py:71 draw/admin.py:88 |  | ||||||
| #: draw/models.py:26 participation/admin.py:79 participation/admin.py:140 | #: draw/models.py:26 participation/admin.py:79 participation/admin.py:140 | ||||||
| #: participation/admin.py:171 participation/models.py:693 | #: participation/admin.py:171 participation/models.py:693 | ||||||
| #: participation/models.py:717 participation/models.py:935 | #: participation/models.py:717 participation/models.py:935 | ||||||
| @@ -85,7 +63,7 @@ msgstr "Type de permission nécessaire pour écrire un message dans un canal." | |||||||
| msgid "tournament" | msgid "tournament" | ||||||
| msgstr "tournoi" | msgstr "tournoi" | ||||||
|  |  | ||||||
| #: chat/models.py:64 | #: chat/models.py:54 | ||||||
| msgid "" | msgid "" | ||||||
| "For a permission that concerns a tournament, indicates what is the concerned " | "For a permission that concerns a tournament, indicates what is the concerned " | ||||||
| "tournament." | "tournament." | ||||||
| @@ -93,21 +71,21 @@ msgstr "" | |||||||
| "Pour une permission qui concerne un tournoi, indique quel est le tournoi " | "Pour une permission qui concerne un tournoi, indique quel est le tournoi " | ||||||
| "concerné." | "concerné." | ||||||
|  |  | ||||||
| #: chat/models.py:73 draw/models.py:429 draw/models.py:456 | #: chat/models.py:63 draw/models.py:429 draw/models.py:456 | ||||||
| #: participation/admin.py:136 participation/admin.py:155 | #: participation/admin.py:136 participation/admin.py:155 | ||||||
| #: participation/models.py:1434 participation/models.py:1443 | #: participation/models.py:1434 participation/models.py:1443 | ||||||
| #: participation/tables.py:84 | #: participation/tables.py:84 | ||||||
| msgid "pool" | msgid "pool" | ||||||
| msgstr "poule" | msgstr "poule" | ||||||
|  |  | ||||||
| #: chat/models.py:75 | #: chat/models.py:65 | ||||||
| msgid "" | msgid "" | ||||||
| "For a permission that concerns a pool, indicates what is the concerned pool." | "For a permission that concerns a pool, indicates what is the concerned pool." | ||||||
| msgstr "" | msgstr "" | ||||||
| "Pour une permission qui concerne une poule, indique quelle est la poule " | "Pour une permission qui concerne une poule, indique quelle est la poule " | ||||||
| "concernée." | "concernée." | ||||||
|  |  | ||||||
| #: chat/models.py:84 draw/templates/draw/tournament_content.html:277 | #: chat/models.py:74 draw/templates/draw/tournament_content.html:277 | ||||||
| #: participation/admin.py:167 participation/models.py:252 | #: participation/admin.py:167 participation/models.py:252 | ||||||
| #: participation/models.py:708 | #: participation/models.py:708 | ||||||
| #: participation/templates/participation/tournament_harmonize.html:15 | #: participation/templates/participation/tournament_harmonize.html:15 | ||||||
| @@ -117,18 +95,18 @@ msgstr "" | |||||||
| msgid "team" | msgid "team" | ||||||
| msgstr "équipe" | msgstr "équipe" | ||||||
|  |  | ||||||
| #: chat/models.py:86 | #: chat/models.py:76 | ||||||
| msgid "" | msgid "" | ||||||
| "For a permission that concerns a team, indicates what is the concerned team." | "For a permission that concerns a team, indicates what is the concerned team." | ||||||
| msgstr "" | msgstr "" | ||||||
| "Pour une permission qui concerne une équipe, indique quelle est l'équipe " | "Pour une permission qui concerne une équipe, indique quelle est l'équipe " | ||||||
| "concernée." | "concernée." | ||||||
|  |  | ||||||
| #: chat/models.py:90 | #: chat/models.py:80 | ||||||
| msgid "private" | msgid "private" | ||||||
| msgstr "privé" | msgstr "privé" | ||||||
|  |  | ||||||
| #: chat/models.py:92 | #: chat/models.py:82 | ||||||
| msgid "" | msgid "" | ||||||
| "If checked, only users who have been explicitly added to the channel will be " | "If checked, only users who have been explicitly added to the channel will be " | ||||||
| "able to access it." | "able to access it." | ||||||
| @@ -136,11 +114,11 @@ msgstr "" | |||||||
| "Si sélectionné, seul⋅es les utilisateur⋅rices qui ont été explicitement " | "Si sélectionné, seul⋅es les utilisateur⋅rices qui ont été explicitement " | ||||||
| "ajouté⋅es au canal pourront y accéder." | "ajouté⋅es au canal pourront y accéder." | ||||||
|  |  | ||||||
| #: chat/models.py:97 | #: chat/models.py:87 | ||||||
| msgid "invited users" | msgid "invited users" | ||||||
| msgstr "Utilisateur⋅rices invité" | msgstr "Utilisateur⋅rices invité" | ||||||
|  |  | ||||||
| #: chat/models.py:100 | #: chat/models.py:90 | ||||||
| msgid "" | msgid "" | ||||||
| "Extra users who have been invited to the channel, in addition to the " | "Extra users who have been invited to the channel, in addition to the " | ||||||
| "permitted group of the channel." | "permitted group of the channel." | ||||||
| @@ -148,64 +126,52 @@ msgstr "" | |||||||
| "Utilisateur⋅rices supplémentaires qui ont été invité⋅es au canal, en plus du " | "Utilisateur⋅rices supplémentaires qui ont été invité⋅es au canal, en plus du " | ||||||
| "groupe autorisé du canal." | "groupe autorisé du canal." | ||||||
|  |  | ||||||
| #: chat/models.py:122 | #: chat/models.py:95 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Channel {name}" | msgid "Channel {name}" | ||||||
| msgstr "Canal {name}" | msgstr "Canal {name}" | ||||||
|  |  | ||||||
| #: chat/models.py:231 chat/models.py:246 | #: chat/models.py:161 chat/models.py:170 | ||||||
| msgid "channel" | msgid "channel" | ||||||
| msgstr "canal" | msgstr "canal" | ||||||
|  |  | ||||||
| #: chat/models.py:232 | #: chat/models.py:162 | ||||||
| msgid "channels" | msgid "channels" | ||||||
| msgstr "canaux" | msgstr "canaux" | ||||||
|  |  | ||||||
| #: chat/models.py:252 | #: chat/models.py:176 | ||||||
| msgid "author" | msgid "author" | ||||||
| msgstr "auteur⋅rice" | msgstr "auteur⋅rice" | ||||||
|  |  | ||||||
| #: chat/models.py:259 | #: chat/models.py:183 | ||||||
| msgid "created at" | msgid "created at" | ||||||
| msgstr "créé le" | msgstr "créé le" | ||||||
|  |  | ||||||
| #: chat/models.py:264 | #: chat/models.py:188 | ||||||
| msgid "updated at" | msgid "updated at" | ||||||
| msgstr "modifié le" | msgstr "modifié le" | ||||||
|  |  | ||||||
| #: chat/models.py:269 | #: chat/models.py:193 | ||||||
| msgid "content" | msgid "content" | ||||||
| msgstr "contenu" | msgstr "contenu" | ||||||
|  |  | ||||||
| #: chat/models.py:274 | #: chat/models.py:256 | ||||||
| msgid "users read" |  | ||||||
| msgstr "utilisateur⋅rices ayant lu" |  | ||||||
|  |  | ||||||
| #: chat/models.py:277 |  | ||||||
| msgid "Users who have read the message." |  | ||||||
| msgstr "Utilisateur⋅rices qui ont lu le message." |  | ||||||
|  |  | ||||||
| #: chat/models.py:363 |  | ||||||
| msgid "message" | msgid "message" | ||||||
| msgstr "message" | msgstr "message" | ||||||
|  |  | ||||||
| #: chat/models.py:364 | #: chat/models.py:257 | ||||||
| msgid "messages" | msgid "messages" | ||||||
| msgstr "messages" | msgstr "messages" | ||||||
|  |  | ||||||
| #: chat/templates/chat/content.html:5 | #: chat/templates/chat/content.html:4 | ||||||
| msgid "JavaScript must be enabled on your browser to access chat." | msgid "JavaScript must be enabled on your browser to access chat." | ||||||
| msgstr "JavaScript doit être activé sur votre navigateur pour accéder au chat." | msgstr "JavaScript doit être activé sur votre navigateur pour accéder au chat." | ||||||
|  |  | ||||||
| #: chat/templates/chat/content.html:10 | #: chat/templates/chat/content.html:8 | ||||||
| msgid "Chat channels" | msgid "Chat channels" | ||||||
| msgstr "Canaux de chat" | msgstr "Canaux de chat" | ||||||
|  |  | ||||||
| #: chat/templates/chat/content.html:17 | #: chat/templates/chat/content.html:17 | ||||||
| msgid "Sort by unread messages" |  | ||||||
| msgstr "Trier par messages non lus" |  | ||||||
|  |  | ||||||
| #: chat/templates/chat/content.html:47 |  | ||||||
| msgid "" | msgid "" | ||||||
| "You can install a shortcut to the chat on your home screen using the " | "You can install a shortcut to the chat on your home screen using the " | ||||||
| "download button on the header." | "download button on the header." | ||||||
| @@ -213,33 +179,27 @@ msgstr "" | |||||||
| "Vous pouvez installer un raccourci vers le chat sur votre écran d'accueil en " | "Vous pouvez installer un raccourci vers le chat sur votre écran d'accueil en " | ||||||
| "utilisant le bouton de téléchargement dans l'en-tête." | "utilisant le bouton de téléchargement dans l'en-tête." | ||||||
|  |  | ||||||
| #: chat/templates/chat/content.html:71 | #: chat/templates/chat/content.html:35 | ||||||
| msgid "Toggle fullscreen mode" | msgid "Toggle fullscreen mode" | ||||||
| msgstr "Inverse le mode plein écran" | msgstr "Inverse le mode plein écran" | ||||||
|  |  | ||||||
| #: chat/templates/chat/content.html:76 tfjm/templates/navbar.html:117 | #: chat/templates/chat/content.html:39 tfjm/templates/navbar.html:117 | ||||||
| msgid "Log out" | msgid "Log out" | ||||||
| msgstr "Déconnexion" | msgstr "Déconnexion" | ||||||
|  |  | ||||||
| #: chat/templates/chat/content.html:81 | #: chat/templates/chat/content.html:43 | ||||||
| msgid "Install app on home screen" | msgid "Install app on home screen" | ||||||
| msgstr "Installer l'application sur l'écran d'accueil" | msgstr "Installer l'application sur l'écran d'accueil" | ||||||
|  |  | ||||||
| #: chat/templates/chat/content.html:97 | #: chat/templates/chat/content.html:55 | ||||||
| msgid "Fetch previous messages…" | msgid "Fetch previous messages…" | ||||||
| msgstr "Récupérer les messages précédents…" | msgstr "Récupérer les messages précédents…" | ||||||
|  |  | ||||||
| #: chat/templates/chat/content.html:112 | #: chat/templates/chat/content.html:66 | ||||||
| msgid "Send message…" | msgid "Send message…" | ||||||
| msgstr "Envoyer un message…" | msgstr "Envoyer un message…" | ||||||
|  |  | ||||||
| #: chat/templates/chat/fullscreen.html:10 | #: chat/templates/chat/login.html:10 chat/templates/chat/login.html:30 | ||||||
| #: chat/templates/chat/fullscreen.html:12 chat/templates/chat/login.html:10 |  | ||||||
| #: chat/templates/chat/login.html:12 |  | ||||||
| msgid "TFJM² Chat" |  | ||||||
| msgstr "Chat du TFJM²" |  | ||||||
|  |  | ||||||
| #: chat/templates/chat/login.html:10 chat/templates/chat/login.html:31 |  | ||||||
| #: registration/templates/registration/password_reset_complete.html:10 | #: registration/templates/registration/password_reset_complete.html:10 | ||||||
| #: tfjm/templates/base.html:84 tfjm/templates/base.html:85 | #: tfjm/templates/base.html:84 tfjm/templates/base.html:85 | ||||||
| #: tfjm/templates/navbar.html:98 | #: tfjm/templates/navbar.html:98 | ||||||
| @@ -603,8 +563,8 @@ msgstr "Êtes-vous sûr·e de vouloir annuler le tirage au sort ?" | |||||||
| msgid "Close" | msgid "Close" | ||||||
| msgstr "Fermer" | msgstr "Fermer" | ||||||
|  |  | ||||||
| #: draw/views.py:31 participation/views.py:162 participation/views.py:504 | #: draw/views.py:31 participation/views.py:162 participation/views.py:501 | ||||||
| #: participation/views.py:535 | #: participation/views.py:532 | ||||||
| msgid "You are not in a team." | msgid "You are not in a team." | ||||||
| msgstr "Vous n'êtes pas dans une équipe." | msgstr "Vous n'êtes pas dans une équipe." | ||||||
|  |  | ||||||
| @@ -716,7 +676,7 @@ msgstr "Ce trigramme est déjà utilisé." | |||||||
| msgid "No team was found with this access code." | msgid "No team was found with this access code." | ||||||
| msgstr "Aucune équipe n'a été trouvée avec ce code d'accès." | msgstr "Aucune équipe n'a été trouvée avec ce code d'accès." | ||||||
|  |  | ||||||
| #: participation/forms.py:58 participation/views.py:506 | #: participation/forms.py:58 participation/views.py:503 | ||||||
| msgid "The team is already validated or the validation is pending." | msgid "The team is already validated or the validation is pending." | ||||||
| msgstr "La validation de l'équipe est déjà faite ou en cours." | msgstr "La validation de l'équipe est déjà faite ou en cours." | ||||||
|  |  | ||||||
| @@ -1896,7 +1856,7 @@ msgid "Invalidate" | |||||||
| msgstr "Invalider" | msgstr "Invalider" | ||||||
|  |  | ||||||
| #: participation/templates/participation/team_detail.html:237 | #: participation/templates/participation/team_detail.html:237 | ||||||
| #: participation/views.py:336 | #: participation/views.py:333 | ||||||
| msgid "Upload motivation letter" | msgid "Upload motivation letter" | ||||||
| msgstr "Envoyer la lettre de motivation" | msgstr "Envoyer la lettre de motivation" | ||||||
|  |  | ||||||
| @@ -1905,7 +1865,7 @@ msgid "Update team" | |||||||
| msgstr "Modifier l'équipe" | msgstr "Modifier l'équipe" | ||||||
|  |  | ||||||
| #: participation/templates/participation/team_detail.html:247 | #: participation/templates/participation/team_detail.html:247 | ||||||
| #: participation/views.py:498 | #: participation/views.py:495 | ||||||
| msgid "Leave team" | msgid "Leave team" | ||||||
| msgstr "Quitter l'équipe" | msgstr "Quitter l'équipe" | ||||||
|  |  | ||||||
| @@ -2101,7 +2061,7 @@ msgstr "Vous êtes déjà dans une équipe." | |||||||
| msgid "Join team" | msgid "Join team" | ||||||
| msgstr "Rejoindre une équipe" | msgstr "Rejoindre une équipe" | ||||||
|  |  | ||||||
| #: participation/views.py:163 participation/views.py:536 | #: participation/views.py:163 participation/views.py:533 | ||||||
| msgid "You don't participate, so you don't have any team." | msgid "You don't participate, so you don't have any team." | ||||||
| msgstr "Vous ne participez pas, vous n'avez donc pas d'équipe." | msgstr "Vous ne participez pas, vous n'avez donc pas d'équipe." | ||||||
|  |  | ||||||
| @@ -2137,169 +2097,169 @@ msgstr "Vous n'êtes pas un⋅e organisateur⋅rice du tournoi." | |||||||
| msgid "This team has no pending validation." | msgid "This team has no pending validation." | ||||||
| msgstr "L'équipe n'a pas de validation en attente." | msgstr "L'équipe n'a pas de validation en attente." | ||||||
|  |  | ||||||
| #: participation/views.py:279 | #: participation/views.py:276 | ||||||
| msgid "You must specify if you validate the registration or not." | msgid "You must specify if you validate the registration or not." | ||||||
| msgstr "Vous devez spécifier si vous validez l'inscription ou non." | msgstr "Vous devez spécifier si vous validez l'inscription ou non." | ||||||
|  |  | ||||||
| #: participation/views.py:314 | #: participation/views.py:311 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Update team {trigram}" | msgid "Update team {trigram}" | ||||||
| msgstr "Mise à jour de l'équipe {trigram}" | msgstr "Mise à jour de l'équipe {trigram}" | ||||||
|  |  | ||||||
| #: participation/views.py:375 participation/views.py:483 | #: participation/views.py:372 participation/views.py:480 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Motivation letter of {team}.{ext}" | msgid "Motivation letter of {team}.{ext}" | ||||||
| msgstr "Lettre de motivation de {team}.{ext}" | msgstr "Lettre de motivation de {team}.{ext}" | ||||||
|  |  | ||||||
| #: participation/views.py:408 | #: participation/views.py:405 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Authorizations of team {trigram}.zip" | msgid "Authorizations of team {trigram}.zip" | ||||||
| msgstr "Autorisations de l'équipe {trigram}.zip" | msgstr "Autorisations de l'équipe {trigram}.zip" | ||||||
|  |  | ||||||
| #: participation/views.py:412 | #: participation/views.py:409 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Authorizations of {tournament}.zip" | msgid "Authorizations of {tournament}.zip" | ||||||
| msgstr "Autorisations du tournoi {tournament}.zip" | msgstr "Autorisations du tournoi {tournament}.zip" | ||||||
|  |  | ||||||
| #: participation/views.py:431 | #: participation/views.py:428 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Photo authorization of {participant}.{ext}" | msgid "Photo authorization of {participant}.{ext}" | ||||||
| msgstr "Autorisation de droit à l'image de {participant}.{ext}" | msgstr "Autorisation de droit à l'image de {participant}.{ext}" | ||||||
|  |  | ||||||
| #: participation/views.py:440 | #: participation/views.py:437 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Parental authorization of {participant}.{ext}" | msgid "Parental authorization of {participant}.{ext}" | ||||||
| msgstr "Autorisation parentale de {participant}.{ext}" | msgstr "Autorisation parentale de {participant}.{ext}" | ||||||
|  |  | ||||||
| #: participation/views.py:448 | #: participation/views.py:445 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Health sheet of {participant}.{ext}" | msgid "Health sheet of {participant}.{ext}" | ||||||
| msgstr "Fiche sanitaire de {participant}.{ext}" | msgstr "Fiche sanitaire de {participant}.{ext}" | ||||||
|  |  | ||||||
| #: participation/views.py:456 | #: participation/views.py:453 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Vaccine sheet of {participant}.{ext}" | msgid "Vaccine sheet of {participant}.{ext}" | ||||||
| msgstr "Carnet de vaccination de {participant}.{ext}" | msgstr "Carnet de vaccination de {participant}.{ext}" | ||||||
|  |  | ||||||
| #: participation/views.py:467 | #: participation/views.py:464 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Photo authorization of {participant} (final).{ext}" | msgid "Photo authorization of {participant} (final).{ext}" | ||||||
| msgstr "Autorisation de droit à l'image de {participant} (finale).{ext}" | msgstr "Autorisation de droit à l'image de {participant} (finale).{ext}" | ||||||
|  |  | ||||||
| #: participation/views.py:476 | #: participation/views.py:473 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Parental authorization of {participant} (final).{ext}" | msgid "Parental authorization of {participant} (final).{ext}" | ||||||
| msgstr "Autorisation parentale de {participant} (finale).{ext}" | msgstr "Autorisation parentale de {participant} (finale).{ext}" | ||||||
|  |  | ||||||
| #: participation/views.py:550 | #: participation/views.py:547 | ||||||
| msgid "The team is not validated yet." | msgid "The team is not validated yet." | ||||||
| msgstr "L'équipe n'est pas encore validée." | msgstr "L'équipe n'est pas encore validée." | ||||||
|  |  | ||||||
| #: participation/views.py:564 | #: participation/views.py:561 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Participation of team {trigram}" | msgid "Participation of team {trigram}" | ||||||
| msgstr "Participation de l'équipe {trigram}" | msgstr "Participation de l'équipe {trigram}" | ||||||
|  |  | ||||||
| #: participation/views.py:652 | #: participation/views.py:649 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Payments of {tournament}" | msgid "Payments of {tournament}" | ||||||
| msgstr "Paiements de {tournament}" | msgstr "Paiements de {tournament}" | ||||||
|  |  | ||||||
| #: participation/views.py:751 | #: participation/views.py:748 | ||||||
| msgid "Notes published!" | msgid "Notes published!" | ||||||
| msgstr "Notes publiées !" | msgstr "Notes publiées !" | ||||||
|  |  | ||||||
| #: participation/views.py:753 | #: participation/views.py:750 | ||||||
| msgid "Notes hidden!" | msgid "Notes hidden!" | ||||||
| msgstr "Notes dissimulées !" | msgstr "Notes dissimulées !" | ||||||
|  |  | ||||||
| #: participation/views.py:784 | #: participation/views.py:781 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Harmonize notes of {tournament} - Day {round}" | msgid "Harmonize notes of {tournament} - Day {round}" | ||||||
| msgstr "Harmoniser les notes de {tournament} - Jour {round}" | msgstr "Harmoniser les notes de {tournament} - Jour {round}" | ||||||
|  |  | ||||||
| #: participation/views.py:897 | #: participation/views.py:894 | ||||||
| msgid "You can't upload a solution after the deadline." | msgid "You can't upload a solution after the deadline." | ||||||
| msgstr "Vous ne pouvez pas envoyer de solution après la date limite." | msgstr "Vous ne pouvez pas envoyer de solution après la date limite." | ||||||
|  |  | ||||||
| #: participation/views.py:1017 | #: participation/views.py:1014 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Solutions of team {trigram}.zip" | msgid "Solutions of team {trigram}.zip" | ||||||
| msgstr "Solutions de l'équipe {trigram}.zip" | msgstr "Solutions de l'équipe {trigram}.zip" | ||||||
|  |  | ||||||
| #: participation/views.py:1017 | #: participation/views.py:1014 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Syntheses of team {trigram}.zip" | msgid "Syntheses of team {trigram}.zip" | ||||||
| msgstr "Notes de synthèse de l'équipe {trigram}.zip" | msgstr "Notes de synthèse de l'équipe {trigram}.zip" | ||||||
|  |  | ||||||
| #: participation/views.py:1034 participation/views.py:1049 | #: participation/views.py:1031 participation/views.py:1046 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Solutions of {tournament}.zip" | msgid "Solutions of {tournament}.zip" | ||||||
| msgstr "Solutions de {tournament}.zip" | msgstr "Solutions de {tournament}.zip" | ||||||
|  |  | ||||||
| #: participation/views.py:1034 participation/views.py:1049 | #: participation/views.py:1031 participation/views.py:1046 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Syntheses of {tournament}.zip" | msgid "Syntheses of {tournament}.zip" | ||||||
| msgstr "Notes de synthèse de {tournament}.zip" | msgstr "Notes de synthèse de {tournament}.zip" | ||||||
|  |  | ||||||
| #: participation/views.py:1058 | #: participation/views.py:1055 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Solutions for pool {pool} of tournament {tournament}.zip" | msgid "Solutions for pool {pool} of tournament {tournament}.zip" | ||||||
| msgstr "Solutions pour la poule {pool} du tournoi {tournament}.zip" | msgstr "Solutions pour la poule {pool} du tournoi {tournament}.zip" | ||||||
|  |  | ||||||
| #: participation/views.py:1059 | #: participation/views.py:1056 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Syntheses for pool {pool} of tournament {tournament}.zip" | msgid "Syntheses for pool {pool} of tournament {tournament}.zip" | ||||||
| msgstr "Notes de synthèses pour la poule {pool} du tournoi {tournament}.zip" | msgstr "Notes de synthèses pour la poule {pool} du tournoi {tournament}.zip" | ||||||
|  |  | ||||||
| #: participation/views.py:1101 | #: participation/views.py:1098 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Jury of pool {pool} for {tournament} with teams {teams}" | msgid "Jury of pool {pool} for {tournament} with teams {teams}" | ||||||
| msgstr "Jury de la poule {pool} pour {tournament} avec les équipes {teams}" | msgstr "Jury de la poule {pool} pour {tournament} avec les équipes {teams}" | ||||||
|  |  | ||||||
| #: participation/views.py:1117 | #: participation/views.py:1114 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "The jury {name} is already in the pool!" | msgid "The jury {name} is already in the pool!" | ||||||
| msgstr "{name} est déjà dans la poule !" | msgstr "{name} est déjà dans la poule !" | ||||||
|  |  | ||||||
| #: participation/views.py:1137 | #: participation/views.py:1134 | ||||||
| msgid "New TFJM² jury account" | msgid "New TFJM² jury account" | ||||||
| msgstr "Nouveau compte de juré⋅e pour le TFJM²" | msgstr "Nouveau compte de juré⋅e pour le TFJM²" | ||||||
|  |  | ||||||
| #: participation/views.py:1158 | #: participation/views.py:1155 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "The jury {name} has been successfully added!" | msgid "The jury {name} has been successfully added!" | ||||||
| msgstr "{name} a été ajouté⋅e avec succès en tant que juré⋅e !" | msgstr "{name} a été ajouté⋅e avec succès en tant que juré⋅e !" | ||||||
|  |  | ||||||
| #: participation/views.py:1194 | #: participation/views.py:1191 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "The jury {name} has been successfully removed!" | msgid "The jury {name} has been successfully removed!" | ||||||
| msgstr "{name} a été retiré⋅e avec succès du jury !" | msgstr "{name} a été retiré⋅e avec succès du jury !" | ||||||
|  |  | ||||||
| #: participation/views.py:1220 | #: participation/views.py:1217 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "The jury {name} has been successfully promoted president!" | msgid "The jury {name} has been successfully promoted president!" | ||||||
| msgstr "{name} a été nommé⋅e président⋅e du jury !" | msgstr "{name} a été nommé⋅e président⋅e du jury !" | ||||||
|  |  | ||||||
| #: participation/views.py:1248 | #: participation/views.py:1245 | ||||||
| msgid "The following user is not registered as a jury:" | msgid "The following user is not registered as a jury:" | ||||||
| msgstr "L'utilisateur⋅rice suivant n'est pas inscrit⋅e en tant que juré⋅e :" | msgstr "L'utilisateur⋅rice suivant n'est pas inscrit⋅e en tant que juré⋅e :" | ||||||
|  |  | ||||||
| #: participation/views.py:1264 | #: participation/views.py:1261 | ||||||
| msgid "Notes were successfully uploaded." | msgid "Notes were successfully uploaded." | ||||||
| msgstr "Les notes ont bien été envoyées." | msgstr "Les notes ont bien été envoyées." | ||||||
|  |  | ||||||
| #: participation/views.py:1842 | #: participation/views.py:1839 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Notation sheets of pool {pool} of {tournament}.zip" | msgid "Notation sheets of pool {pool} of {tournament}.zip" | ||||||
| msgstr "Feuilles de notations pour la poule {pool} du tournoi {tournament}.zip" | msgstr "Feuilles de notations pour la poule {pool} du tournoi {tournament}.zip" | ||||||
|  |  | ||||||
| #: participation/views.py:1847 | #: participation/views.py:1844 | ||||||
| #, python-brace-format | #, python-brace-format | ||||||
| msgid "Notation sheets of {tournament}.zip" | msgid "Notation sheets of {tournament}.zip" | ||||||
| msgstr "Feuilles de notation de {tournament}.zip" | msgstr "Feuilles de notation de {tournament}.zip" | ||||||
|  |  | ||||||
| #: participation/views.py:2012 | #: participation/views.py:2009 | ||||||
| msgid "You can't upload a synthesis after the deadline." | msgid "You can't upload a synthesis after the deadline." | ||||||
| msgstr "Vous ne pouvez pas envoyer de note de synthèse après la date limite." | msgstr "Vous ne pouvez pas envoyer de note de synthèse après la date limite." | ||||||
|  |  | ||||||
|   | |||||||
| @@ -17,8 +17,8 @@ class Command(BaseCommand): | |||||||
|             self.w("") |             self.w("") | ||||||
|             self.w("") |             self.w("") | ||||||
|  |  | ||||||
|     def w(self, msg, prefix="", suffix=""): |     def w(self, msg): | ||||||
|         self.stdout.write(f"{prefix}{msg}{suffix}") |         self.stdout.write(msg) | ||||||
|  |  | ||||||
|     def handle_tournament(self, tournament): |     def handle_tournament(self, tournament): | ||||||
|         name = tournament.name |         name = tournament.name | ||||||
| @@ -40,7 +40,7 @@ class Command(BaseCommand): | |||||||
|         if tournament.final: |         if tournament.final: | ||||||
|             self.w(f"<p>La finale a eu lieu le weekend du {date_start} au {date_end} et a été remporté par l'équipe " |             self.w(f"<p>La finale a eu lieu le weekend du {date_start} au {date_end} et a été remporté par l'équipe " | ||||||
|                    f"<em>{notes[0][0].team.name}</em> suivie de l'équipe <em>{notes[1][0].team.name}</em>. " |                    f"<em>{notes[0][0].team.name}</em> suivie de l'équipe <em>{notes[1][0].team.name}</em>. " | ||||||
|                    f"Les deux premières équipes sont sélectionnées pour représenter la France lors de l'ETEAM.</p>") |                    f"Les deux premières équipes sont sélectionnées pour représenter la France lors de l'ITYM.</p>") | ||||||
|         else: |         else: | ||||||
|             self.w(f"<p>Le tournoi de {name} a eu lieu le weekend du {date_start} au {date_end} et a été remporté par " |             self.w(f"<p>Le tournoi de {name} a eu lieu le weekend du {date_start} au {date_end} et a été remporté par " | ||||||
|                    f"l'équipe <em>{notes[0][0].team.name}</em>.</p>") |                    f"l'équipe <em>{notes[0][0].team.name}</em>.</p>") | ||||||
| @@ -52,29 +52,32 @@ class Command(BaseCommand): | |||||||
|         self.w("<table>") |         self.w("<table>") | ||||||
|         self.w("<thead>") |         self.w("<thead>") | ||||||
|         self.w("<tr>") |         self.w("<tr>") | ||||||
|         self.w("    <th>Équipe</th>") |         self.w("\t<th>Équipe</th>") | ||||||
|         self.w("    <th>Score Tour 1</th>") |         self.w("\t<th>Score Tour 1</th>") | ||||||
|         self.w("    <th>Score Tour 2</th>") |         self.w("\t<th>Score Tour 2</th>") | ||||||
|         self.w("    <th>Total</th>") |         self.w("\t<th>Total</th>") | ||||||
|         self.w("    <th class=\"has-text-align-center\">Prix</th>") |         self.w("\t<th class=\"has-text-align-center\">Prix</th>") | ||||||
|         self.w("</tr>") |         self.w("</tr>") | ||||||
|         self.w("</thead>") |         self.w("</thead>") | ||||||
|         self.w("<tbody>") |         self.w("<tbody>") | ||||||
|         for i, (participation, note) in enumerate(notes): |         for i, (participation, note) in enumerate(notes): | ||||||
|             self.w("<tr>") |             self.w("<tr>") | ||||||
|             bold = (not tournament.final and participation.final) or (tournament.final and i < 2) |             if i < (2 if len(notes) >= 7 else 1): | ||||||
|             if bold: |                 self.w(f"\t<th>{participation.team.name} ({participation.team.trigram})</td>") | ||||||
|                 prefix, suffix = "    <td><strong>", "</strong></td>" |  | ||||||
|             else: |             else: | ||||||
|                 prefix, suffix = "    <td>", "</td>" |                 self.w(f"\t<td>{participation.team.name} ({participation.team.trigram})</td>") | ||||||
|             self.w(f"{participation.team.name} ({participation.team.trigram})", prefix, suffix) |             for pool in tournament.pools.filter(participations=participation).all(): | ||||||
|             for tournament_round in [1, 2]: |                 pool_note = pool.average(participation) | ||||||
|                 pool_note = sum(pool.average(participation) |                 self.w(f"\t<td>{pool_note:.01f}</td>") | ||||||
|                                 for pool in tournament.pools.filter(participations=participation, |             self.w(f"\t<td>{note:.01f}</td>") | ||||||
|                                                                     round=tournament_round).all()) |             if i == 0: | ||||||
|                 self.w(f"{pool_note:.01f}", prefix, suffix) |                 self.w("\t<td class=\"has-text-align-center\">1<sup>er</sup> prix</td>") | ||||||
|             self.w(f"{note:.01f}", prefix, suffix) |             elif i < (5 if tournament.final else 3): | ||||||
|             self.w(participation.mention_final if tournament.final else participation.mention, prefix, suffix) |                 self.w(f"\t<td class=\"has-text-align-center\">{i + 1}<sup>ème</sup> prix</td>") | ||||||
|  |             elif i < 2 * len(notes) / 3: | ||||||
|  |                 self.w("\t<td class=\"has-text-align-center\">Mention très honorable</td>") | ||||||
|  |             else: | ||||||
|  |                 self.w("\t<td class=\"has-text-align-center\">Mention honorable</td>") | ||||||
|             self.w("</tr>") |             self.w("</tr>") | ||||||
|         self.w("</tbody>") |         self.w("</tbody>") | ||||||
|         self.w("</table>") |         self.w("</table>") | ||||||
|   | |||||||
| @@ -10,19 +10,19 @@ | |||||||
|         </div> |         </div> | ||||||
|     <div class="card-body"> |     <div class="card-body"> | ||||||
|         <dl class="row"> |         <dl class="row"> | ||||||
|             <dt class="col-sm-6 text-sm-end">{% trans "Name:" %}</dt> |             <dt class="col-sm-6 text-end">{% trans "Name:" %}</dt> | ||||||
|             <dd class="col-sm-6">{{ team.name }}</dd> |             <dd class="col-sm-6">{{ team.name }}</dd> | ||||||
|  |  | ||||||
|             <dt class="col-sm-6 text-sm-end">{% trans "Trigram:" %}</dt> |             <dt class="col-sm-6 text-end">{% trans "Trigram:" %}</dt> | ||||||
|             <dd class="col-sm-6">{{ team.trigram }}</dd> |             <dd class="col-sm-6">{{ team.trigram }}</dd> | ||||||
|  |  | ||||||
|             <dt class="col-sm-6 text-sm-end">{% trans "Email:" %}</dt> |             <dt class="col-sm-6 text-end">{% trans "Email:" %}</dt> | ||||||
|             <dd class="col-sm-6"><a href="mailto:{{ team.email }}">{{ team.email }}</a></dd> |             <dd class="col-sm-6"><a href="mailto:{{ team.email }}">{{ team.email }}</a></dd> | ||||||
|  |  | ||||||
|             <dt class="col-sm-6 text-sm-end">{% trans "Access code:" %}</dt> |             <dt class="col-sm-6 text-end">{% trans "Access code:" %}</dt> | ||||||
|             <dd class="col-sm-6">{{ team.access_code }}</dd> |             <dd class="col-sm-6">{{ team.access_code }}</dd> | ||||||
|  |  | ||||||
|             <dt class="col-sm-6 text-sm-end">{% trans "Coaches:" %}</dt> |             <dt class="col-sm-6 text-end">{% trans "Coaches:" %}</dt> | ||||||
|             <dd class="col-sm-6"> |             <dd class="col-sm-6"> | ||||||
|                 {% for coach in team.coaches.all %} |                 {% for coach in team.coaches.all %} | ||||||
|                     <a href="{% url "registration:user_detail" pk=coach.user.pk %}">{{ coach }}</a>{% if not forloop.last %},{% endif %} |                     <a href="{% url "registration:user_detail" pk=coach.user.pk %}">{{ coach }}</a>{% if not forloop.last %},{% endif %} | ||||||
| @@ -31,7 +31,7 @@ | |||||||
|                 {% endfor %} |                 {% endfor %} | ||||||
|             </dd> |             </dd> | ||||||
|  |  | ||||||
|             <dt class="col-sm-6 text-sm-end">{% trans "Participants:" %}</dt> |             <dt class="col-sm-6 text-end">{% trans "Participants:" %}</dt> | ||||||
|             <dd class="col-sm-6"> |             <dd class="col-sm-6"> | ||||||
|                 {% for student in team.students.all %} |                 {% for student in team.students.all %} | ||||||
|                     <a href="{% url "registration:user_detail" pk=student.user.pk %}">{{ student }}</a>{% if not forloop.last %},{% endif %} |                     <a href="{% url "registration:user_detail" pk=student.user.pk %}">{{ student }}</a>{% if not forloop.last %},{% endif %} | ||||||
| @@ -40,7 +40,7 @@ | |||||||
|                 {% endfor %} |                 {% endfor %} | ||||||
|             </dd> |             </dd> | ||||||
|  |  | ||||||
|             <dt class="col-sm-6 text-sm-end">{% trans "Tournament:" %}</dt> |             <dt class="col-sm-6 text-end">{% trans "Tournament:" %}</dt> | ||||||
|             <dd class="col-sm-6"> |             <dd class="col-sm-6"> | ||||||
|                 {% if team.participation.tournament %} |                 {% if team.participation.tournament %} | ||||||
|                     <a href="{% url "participation:tournament_detail" pk=team.participation.tournament.pk %}">{{ team.participation.tournament }}</a> |                     <a href="{% url "participation:tournament_detail" pk=team.participation.tournament.pk %}">{{ team.participation.tournament }}</a> | ||||||
| @@ -49,7 +49,7 @@ | |||||||
|                 {% endif %} |                 {% endif %} | ||||||
|             </dd> |             </dd> | ||||||
|  |  | ||||||
|             <dt class="col-sm-6 text-sm-end">{% trans "Photo authorizations:" %}</dt> |             <dt class="col-sm-6 text-end">{% trans "Photo authorizations:" %}</dt> | ||||||
|             <dd class="col-sm-6"> |             <dd class="col-sm-6"> | ||||||
|                 {% for participant in team.participants.all %} |                 {% for participant in team.participants.all %} | ||||||
|                     {% if participant.photo_authorization %} |                     {% if participant.photo_authorization %} | ||||||
| @@ -61,7 +61,7 @@ | |||||||
|             </dd> |             </dd> | ||||||
|  |  | ||||||
|             {% if team.participation.final %} |             {% if team.participation.final %} | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "Photo authorizations (final):" %}</dt> |                 <dt class="col-sm-6 text-end">{% trans "Photo authorizations (final):" %}</dt> | ||||||
|                 <dd class="col-sm-6"> |                 <dd class="col-sm-6"> | ||||||
|                     {% for participant in team.participants.all %} |                     {% for participant in team.participants.all %} | ||||||
|                         {% if participant.photo_authorization_final %} |                         {% if participant.photo_authorization_final %} | ||||||
| @@ -74,7 +74,7 @@ | |||||||
|             {% endif %} |             {% endif %} | ||||||
|  |  | ||||||
|             {% if not team.participation.tournament.remote  %} |             {% if not team.participation.tournament.remote  %} | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "Health sheets:" %}</dt> |                 <dt class="col-sm-6 text-end">{% trans "Health sheets:" %}</dt> | ||||||
|                 <dd class="col-sm-6"> |                 <dd class="col-sm-6"> | ||||||
|                     {% for student in team.students.all %} |                     {% for student in team.students.all %} | ||||||
|                         {% if student.under_18 %} |                         {% if student.under_18 %} | ||||||
| @@ -87,7 +87,7 @@ | |||||||
|                     {% endfor %} |                     {% endfor %} | ||||||
|                 </dd> |                 </dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "Vaccine sheets:" %}</dt> |                 <dt class="col-sm-6 text-end">{% trans "Vaccine sheets:" %}</dt> | ||||||
|                 <dd class="col-sm-6"> |                 <dd class="col-sm-6"> | ||||||
|                     {% for student in team.students.all %} |                     {% for student in team.students.all %} | ||||||
|                         {% if student.under_18 %} |                         {% if student.under_18 %} | ||||||
| @@ -100,7 +100,7 @@ | |||||||
|                     {% endfor %} |                     {% endfor %} | ||||||
|                 </dd> |                 </dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "Parental authorizations:" %}</dt> |                 <dt class="col-sm-6 text-end">{% trans "Parental authorizations:" %}</dt> | ||||||
|                 <dd class="col-sm-6"> |                 <dd class="col-sm-6"> | ||||||
|                     {% for student in team.students.all %} |                     {% for student in team.students.all %} | ||||||
|                         {% if student.under_18 %} |                         {% if student.under_18 %} | ||||||
| @@ -114,7 +114,7 @@ | |||||||
|                 </dd> |                 </dd> | ||||||
|  |  | ||||||
|                 {% if team.participation.final %} |                 {% if team.participation.final %} | ||||||
|                     <dt class="col-sm-6 text-sm-end">{% trans "Parental authorizations (final):" %}</dt> |                     <dt class="col-sm-6 text-end">{% trans "Parental authorizations (final):" %}</dt> | ||||||
|                     <dd class="col-sm-6"> |                     <dd class="col-sm-6"> | ||||||
|                         {% for student in team.students.all %} |                         {% for student in team.students.all %} | ||||||
|                             {% if student.under_18_final %} |                             {% if student.under_18_final %} | ||||||
| @@ -129,7 +129,7 @@ | |||||||
|                 {% endif %} |                 {% endif %} | ||||||
|             {% endif %} |             {% endif %} | ||||||
|  |  | ||||||
|             <dt class="col-sm-6 text-sm-end">{% trans "Motivation letter:" %}</dt> |             <dt class="col-sm-6 text-end">{% trans "Motivation letter:" %}</dt> | ||||||
|             <dd class="col-sm-6"> |             <dd class="col-sm-6"> | ||||||
|                 {% if team.motivation_letter %} |                 {% if team.motivation_letter %} | ||||||
|                     <a href="{{ team.motivation_letter.url }}">{% trans "Download" %}</a> |                     <a href="{{ team.motivation_letter.url }}">{% trans "Download" %}</a> | ||||||
| @@ -155,7 +155,7 @@ | |||||||
|             <hr class="my-3"> |             <hr class="my-3"> | ||||||
|             {% for student in team.students.all %} |             {% for student in team.students.all %} | ||||||
|                 {% for payment in student.payments.all %} |                 {% for payment in student.payments.all %} | ||||||
|                     <dt class="col-sm-6 text-sm-end"> |                     <dt class="col-sm-6 text-end"> | ||||||
|                         {% trans "Payment of" %} {{ student }} |                         {% trans "Payment of" %} {{ student }} | ||||||
|                         {% if payment.grouped %}({% trans "grouped" %}){% endif %} |                         {% if payment.grouped %}({% trans "grouped" %}){% endif %} | ||||||
|                         {% if payment.final %} ({% trans "final" %}){% endif %} : |                         {% if payment.final %} ({% trans "final" %}){% endif %} : | ||||||
|   | |||||||
| @@ -9,53 +9,53 @@ | |||||||
|         </div> |         </div> | ||||||
|         <div class="card-body"> |         <div class="card-body"> | ||||||
|             <dl class="row"> |             <dl class="row"> | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans 'organizers'|capfirst %}</dt> |                 <dt class="col-xl-6 text-end">{% trans 'organizers'|capfirst %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ tournament.organizers.all|join:", " }}</dd> |                 <dd class="col-xl-6">{{ tournament.organizers.all|join:", " }}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans 'size'|capfirst %}</dt> |                 <dt class="col-xl-6 text-end">{% trans 'size'|capfirst %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ tournament.max_teams }}</dd> |                 <dd class="col-xl-6">{{ tournament.max_teams }}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans 'place'|capfirst %}</dt> |                 <dt class="col-xl-6 text-end">{% trans 'place'|capfirst %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ tournament.place }}</dd> |                 <dd class="col-xl-6">{{ tournament.place }}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans 'price'|capfirst %}</dt> |                 <dt class="col-xl-6 text-end">{% trans 'price'|capfirst %}</dt> | ||||||
|                 <dd class="col-sm-6">{% if tournament.price %}{{ tournament.price }} €{% else %}{% trans "Free" %}{% endif %}</dd> |                 <dd class="col-xl-6">{% if tournament.price %}{{ tournament.price }} €{% else %}{% trans "Free" %}{% endif %}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans 'remote'|capfirst %}</dt> |                 <dt class="col-xl-6 text-end">{% trans 'remote'|capfirst %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ tournament.remote|yesno }}</dd> |                 <dd class="col-xl-6">{{ tournament.remote|yesno }}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans 'dates'|capfirst %}</dt> |                 <dt class="col-xl-6 text-end">{% trans 'dates'|capfirst %}</dt> | ||||||
|                 <dd class="col-sm-6">{% trans "From" %} {{ tournament.date_start }} {% trans "to" %} {{ tournament.date_end }}</dd> |                 <dd class="col-xl-6">{% trans "From" %} {{ tournament.date_start }} {% trans "to" %} {{ tournament.date_end }}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans 'date of registration closing'|capfirst %}</dt> |                 <dt class="col-xl-6 text-end">{% trans 'date of registration closing'|capfirst %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ tournament.inscription_limit }}</dd> |                 <dd class="col-xl-6">{{ tournament.inscription_limit }}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans 'date of maximal solution submission'|capfirst %}</dt> |                 <dt class="col-xl-6 text-end">{% trans 'date of maximal solution submission'|capfirst %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ tournament.solution_limit }}</dd> |                 <dd class="col-xl-6">{{ tournament.solution_limit }}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans 'date of the random draw'|capfirst %}</dt> |                 <dt class="col-xl-6 text-end">{% trans 'date of the random draw'|capfirst %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ tournament.solutions_draw }}</dd> |                 <dd class="col-xl-6">{{ tournament.solutions_draw }}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans 'date of maximal syntheses submission for the first round'|capfirst %}</dt> |                 <dt class="col-xl-6 text-end">{% trans 'date of maximal syntheses submission for the first round'|capfirst %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ tournament.syntheses_first_phase_limit }}</dd> |                 <dd class="col-xl-6">{{ tournament.syntheses_first_phase_limit }}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans 'date when solutions of round 2 are available'|capfirst %}</dt> |                 <dt class="col-xl-6 text-end">{% trans 'date when solutions of round 2 are available'|capfirst %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ tournament.solutions_available_second_phase }}</dd> |                 <dd class="col-xl-6">{{ tournament.solutions_available_second_phase }}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans 'date of maximal syntheses submission for the second round'|capfirst %}</dt> |                 <dt class="col-xl-6 text-end">{% trans 'date of maximal syntheses submission for the second round'|capfirst %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ tournament.syntheses_second_phase_limit }}</dd> |                 <dd class="col-xl-6">{{ tournament.syntheses_second_phase_limit }}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans 'description'|capfirst %}</dt> |                 <dt class="col-xl-6 text-end">{% trans 'description'|capfirst %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ tournament.description }}</dd> |                 <dd class="col-xl-6">{{ tournament.description }}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans 'To contact organizers' %}</dt> |                 <dt class="col-xl-6 text-end">{% trans 'To contact organizers' %}</dt> | ||||||
|                 <dd class="col-sm-6"><a href="mailto:{{ tournament.organizers_email }}">{{ tournament.organizers_email }}</a></dd> |                 <dd class="col-xl-6"><a href="mailto:{{ tournament.organizers_email }}">{{ tournament.organizers_email }}</a></dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans 'To contact juries' %}</dt> |                 <dt class="col-xl-6 text-end">{% trans 'To contact juries' %}</dt> | ||||||
|                 <dd class="col-sm-6"><a href="mailto:{{ tournament.jurys_email }}">{{ tournament.jurys_email }}</a></dd> |                 <dd class="col-xl-6"><a href="mailto:{{ tournament.jurys_email }}">{{ tournament.jurys_email }}</a></dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans 'To contact valid teams' %}</dt> |                 <dt class="col-xl-6 text-end">{% trans 'To contact valid teams' %}</dt> | ||||||
|                 <dd class="col-sm-6"><a href="mailto:{{ tournament.teams_email }}">{{ tournament.teams_email }}</a></dd> |                 <dd class="col-xl-6"><a href="mailto:{{ tournament.teams_email }}">{{ tournament.teams_email }}</a></dd> | ||||||
|             </dl> |             </dl> | ||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
| @@ -105,7 +105,7 @@ | |||||||
|                     {% for participation, note in notes %} |                     {% for participation, note in notes %} | ||||||
|                         <li> |                         <li> | ||||||
|                             <strong>{{ participation.team }} :</strong> {{ note|floatformat }} |                             <strong>{{ participation.team }} :</strong> {{ note|floatformat }} | ||||||
|                             {% if available_notes_2 or user.registration.is_volunteer %} |                             {% if available_notes_2 %} | ||||||
|                                 {% if not tournament.final and participation.mention %} |                                 {% if not tournament.final and participation.mention %} | ||||||
|                                     — {{ participation.mention }} |                                     — {{ participation.mention }} | ||||||
|                                 {% endif %} |                                 {% endif %} | ||||||
| @@ -113,7 +113,7 @@ | |||||||
|                                     — {{ participation.mention_final }} |                                     — {{ participation.mention_final }} | ||||||
|                                 {% endif %} |                                 {% endif %} | ||||||
|                              {% endif %} |                              {% endif %} | ||||||
|                             {% if participation.final and not tournament.final %} |                             {% if participation.final %} | ||||||
|                                 <span class="badge badge-sm text-bg-warning"> |                                 <span class="badge badge-sm text-bg-warning"> | ||||||
|                                     <i class="fas fa-medal"></i> |                                     <i class="fas fa-medal"></i> | ||||||
|                                     {% trans "Selected for final tournament" %} |                                     {% trans "Selected for final tournament" %} | ||||||
|   | |||||||
| @@ -7,10 +7,10 @@ | |||||||
|         <div id="form-content"> |         <div id="form-content"> | ||||||
|             <div class="alert alert-info"> |             <div class="alert alert-info"> | ||||||
|                 {% trans "Templates:" %} |                 {% trans "Templates:" %} | ||||||
|                 <a class="alert-link" href="{% static "tfjm/Fiche_synthèse.pdf" %}"> PDF</a> — |                 <a class="alert-link" href="{% static "Fiche_synthèse.pdf" %}"> PDF</a> — | ||||||
|                 <a class="alert-link" href="{% static "tfjm/Fiche_synthèse.tex" %}"> TEX</a> — |                 <a class="alert-link" href="{% static "Fiche_synthèse.tex" %}"> TEX</a> — | ||||||
|                 <a class="alert-link" href="{% static "tfjm/Fiche_synthèse.odt" %}"> ODT</a> — |                 <a class="alert-link" href="{% static "Fiche_synthèse.odt" %}"> ODT</a> — | ||||||
|                 <a class="alert-link" href="{% static "tfjm/Fiche_synthèse.docx" %}" title="{% trans "Warning: non-free format" %}"> DOCX</a> |                 <a class="alert-link" href="{% static "Fiche_synthèse.docx" %}" title="{% trans "Warning: non-free format" %}"> DOCX</a> | ||||||
|             </div> |             </div> | ||||||
|             {% csrf_token %} |             {% csrf_token %} | ||||||
|             {{ form|crispy }} |             {{ form|crispy }} | ||||||
|   | |||||||
| @@ -259,20 +259,17 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView) | |||||||
|                     payment = Payment.objects.get(registrations=registration, final=False) |                     payment = Payment.objects.get(registrations=registration, final=False) | ||||||
|                 else: |                 else: | ||||||
|                     payment = None |                     payment = None | ||||||
|                 mail_context_plain = dict(domain=domain, registration=registration, team=self.object, payment=payment, |                 mail_context = dict(domain=domain, registration=registration, team=self.object, payment=payment, | ||||||
|                                     message=form.cleaned_data["message"]) |                                     message=form.cleaned_data["message"]) | ||||||
|                 mail_context_html = dict(domain=domain, registration=registration, team=self.object, payment=payment, |                 mail_plain = render_to_string("participation/mails/team_validated.txt", mail_context) | ||||||
|                                          message=form.cleaned_data["message"].replace('\n', '<br>')) |                 mail_html = render_to_string("participation/mails/team_validated.html", mail_context) | ||||||
|                 mail_plain = render_to_string("participation/mails/team_validated.txt", mail_context_plain) |  | ||||||
|                 mail_html = render_to_string("participation/mails/team_validated.html", mail_context_html) |  | ||||||
|                 registration.user.email_user("[TFJM²] Équipe validée", mail_plain, html_message=mail_html) |                 registration.user.email_user("[TFJM²] Équipe validée", mail_plain, html_message=mail_html) | ||||||
|         elif "invalidate" in self.request.POST: |         elif "invalidate" in self.request.POST: | ||||||
|             self.object.participation.valid = None |             self.object.participation.valid = None | ||||||
|             self.object.participation.save() |             self.object.participation.save() | ||||||
|             mail_context_plain = dict(team=self.object, message=form.cleaned_data["message"]) |             mail_context = dict(team=self.object, message=form.cleaned_data["message"]) | ||||||
|             mail_context_html = dict(team=self.object, message=form.cleaned_data["message"].replace('\n', '<br>')) |             mail_plain = render_to_string("participation/mails/team_not_validated.txt", mail_context) | ||||||
|             mail_plain = render_to_string("participation/mails/team_not_validated.txt", mail_context_plain) |             mail_html = render_to_string("participation/mails/team_not_validated.html", mail_context) | ||||||
|             mail_html = render_to_string("participation/mails/team_not_validated.html", mail_context_html) |  | ||||||
|             send_mail("[TFJM²] Équipe non validée", mail_plain, None, [self.object.email], |             send_mail("[TFJM²] Équipe non validée", mail_plain, None, [self.object.email], | ||||||
|                       html_message=mail_html) |                       html_message=mail_html) | ||||||
|         else: |         else: | ||||||
| @@ -394,7 +391,7 @@ class TeamAuthorizationsView(LoginRequiredMixin, View): | |||||||
|             tournament = Tournament.objects.get(pk=kwargs["tournament_id"]) |             tournament = Tournament.objects.get(pk=kwargs["tournament_id"]) | ||||||
|  |  | ||||||
|         if user.registration.is_admin or user.registration.is_volunteer \ |         if user.registration.is_admin or user.registration.is_volunteer \ | ||||||
|                 and (user.registration in tournament.organizers.all() |                 and (user.registration in tournament.organizers | ||||||
|                      or (team is not None and team.participation.final |                      or (team is not None and team.participation.final | ||||||
|                          and user.registration in Tournament.final_tournament().organizers)): |                          and user.registration in Tournament.final_tournament().organizers)): | ||||||
|             return super().dispatch(request, *args, **kwargs) |             return super().dispatch(request, *args, **kwargs) | ||||||
|   | |||||||
| @@ -9,7 +9,7 @@ | |||||||
|         <div id="form-content"> |         <div id="form-content"> | ||||||
|             <div class="alert alert-info"> |             <div class="alert alert-info"> | ||||||
|                 {% trans "Health sheet template:" %} |                 {% trans "Health sheet template:" %} | ||||||
|                 <a class="alert-link" href="{% static "tfjm/Fiche_sanitaire.pdf" %}">{% trans "Download" %}</a> |                 <a class="alert-link" href="{% static "Fiche_sanitaire.pdf" %}">{% trans "Download" %}</a> | ||||||
|             </div> |             </div> | ||||||
|             {% csrf_token %} |             {% csrf_token %} | ||||||
|             {{ form|crispy }} |             {{ form|crispy }} | ||||||
|   | |||||||
| @@ -11,18 +11,18 @@ | |||||||
|         </div> |         </div> | ||||||
|     <div class="card-body"> |     <div class="card-body"> | ||||||
|         <dl class="row"> |         <dl class="row"> | ||||||
|             <dt class="col-sm-6 text-sm-end">{% trans "Last name:" %}</dt> |             <dt class="col-sm-6 text-end">{% trans "Last name:" %}</dt> | ||||||
|             <dd class="col-sm-6">{{ user_object.last_name }}</dd> |             <dd class="col-sm-6">{{ user_object.last_name }}</dd> | ||||||
|  |  | ||||||
|             <dt class="col-sm-6 text-sm-end">{% trans "First name:" %}</dt> |             <dt class="col-sm-6 text-end">{% trans "First name:" %}</dt> | ||||||
|             <dd class="col-sm-6">{{ user_object.first_name }}</dd> |             <dd class="col-sm-6">{{ user_object.first_name }}</dd> | ||||||
|  |  | ||||||
|             <dt class="col-sm-6 text-sm-end">{% trans "Email:" %}</dt> |             <dt class="col-sm-6 text-end">{% trans "Email:" %}</dt> | ||||||
|             <dd class="col-sm-6"><a href="mailto:{{ user_object.email }}">{{ user_object.email }}</a> |             <dd class="col-sm-6"><a href="mailto:{{ user_object.email }}">{{ user_object.email }}</a> | ||||||
|                 {% if not user_object.registration.email_confirmed %} (<em>{% trans "Not confirmed" %}, <a href="{% url "registration:email_validation_resend" pk=user_object.pk %}">{% trans "resend the validation link" %}</a></em>){% endif %}</dd> |                 {% if not user_object.registration.email_confirmed %} (<em>{% trans "Not confirmed" %}, <a href="{% url "registration:email_validation_resend" pk=user_object.pk %}">{% trans "resend the validation link" %}</a></em>){% endif %}</dd> | ||||||
|  |  | ||||||
|             {% if user_object == user %} |             {% if user_object == user %} | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "Password:" %}</dt> |                 <dt class="col-sm-6 text-end">{% trans "Password:" %}</dt> | ||||||
|                 <dd class="col-sm-6"> |                 <dd class="col-sm-6"> | ||||||
|                     <a href="{% url 'password_change' %}" class="btn btn-sm btn-secondary"> |                     <a href="{% url 'password_change' %}" class="btn btn-sm btn-secondary"> | ||||||
|                         <i class="fas fa-edit"></i> {% trans "Change password" %} |                         <i class="fas fa-edit"></i> {% trans "Change password" %} | ||||||
| @@ -31,7 +31,7 @@ | |||||||
|             {% endif %} |             {% endif %} | ||||||
|  |  | ||||||
|             {% if user_object.registration.participates %} |             {% if user_object.registration.participates %} | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "Team:" %}</dt> |                 <dt class="col-sm-6 text-end">{% trans "Team:" %}</dt> | ||||||
|                 {% trans "any" as any %} |                 {% trans "any" as any %} | ||||||
|                 <dd class="col-sm-6"> |                 <dd class="col-sm-6"> | ||||||
|                     <a href="{% if user_object.registration.team %}{% url "participation:team_detail" pk=user_object.registration.team.pk %}{% else %}#{% endif %}"> |                     <a href="{% if user_object.registration.team %}{% url "participation:team_detail" pk=user_object.registration.team.pk %}{% else %}#{% endif %}"> | ||||||
| @@ -40,30 +40,30 @@ | |||||||
|                 </dd> |                 </dd> | ||||||
|  |  | ||||||
|                 {% if user_object.registration.studentregistration %} |                 {% if user_object.registration.studentregistration %} | ||||||
|                     <dt class="col-sm-6 text-sm-end">{% trans "Birth date:" %}</dt> |                     <dt class="col-sm-6 text-end">{% trans "Birth date:" %}</dt> | ||||||
|                     <dd class="col-sm-6">{{ user_object.registration.birth_date }}</dd> |                     <dd class="col-sm-6">{{ user_object.registration.birth_date }}</dd> | ||||||
|                 {% endif %} |                 {% endif %} | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "Gender:" %}</dt> |                 <dt class="col-sm-6 text-end">{% trans "Gender:" %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ user_object.registration.get_gender_display }}</dd> |                 <dd class="col-sm-6">{{ user_object.registration.get_gender_display }}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "Address:" %}</dt> |                 <dt class="col-sm-6 text-end">{% trans "Address:" %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ user_object.registration.address }}, {{ user_object.registration.zip_code|stringformat:'05d' }} {{ user_object.registration.city }}</dd> |                 <dd class="col-sm-6">{{ user_object.registration.address }}, {{ user_object.registration.zip_code|stringformat:'05d' }} {{ user_object.registration.city }}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "Phone number:" %}</dt> |                 <dt class="col-sm-6 text-end">{% trans "Phone number:" %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ user_object.registration.phone_number }}</dd> |                 <dd class="col-sm-6">{{ user_object.registration.phone_number }}</dd> | ||||||
|  |  | ||||||
|                 {% if user_object.registration.health_issues %} |                 {% if user_object.registration.health_issues %} | ||||||
|                     <dt class="col-sm-6 text-sm-end">{% trans "Health issues:" %}</dt> |                     <dt class="col-sm-6 text-end">{% trans "Health issues:" %}</dt> | ||||||
|                     <dd class="col-sm-6">{{ user_object.registration.health_issues }}</dd> |                     <dd class="col-sm-6">{{ user_object.registration.health_issues }}</dd> | ||||||
|                 {% endif %} |                 {% endif %} | ||||||
|  |  | ||||||
|                 {% if user_object.registration.housing_constraints %} |                 {% if user_object.registration.housing_constraints %} | ||||||
|                     <dt class="col-sm-6 text-sm-end">{% trans "Housing constraints:" %}</dt> |                     <dt class="col-sm-6 text-end">{% trans "Housing constraints:" %}</dt> | ||||||
|                     <dd class="col-sm-6">{{ user_object.registration.housing_constraints }}</dd> |                     <dd class="col-sm-6">{{ user_object.registration.housing_constraints }}</dd> | ||||||
|                 {% endif %} |                 {% endif %} | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "Photo authorization:" %}</dt> |                 <dt class="col-sm-6 text-end">{% trans "Photo authorization:" %}</dt> | ||||||
|                 <dd class="col-sm-6"> |                 <dd class="col-sm-6"> | ||||||
|                     {% if user_object.registration.photo_authorization %} |                     {% if user_object.registration.photo_authorization %} | ||||||
|                         <a href="{{ user_object.registration.photo_authorization.url }}">{% trans "Download" %}</a> |                         <a href="{{ user_object.registration.photo_authorization.url }}">{% trans "Download" %}</a> | ||||||
| @@ -74,7 +74,7 @@ | |||||||
|                 </dd> |                 </dd> | ||||||
|  |  | ||||||
|                 {% if user_object.registration.team.participation.final %} |                 {% if user_object.registration.team.participation.final %} | ||||||
|                     <dt class="col-sm-6 text-sm-end">{% trans "Photo authorization (final):" %}</dt> |                     <dt class="col-sm-6 text-end">{% trans "Photo authorization (final):" %}</dt> | ||||||
|                     <dd class="col-sm-6"> |                     <dd class="col-sm-6"> | ||||||
|                         {% if user_object.registration.photo_authorization_final %} |                         {% if user_object.registration.photo_authorization_final %} | ||||||
|                             <a href="{{ user_object.registration.photo_authorization_final.url }}">{% trans "Download" %}</a> |                             <a href="{{ user_object.registration.photo_authorization_final.url }}">{% trans "Download" %}</a> | ||||||
| @@ -86,7 +86,7 @@ | |||||||
|  |  | ||||||
|             {% if user_object.registration.studentregistration %} |             {% if user_object.registration.studentregistration %} | ||||||
|                 {% if user_object.registration.under_18 and user_object.registration.team.participation.tournament and not user_object.registration.team.participation.tournament.remote %} |                 {% if user_object.registration.under_18 and user_object.registration.team.participation.tournament and not user_object.registration.team.participation.tournament.remote %} | ||||||
|                     <dt class="col-sm-6 text-sm-end">{% trans "Health sheet:" %}</dt> |                     <dt class="col-sm-6 text-end">{% trans "Health sheet:" %}</dt> | ||||||
|                     <dd class="col-sm-6"> |                     <dd class="col-sm-6"> | ||||||
|                         {% if user_object.registration.health_sheet %} |                         {% if user_object.registration.health_sheet %} | ||||||
|                             <a href="{{ user_object.registration.health_sheet.url }}">{% trans "Download" %}</a> |                             <a href="{{ user_object.registration.health_sheet.url }}">{% trans "Download" %}</a> | ||||||
| @@ -96,7 +96,7 @@ | |||||||
|                         {% endif %} |                         {% endif %} | ||||||
|                     </dd> |                     </dd> | ||||||
|  |  | ||||||
|                     <dt class="col-sm-6 text-sm-end">{% trans "Vaccine sheet:" %}</dt> |                     <dt class="col-sm-6 text-end">{% trans "Vaccine sheet:" %}</dt> | ||||||
|                     <dd class="col-sm-6"> |                     <dd class="col-sm-6"> | ||||||
|                         {% if user_object.registration.vaccine_sheet %} |                         {% if user_object.registration.vaccine_sheet %} | ||||||
|                             <a href="{{ user_object.registration.vaccine_sheet.url }}">{% trans "Download" %}</a> |                             <a href="{{ user_object.registration.vaccine_sheet.url }}">{% trans "Download" %}</a> | ||||||
| @@ -106,7 +106,7 @@ | |||||||
|                         {% endif %} |                         {% endif %} | ||||||
|                     </dd> |                     </dd> | ||||||
|  |  | ||||||
|                     <dt class="col-sm-6 text-sm-end">{% trans "Parental authorization:" %}</dt> |                     <dt class="col-sm-6 text-end">{% trans "Parental authorization:" %}</dt> | ||||||
|                     <dd class="col-sm-6"> |                     <dd class="col-sm-6"> | ||||||
|                         {% if user_object.registration.parental_authorization %} |                         {% if user_object.registration.parental_authorization %} | ||||||
|                             <a href="{{ user_object.registration.parental_authorization.url }}">{% trans "Download" %}</a> |                             <a href="{{ user_object.registration.parental_authorization.url }}">{% trans "Download" %}</a> | ||||||
| @@ -117,7 +117,7 @@ | |||||||
|                     </dd> |                     </dd> | ||||||
|  |  | ||||||
|                     {% if user_object.registration.team.participation.final %} |                     {% if user_object.registration.team.participation.final %} | ||||||
|                         <dt class="col-sm-6 text-sm-end">{% trans "Parental authorization (final):" %}</dt> |                         <dt class="col-sm-6 text-end">{% trans "Parental authorization (final):" %}</dt> | ||||||
|                         <dd class="col-sm-6"> |                         <dd class="col-sm-6"> | ||||||
|                             {% if user_object.registration.parental_authorization_final %} |                             {% if user_object.registration.parental_authorization_final %} | ||||||
|                                 <a href="{{ user_object.registration.parental_authorization_final.url }}">{% trans "Download" %}</a> |                                 <a href="{{ user_object.registration.parental_authorization_final.url }}">{% trans "Download" %}</a> | ||||||
| @@ -127,38 +127,38 @@ | |||||||
|                     {% endif %} |                     {% endif %} | ||||||
|                 {% endif %} |                 {% endif %} | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "Student class:" %}</dt> |                 <dt class="col-sm-6 text-end">{% trans "Student class:" %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ user_object.registration.get_student_class_display }}</dd> |                 <dd class="col-sm-6">{{ user_object.registration.get_student_class_display }}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "School:" %}</dt> |                 <dt class="col-sm-6 text-end">{% trans "School:" %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ user_object.registration.school }}</dd> |                 <dd class="col-sm-6">{{ user_object.registration.school }}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "Responsible name:" %}</dt> |                 <dt class="col-sm-6 text-end">{% trans "Responsible name:" %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ user_object.registration.responsible_name }}</dd> |                 <dd class="col-sm-6">{{ user_object.registration.responsible_name }}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "Responsible phone number:" %}</dt> |                 <dt class="col-sm-6 text-end">{% trans "Responsible phone number:" %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ user_object.registration.responsible_phone }}</dd> |                 <dd class="col-sm-6">{{ user_object.registration.responsible_phone }}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "Responsible email address:" %}</dt> |                 <dt class="col-sm-6 text-end">{% trans "Responsible email address:" %}</dt> | ||||||
|                 {% with user_object.registration.responsible_email as email %} |                 {% with user_object.registration.responsible_email as email %} | ||||||
|                     <dd class="col-sm-6"><a href="mailto:{{ email }}">{{ email }}</a></dd> |                     <dd class="col-sm-6"><a href="mailto:{{ email }}">{{ email }}</a></dd> | ||||||
|                 {% endwith %} |                 {% endwith %} | ||||||
|             {% elif user_object.registration.coachregistration %} |             {% elif user_object.registration.coachregistration %} | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "Most recent degree:" %}</dt> |                 <dt class="col-sm-6 text-end">{% trans "Most recent degree:" %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ user_object.registration.last_degree }}</dd> |                 <dd class="col-sm-6">{{ user_object.registration.last_degree }}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "Professional activity:" %}</dt> |                 <dt class="col-sm-6 text-end">{% trans "Professional activity:" %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ user_object.registration.professional_activity }}</dd> |                 <dd class="col-sm-6">{{ user_object.registration.professional_activity }}</dd> | ||||||
|  |  | ||||||
|             {% elif user_object.registration.is_volunteer %} |             {% elif user_object.registration.is_volunteer %} | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "Professional activity:" %}</dt> |                 <dt class="col-sm-6 text-end">{% trans "Professional activity:" %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ user_object.registration.professional_activity }}</dd> |                 <dd class="col-sm-6">{{ user_object.registration.professional_activity }}</dd> | ||||||
|  |  | ||||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "Admin:" %}</dt> |                 <dt class="col-sm-6 text-end">{% trans "Admin:" %}</dt> | ||||||
|                 <dd class="col-sm-6">{{ user_object.registration.is_admin|yesno }}</dd> |                 <dd class="col-sm-6">{{ user_object.registration.is_admin|yesno }}</dd> | ||||||
|             {% endif %} |             {% endif %} | ||||||
|  |  | ||||||
|             <dt class="col-sm-6 text-sm-end">{% trans "Grant Animath to contact me in the future about other actions:" %}</dt> |             <dt class="col-sm-6 text-end">{% trans "Grant Animath to contact me in the future about other actions:" %}</dt> | ||||||
|             <dd class="col-sm-6">{{ user_object.registration.give_contact_to_animath|yesno }}</dd> |             <dd class="col-sm-6">{{ user_object.registration.give_contact_to_animath|yesno }}</dd> | ||||||
|         </dl> |         </dl> | ||||||
|  |  | ||||||
| @@ -166,7 +166,7 @@ | |||||||
|             <hr> |             <hr> | ||||||
|             {% for payment in user_object.registration.payments.all %} |             {% for payment in user_object.registration.payments.all %} | ||||||
|                 <dl class="row"> |                 <dl class="row"> | ||||||
|                     <dt class="col-sm-6 text-sm-end"> |                     <dt class="col-sm-6 text-end"> | ||||||
|                         {% if payment.final %} |                         {% if payment.final %} | ||||||
|                             {% trans "Payment information (final):" %} |                             {% trans "Payment information (final):" %} | ||||||
|                         {% else %} |                         {% else %} | ||||||
|   | |||||||
| @@ -5,15 +5,14 @@ Django>=5.0.3,<6.0 | |||||||
| django-crispy-forms~=2.1 | django-crispy-forms~=2.1 | ||||||
| django-extensions~=3.2.3 | django-extensions~=3.2.3 | ||||||
| django-filter~=23.5 | django-filter~=23.5 | ||||||
| git+https://github.com/django-haystack/django-haystack.git#v3.3b2 | elasticsearch~=7.17.9 | ||||||
|  | git+https://github.com/django-haystack/django-haystack.git#v3.3b1 | ||||||
| django-mailer~=2.3.1 | django-mailer~=2.3.1 | ||||||
| django-phonenumber-field~=7.3.0 | django-phonenumber-field~=7.3.0 | ||||||
| django-pipeline~=3.1.0 |  | ||||||
| django-polymorphic~=3.1.0 | django-polymorphic~=3.1.0 | ||||||
| django-tables2~=2.7.0 | django-tables2~=2.7.0 | ||||||
| djangorestframework~=3.14.0 | djangorestframework~=3.14.0 | ||||||
| django-rest-polymorphic~=0.1.10 | django-rest-polymorphic~=0.1.10 | ||||||
| elasticsearch~=7.17.9 |  | ||||||
| gspread~=6.1.0 | gspread~=6.1.0 | ||||||
| gunicorn~=21.2.0 | gunicorn~=21.2.0 | ||||||
| odfpy~=1.4.1 | odfpy~=1.4.1 | ||||||
|   | |||||||
| @@ -8,7 +8,7 @@ | |||||||
| */2     *       *       *       *       cd /code && python manage.py update_index &> /dev/null | */2     *       *       *       *       cd /code && python manage.py update_index &> /dev/null | ||||||
|  |  | ||||||
| # Recreate sympa lists | # Recreate sympa lists | ||||||
| 7       3       *       *      *        cd /code && python manage.py fix_sympa_lists &> /dev/null | */2     *       *       *       *       cd /code && python manage.py fix_sympa_lists &> /dev/null | ||||||
|  |  | ||||||
| # Check payments from Hello Asso | # Check payments from Hello Asso | ||||||
| */6     *       *       *       *       cd /code && python manage.py check_hello_asso &> /dev/null | */6     *       *       *       *       cd /code && python manage.py check_hello_asso &> /dev/null | ||||||
|   | |||||||
| @@ -7,10 +7,10 @@ Django settings for tfjm project. | |||||||
| Generated by 'django-admin startproject' using Django 3.0.5. | Generated by 'django-admin startproject' using Django 3.0.5. | ||||||
|  |  | ||||||
| For more information on this file, see | For more information on this file, see | ||||||
| https://docs.djangoproject.com/en/5.0/topics/settings/ | https://docs.djangoproject.com/en/3.0/topics/settings/ | ||||||
|  |  | ||||||
| For the full list of settings and their values, see | For the full list of settings and their values, see | ||||||
| https://docs.djangoproject.com/en/5.0/ref/settings/ | https://docs.djangoproject.com/en/3.0/ref/settings/ | ||||||
| """ | """ | ||||||
|  |  | ||||||
| import os | import os | ||||||
| @@ -25,7 +25,7 @@ PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) | |||||||
| ADMINS = [("Emmy D'Anello", "emmy.danello@animath.fr")] | ADMINS = [("Emmy D'Anello", "emmy.danello@animath.fr")] | ||||||
|  |  | ||||||
| # Quick-start development settings - unsuitable for production | # Quick-start development settings - unsuitable for production | ||||||
| # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ | # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ | ||||||
|  |  | ||||||
| # SECURITY WARNING: keep the secret key used in production secret! | # SECURITY WARNING: keep the secret key used in production secret! | ||||||
| SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'CHANGE_ME_IN_ENV_SETTINGS') | SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'CHANGE_ME_IN_ENV_SETTINGS') | ||||||
| @@ -63,7 +63,6 @@ INSTALLED_APPS = [ | |||||||
|     'haystack', |     'haystack', | ||||||
|     'logs', |     'logs', | ||||||
|     'phonenumber_field', |     'phonenumber_field', | ||||||
|     'pipeline', |  | ||||||
|     'polymorphic', |     'polymorphic', | ||||||
|     'rest_framework', |     'rest_framework', | ||||||
|     'rest_framework.authtoken', |     'rest_framework.authtoken', | ||||||
| @@ -96,8 +95,6 @@ MIDDLEWARE = [ | |||||||
|     'django.middleware.clickjacking.XFrameOptionsMiddleware', |     'django.middleware.clickjacking.XFrameOptionsMiddleware', | ||||||
|     'django.middleware.locale.LocaleMiddleware', |     'django.middleware.locale.LocaleMiddleware', | ||||||
|     'django.contrib.sites.middleware.CurrentSiteMiddleware', |     'django.contrib.sites.middleware.CurrentSiteMiddleware', | ||||||
|     'django.middleware.gzip.GZipMiddleware', |  | ||||||
|     'pipeline.middleware.MinifyHTMLMiddleware', |  | ||||||
|     'tfjm.middlewares.SessionMiddleware', |     'tfjm.middlewares.SessionMiddleware', | ||||||
|     'tfjm.middlewares.FetchMiddleware', |     'tfjm.middlewares.FetchMiddleware', | ||||||
| ] | ] | ||||||
| @@ -129,7 +126,7 @@ ASGI_APPLICATION = 'tfjm.asgi.application' | |||||||
| WSGI_APPLICATION = 'tfjm.wsgi.application' | WSGI_APPLICATION = 'tfjm.wsgi.application' | ||||||
|  |  | ||||||
| # Password validation | # Password validation | ||||||
| # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators | # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators | ||||||
|  |  | ||||||
| AUTH_PASSWORD_VALIDATORS = [ | AUTH_PASSWORD_VALIDATORS = [ | ||||||
|     { |     { | ||||||
| @@ -164,7 +161,7 @@ REST_FRAMEWORK = { | |||||||
| } | } | ||||||
|  |  | ||||||
| # Internationalization | # Internationalization | ||||||
| # https://docs.djangoproject.com/en/5.0/topics/i18n/ | # https://docs.djangoproject.com/en/3.0/topics/i18n/ | ||||||
|  |  | ||||||
| LANGUAGE_CODE = 'en' | LANGUAGE_CODE = 'en' | ||||||
|  |  | ||||||
| @@ -184,7 +181,7 @@ USE_TZ = True | |||||||
| LOCALE_PATHS = [os.path.join(BASE_DIR, "locale")] | LOCALE_PATHS = [os.path.join(BASE_DIR, "locale")] | ||||||
|  |  | ||||||
| # Static files (CSS, JavaScript, Images) | # Static files (CSS, JavaScript, Images) | ||||||
| # https://docs.djangoproject.com/en/5.0/howto/static-files/ | # https://docs.djangoproject.com/en/3.0/howto/static-files/ | ||||||
|  |  | ||||||
| STATIC_URL = '/static/' | STATIC_URL = '/static/' | ||||||
|  |  | ||||||
| @@ -194,70 +191,6 @@ STATICFILES_DIRS = [ | |||||||
|  |  | ||||||
| STATIC_ROOT = os.path.join(BASE_DIR, "static") | STATIC_ROOT = os.path.join(BASE_DIR, "static") | ||||||
|  |  | ||||||
| STATICFILES_STORAGE = 'pipeline.storage.PipelineStorage' |  | ||||||
|  |  | ||||||
| STATICFILES_FINDERS = ( |  | ||||||
|     'django.contrib.staticfiles.finders.FileSystemFinder', |  | ||||||
|     'django.contrib.staticfiles.finders.AppDirectoriesFinder', |  | ||||||
|     'pipeline.finders.PipelineFinder', |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| PIPELINE = { |  | ||||||
|     'DISABLE_WRAPPER': True, |  | ||||||
|     'JAVASCRIPT': { |  | ||||||
|         'bootstrap': { |  | ||||||
|             'source_filenames': { |  | ||||||
|                 'bootstrap/js/bootstrap.bundle.min.js', |  | ||||||
|             }, |  | ||||||
|             'output_filename': 'tfjm/js/bootstrap.bundle.min.js', |  | ||||||
|         }, |  | ||||||
|         'bootstrap_select': { |  | ||||||
|             'source_filenames': { |  | ||||||
|                 'jquery/jquery.min.js', |  | ||||||
|                 'bootstrap-select/js/bootstrap-select.min.js', |  | ||||||
|                 'bootstrap-select/js/defaults-fr_FR.min.js', |  | ||||||
|             }, |  | ||||||
|             'output_filename': 'tfjm/js/bootstrap-select-jquery.min.js', |  | ||||||
|         }, |  | ||||||
|         'main': { |  | ||||||
|             'source_filenames': ( |  | ||||||
|               'tfjm/js/main.js', |  | ||||||
|               'tfjm/js/theme.js', |  | ||||||
|             ), |  | ||||||
|             'output_filename': 'tfjm/js/main.min.js', |  | ||||||
|         }, |  | ||||||
|         'theme': { |  | ||||||
|             'source_filenames': ( |  | ||||||
|               'tfjm/js/theme.js', |  | ||||||
|             ), |  | ||||||
|             'output_filename': 'tfjm/js/theme.min.js', |  | ||||||
|         }, |  | ||||||
|         'chat': { |  | ||||||
|             'source_filenames': ( |  | ||||||
|               'tfjm/js/chat.js', |  | ||||||
|             ), |  | ||||||
|             'output_filename': 'tfjm/js/chat.min.js', |  | ||||||
|         }, |  | ||||||
|         'draw': { |  | ||||||
|             'source_filenames': ( |  | ||||||
|               'tfjm/js/draw.js', |  | ||||||
|             ), |  | ||||||
|             'output_filename': 'tfjm/js/draw.min.js', |  | ||||||
|         }, |  | ||||||
|     }, |  | ||||||
|     'STYLESHEETS': { |  | ||||||
|         'bootstrap_fontawesome': { |  | ||||||
|             'source_filenames': ( |  | ||||||
|                 'bootstrap/css/bootstrap.min.css', |  | ||||||
|                 'fontawesome/css/all.css', |  | ||||||
|                 'fontawesome/css/v4-shims.css', |  | ||||||
|                 'bootstrap-select/css/bootstrap-select.min.css', |  | ||||||
|             ), |  | ||||||
|             'output_filename': 'tfjm/css/bootstrap_fontawesome.min.css', |  | ||||||
|         } |  | ||||||
|     }, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| MEDIA_URL = '/media/' | MEDIA_URL = '/media/' | ||||||
|  |  | ||||||
| MEDIA_ROOT = os.path.join(BASE_DIR, "media") | MEDIA_ROOT = os.path.join(BASE_DIR, "media") | ||||||
|   | |||||||
| @@ -7,7 +7,7 @@ import os | |||||||
| DEBUG = False | DEBUG = False | ||||||
|  |  | ||||||
| # Mandatory ! | # Mandatory ! | ||||||
| ALLOWED_HOSTS = ['inscription.tfjm.org', 'inscriptions.tfjm.org', 'plateforme.tfjm.org'] | ALLOWED_HOSTS = ['inscription.tfjm.org', 'plateforme.tfjm.org'] | ||||||
|  |  | ||||||
| # Emails | # Emails | ||||||
| EMAIL_BACKEND = 'mailer.backend.DbBackend' | EMAIL_BACKEND = 'mailer.backend.DbBackend' | ||||||
|   | |||||||
| Before Width: | Height: | Size: 428 KiB After Width: | Height: | Size: 428 KiB | 
| Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB | 
| Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB | 
| Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB | 
| Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB | 
| Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB | 
| Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB | 
| @@ -1,6 +1,4 @@ | |||||||
| {% load i18n %} | {% load i18n static %} | ||||||
| {% load pipeline %} |  | ||||||
| {% load static %} |  | ||||||
|  |  | ||||||
| <!DOCTYPE html> | <!DOCTYPE html> | ||||||
| {% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %} | {% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %} | ||||||
| @@ -18,12 +16,19 @@ | |||||||
|     <meta name="theme-color" content="#ffffff"> |     <meta name="theme-color" content="#ffffff"> | ||||||
|  |  | ||||||
|     {# Bootstrap CSS #} |     {# Bootstrap CSS #} | ||||||
|     {% stylesheet 'bootstrap_fontawesome' %} |     <link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}"> | ||||||
|  |     <link rel="stylesheet" href="{% static 'fontawesome/css/all.css' %}"> | ||||||
|  |     <link rel="stylesheet" href="{% static 'fontawesome/css/v4-shims.css' %}"> | ||||||
|  |  | ||||||
|  |     <link rel="stylesheet" href="{% static 'bootstrap-select/css/bootstrap-select.min.css' %}"> | ||||||
|  |  | ||||||
|     {# Bootstrap JavaScript #} |     {# Bootstrap JavaScript #} | ||||||
|     {% javascript 'bootstrap' %} |     <script src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script> | ||||||
|  |  | ||||||
|     {# bootstrap-select for beautiful selects and JQuery dependency #} |     {# bootstrap-select for beautiful selects and JQuery dependency #} | ||||||
|     {% javascript 'bootstrap_select' %} |     <script src="{% static 'jquery/jquery.min.js' %}"></script> | ||||||
|  |     <script src="{% static 'bootstrap-select/js/bootstrap-select.min.js' %}"></script> | ||||||
|  |     <script src="{% static 'bootstrap-select/js/defaults-fr_FR.min.js' %}"></script> | ||||||
|  |  | ||||||
|     {# Si un formulaire requiert des données supplémentaires (notamment JS), les données sont chargées #} |     {# Si un formulaire requiert des données supplémentaires (notamment JS), les données sont chargées #} | ||||||
|     {% if form.media %} |     {% if form.media %} | ||||||
| @@ -82,7 +87,8 @@ | |||||||
|     {% include "base_modal.html" with modal_id="login" %} |     {% include "base_modal.html" with modal_id="login" %} | ||||||
| {% endif %} | {% endif %} | ||||||
|  |  | ||||||
| {% javascript 'main' %} | <script src="{% static 'main.js' %}"></script> | ||||||
|  | <script src="{% static 'theme.js' %}"></script> | ||||||
|  |  | ||||||
| <script> | <script> | ||||||
|     CSRF_TOKEN = "{{ csrf_token }}"; |     CSRF_TOKEN = "{{ csrf_token }}"; | ||||||
|   | |||||||
| @@ -41,7 +41,7 @@ | |||||||
|                         <i class="fab fa-gitlab"></i> |                         <i class="fab fa-gitlab"></i> | ||||||
|                     </a> |                     </a> | ||||||
|             </div> |             </div> | ||||||
|             <div class="col-sm-1 text-sm-end"> |             <div class="col-sm-1 text-end"> | ||||||
|                 <a href="#" class="text-muted"> |                 <a href="#" class="text-muted"> | ||||||
|                     <i class="fa fa-arrow-up" aria-hidden="true"></i> |                     <i class="fa fa-arrow-up" aria-hidden="true"></i> | ||||||
|                 </a> |                 </a> | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ | |||||||
|                 Ton équipe est déjà formée ? |                 Ton équipe est déjà formée ? | ||||||
|             </h3> |             </h3> | ||||||
|         </div> |         </div> | ||||||
|         <div class="col-sm text-sm-end"> |         <div class="col-sm text-end"> | ||||||
|             <div class="btn-group-vertical"> |             <div class="btn-group-vertical"> | ||||||
|                 <a class="btn btn-primary btn-lg" href="{% url "registration:signup" %}" role="button">Inscris-toi maintenant !</a> |                 <a class="btn btn-primary btn-lg" href="{% url "registration:signup" %}" role="button">Inscris-toi maintenant !</a> | ||||||
|                 <a class="btn btn-light text-dark btn-lg" href="{% url "login" %}" role="button">J'ai déjà un compte</a> |                 <a class="btn btn-light text-dark btn-lg" href="{% url "login" %}" role="button">J'ai déjà un compte</a> | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ | |||||||
| <nav class="navbar navbar-expand-lg fixed-navbar shadow-sm"> | <nav class="navbar navbar-expand-lg fixed-navbar shadow-sm"> | ||||||
|     <div class="container-fluid"> |     <div class="container-fluid"> | ||||||
|     <a class="navbar-brand" href="https://tfjm.org/"> |     <a class="navbar-brand" href="https://tfjm.org/"> | ||||||
|         <img src="{% static "tfjm/img/tfjm.svg" %}" style="height: 2em;" alt="Logo TFJM²" id="navbar-logo"> |         <img src="{% static "tfjm.svg" %}" style="height: 2em;" alt="Logo TFJM²" id="navbar-logo"> | ||||||
|     </a> |     </a> | ||||||
|     <button class="navbar-toggler" type="button" data-bs-toggle="collapse" |     <button class="navbar-toggler" type="button" data-bs-toggle="collapse" | ||||||
|             data-bs-target="#navbarNavDropdown" |             data-bs-target="#navbarNavDropdown" | ||||||
|   | |||||||