Compare commits
47 Commits
a594b268ea
...
docs
Author | SHA1 | Date | |
---|---|---|---|
5e2add90a8
|
|||
635606eb13
|
|||
b828631106
|
|||
8216e0943f
|
|||
1138885fb4
|
|||
a43dc9c12a
|
|||
70050827d8
|
|||
f687deed14
|
|||
7a0341e7cf
|
|||
0129e32643
|
|||
64a2ea007e
|
|||
531eecf4b8
|
|||
bd416318ac
|
|||
90bec6bf5e
|
|||
ed5944e044
|
|||
a41c17576f
|
|||
80456f4da8
|
|||
1a641cb2d7
|
|||
8f3929875f
|
|||
f26f102650
|
|||
1e5d0ebcfc
|
|||
0cab21f344
|
|||
a771710094
|
|||
3b3dcff28b
|
|||
d6aa5eb0cc
|
|||
c6b9a84def
|
|||
675f19492c
|
|||
a5c210e9b6
|
|||
784002c085
|
|||
e77cc558de
|
|||
7bb0f78f34
|
|||
bfd1a76a2d
|
|||
b86dfe7351
|
|||
d36e97fa2e
|
|||
181bb86e49
|
|||
a121d1042b
|
|||
2d706b2b81
|
|||
ca91842c2d
|
|||
d617dd77c1
|
|||
d59bb75dce
|
|||
4a78e80399
|
|||
f3a4a99b78
|
|||
46fc5f39c8
|
|||
b464e7df1d
|
|||
7498677bbd
|
|||
ea8007aa07
|
|||
d9bb0a0860
|
@ -3,10 +3,12 @@ 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 postgresql-dev libmagic texlive texmf-dist-latexextra
|
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 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
|
||||||
|
2
chat/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Copyright (C) 2024 by Animath
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
28
chat/admin.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Copyright (C) 2024 by Animath
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Channel, Message
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Channel)
|
||||||
|
class ChannelAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Modèle d'administration des canaux de chat.
|
||||||
|
"""
|
||||||
|
list_display = ('name', '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',)
|
||||||
|
autocomplete_fields = ('tournament', 'pool', 'team', 'invited', )
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Message)
|
||||||
|
class MessageAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Modèle d'administration des messages de chat.
|
||||||
|
"""
|
||||||
|
list_display = ('channel', 'author', 'created_at', 'updated_at', 'content',)
|
||||||
|
list_filter = ('channel', 'created_at', 'updated_at',)
|
||||||
|
search_fields = ('author__username', 'author__first_name', 'author__last_name', 'content',)
|
||||||
|
autocomplete_fields = ('channel', 'author', 'users_read',)
|
16
chat/apps.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Copyright (C) 2024 by Animath
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from django.apps import AppConfig
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
|
||||||
|
|
||||||
|
class ChatConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "chat"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
from chat import signals
|
||||||
|
post_save.connect(signals.create_tournament_channels, "participation.Tournament")
|
||||||
|
post_save.connect(signals.create_pool_channels, "participation.Pool")
|
||||||
|
post_save.connect(signals.create_team_channel, "participation.Participation")
|
370
chat/consumers.py
Normal file
@ -0,0 +1,370 @@
|
|||||||
|
# Copyright (C) 2024 by Animath
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db.models import Count, F, Q
|
||||||
|
from registration.models import Registration
|
||||||
|
|
||||||
|
from .models import Channel, Message
|
||||||
|
|
||||||
|
|
||||||
|
class ChatConsumer(AsyncJsonWebsocketConsumer):
|
||||||
|
"""
|
||||||
|
Ce consommateur gère les connexions WebSocket pour le chat.
|
||||||
|
"""
|
||||||
|
async def connect(self) -> None:
|
||||||
|
"""
|
||||||
|
Cette fonction est appelée lorsqu'un nouveau websocket tente de se connecter au serveur.
|
||||||
|
On n'accept que si c'est un⋅e utilisateur⋅rice connecté⋅e.
|
||||||
|
"""
|
||||||
|
if '_fake_user_id' in self.scope['session']:
|
||||||
|
# Dans le cas d'une impersonification, on charge l'utilisateur⋅rice concerné
|
||||||
|
self.scope['user'] = await User.objects.aget(pk=self.scope['session']['_fake_user_id'])
|
||||||
|
|
||||||
|
# Récupération de l'utilisateur⋅rice courant⋅e
|
||||||
|
user = self.scope['user']
|
||||||
|
if user.is_anonymous:
|
||||||
|
# L'utilisateur⋅rice n'est pas connecté⋅e
|
||||||
|
await self.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
reg = await Registration.objects.aget(user_id=user.id)
|
||||||
|
self.registration = reg
|
||||||
|
|
||||||
|
# Acceptation de la connexion
|
||||||
|
await self.accept()
|
||||||
|
|
||||||
|
# Récupération des canaux accessibles en lecture et/ou en écriture
|
||||||
|
self.read_channels = await Channel.get_accessible_channels(user, 'read')
|
||||||
|
self.write_channels = await Channel.get_accessible_channels(user, 'write')
|
||||||
|
|
||||||
|
# Abonnement aux canaux de diffusion Websocket pour les différents canaux de chat
|
||||||
|
async for channel in self.read_channels.all():
|
||||||
|
await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name)
|
||||||
|
# Abonnement à un canal de diffusion Websocket personnel, utile pour s'adresser à une unique personne
|
||||||
|
await self.channel_layer.group_add(f"user-{user.id}", self.channel_name)
|
||||||
|
|
||||||
|
async def disconnect(self, close_code: int) -> None:
|
||||||
|
"""
|
||||||
|
Cette fonction est appelée lorsqu'un websocket se déconnecte du serveur.
|
||||||
|
:param close_code: Le code d'erreur.
|
||||||
|
"""
|
||||||
|
if self.scope['user'].is_anonymous:
|
||||||
|
# L'utilisateur⋅rice n'était pas connecté⋅e, on ne fait rien
|
||||||
|
return
|
||||||
|
|
||||||
|
async for channel in self.read_channels.all():
|
||||||
|
# Désabonnement des canaux de diffusion Websocket liés aux canaux de chat
|
||||||
|
await self.channel_layer.group_discard(f"chat-{channel.id}", self.channel_name)
|
||||||
|
# Désabonnement du canal de diffusion Websocket personnel
|
||||||
|
await self.channel_layer.group_discard(f"user-{self.scope['user'].id}", self.channel_name)
|
||||||
|
|
||||||
|
async def receive_json(self, content: dict, **kwargs) -> None:
|
||||||
|
"""
|
||||||
|
Appelée lorsque le client nous envoie des données, décodées depuis du JSON.
|
||||||
|
:param content: Les données envoyées par le client, décodées depuis du JSON. Doit contenir un champ 'type'.
|
||||||
|
"""
|
||||||
|
match content['type']:
|
||||||
|
case 'fetch_channels':
|
||||||
|
# Demande de récupération des canaux disponibles
|
||||||
|
await self.fetch_channels()
|
||||||
|
case 'send_message':
|
||||||
|
# Envoi d'un message dans un canal
|
||||||
|
await self.receive_message(**content)
|
||||||
|
case 'edit_message':
|
||||||
|
# Modification d'un message
|
||||||
|
await self.edit_message(**content)
|
||||||
|
case 'delete_message':
|
||||||
|
# Suppression d'un message
|
||||||
|
await self.delete_message(**content)
|
||||||
|
case 'fetch_messages':
|
||||||
|
# Récupération des messages d'un canal (ou d'une partie)
|
||||||
|
await self.fetch_messages(**content)
|
||||||
|
case 'mark_read':
|
||||||
|
# Marquage de messages comme lus
|
||||||
|
await self.mark_read(**content)
|
||||||
|
case 'start_private_chat':
|
||||||
|
# Démarrage d'une conversation privée avec un⋅e autre utilisateur⋅rice
|
||||||
|
await self.start_private_chat(**content)
|
||||||
|
case unknown:
|
||||||
|
# Type inconnu, on soulève une erreur
|
||||||
|
raise ValueError(f"Unknown message type: {unknown}")
|
||||||
|
|
||||||
|
async def fetch_channels(self) -> None:
|
||||||
|
"""
|
||||||
|
L'utilisateur⋅rice demande à récupérer la liste des canaux disponibles.
|
||||||
|
On lui renvoie alors la liste des canaux qui lui sont accessibles en lecture,
|
||||||
|
en fournissant nom, catégorie, permission de lecture et nombre de messages non lus.
|
||||||
|
"""
|
||||||
|
user = self.scope['user']
|
||||||
|
|
||||||
|
# Récupération des canaux accessibles en lecture, avec le nombre de messages non lus
|
||||||
|
channels = self.read_channels.prefetch_related('invited') \
|
||||||
|
.annotate(total_messages=Count('messages', distinct=True)) \
|
||||||
|
.annotate(read_messages=Count('messages', filter=Q(messages__users_read=user), distinct=True)) \
|
||||||
|
.annotate(unread_messages=F('total_messages') - F('read_messages')).all()
|
||||||
|
|
||||||
|
# Envoi de la liste des canaux
|
||||||
|
message = {
|
||||||
|
'type': 'fetch_channels',
|
||||||
|
'channels': [
|
||||||
|
{
|
||||||
|
'id': channel.id,
|
||||||
|
'name': channel.get_visible_name(user),
|
||||||
|
'category': channel.category,
|
||||||
|
'read_access': True,
|
||||||
|
'write_access': await self.write_channels.acontains(channel),
|
||||||
|
'unread_messages': channel.unread_messages,
|
||||||
|
}
|
||||||
|
async for channel in channels
|
||||||
|
]
|
||||||
|
}
|
||||||
|
await self.send_json(message)
|
||||||
|
|
||||||
|
async def receive_message(self, channel_id: int, content: str, **kwargs) -> None:
|
||||||
|
"""
|
||||||
|
L'utilisateur⋅ice a envoyé un message dans un canal.
|
||||||
|
On vérifie d'abord la permission d'écriture, puis on crée le message et on l'envoie à tou⋅tes les
|
||||||
|
utilisateur⋅ices abonné⋅es au canal.
|
||||||
|
|
||||||
|
:param channel_id: Identifiant du canal où envoyer le message.
|
||||||
|
:param content: Contenu du message.
|
||||||
|
"""
|
||||||
|
user = self.scope['user']
|
||||||
|
|
||||||
|
# Récupération du canal
|
||||||
|
channel = await Channel.objects.prefetch_related('tournament__pools__juries', 'pool', 'team', 'invited') \
|
||||||
|
.aget(id=channel_id)
|
||||||
|
if not await self.write_channels.acontains(channel):
|
||||||
|
# L'utilisateur⋅ice n'a pas la permission d'écrire dans ce canal, on abandonne
|
||||||
|
return
|
||||||
|
|
||||||
|
# Création du message
|
||||||
|
message = await Message.objects.acreate(
|
||||||
|
author=user,
|
||||||
|
channel=channel,
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Envoi du message à toutes les personnes connectées sur le canal
|
||||||
|
await self.channel_layer.group_send(f'chat-{channel.id}', {
|
||||||
|
'type': 'chat.send_message',
|
||||||
|
'id': message.id,
|
||||||
|
'channel_id': channel.id,
|
||||||
|
'timestamp': message.created_at.isoformat(),
|
||||||
|
'author_id': message.author_id,
|
||||||
|
'author': await message.aget_author_name(),
|
||||||
|
'content': message.content,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def edit_message(self, message_id: int, content: str, **kwargs) -> None:
|
||||||
|
"""
|
||||||
|
L'utilisateur⋅ice a modifié un message.
|
||||||
|
On vérifie d'abord que l'utilisateur⋅ice a le droit de modifier le message, puis on modifie le message
|
||||||
|
et on envoie la modification à tou⋅tes les utilisateur⋅ices abonné⋅es au canal.
|
||||||
|
|
||||||
|
:param message_id: Identifiant du message à modifier.
|
||||||
|
:param content: Nouveau contenu du message.
|
||||||
|
"""
|
||||||
|
user = self.scope['user']
|
||||||
|
|
||||||
|
# Récupération du message
|
||||||
|
message = await Message.objects.aget(id=message_id)
|
||||||
|
if user.id != message.author_id and not user.is_superuser:
|
||||||
|
# Seul⋅e l'auteur⋅ice du message ou un⋅e admin peut modifier un message
|
||||||
|
return
|
||||||
|
|
||||||
|
# Modification du contenu du message
|
||||||
|
message.content = content
|
||||||
|
await message.asave()
|
||||||
|
|
||||||
|
# Envoi de la modification à tou⋅tes les personnes connectées sur le canal
|
||||||
|
await self.channel_layer.group_send(f'chat-{message.channel_id}', {
|
||||||
|
'type': 'chat.edit_message',
|
||||||
|
'id': message_id,
|
||||||
|
'channel_id': message.channel_id,
|
||||||
|
'content': content,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def delete_message(self, message_id: int, **kwargs) -> None:
|
||||||
|
"""
|
||||||
|
L'utilisateur⋅ice a supprimé un message.
|
||||||
|
On vérifie d'abord que l'utilisateur⋅ice a le droit de supprimer le message, puis on supprime le message
|
||||||
|
et on envoie la suppression à tou⋅tes les utilisateur⋅ices abonné⋅es au canal.
|
||||||
|
|
||||||
|
:param message_id: Identifiant du message à supprimer.
|
||||||
|
"""
|
||||||
|
user = self.scope['user']
|
||||||
|
|
||||||
|
# Récupération du message
|
||||||
|
message = await Message.objects.aget(id=message_id)
|
||||||
|
if user.id != message.author_id and not user.is_superuser:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Suppression effective du message
|
||||||
|
await message.adelete()
|
||||||
|
|
||||||
|
# Envoi de la suppression à tou⋅tes les personnes connectées sur le canal
|
||||||
|
await self.channel_layer.group_send(f'chat-{message.channel_id}', {
|
||||||
|
'type': 'chat.delete_message',
|
||||||
|
'id': message_id,
|
||||||
|
'channel_id': message.channel_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def fetch_messages(self, channel_id: int, offset: int = 0, limit: int = 50, **_kwargs) -> None:
|
||||||
|
"""
|
||||||
|
L'utilisateur⋅ice demande à récupérer les messages d'un canal.
|
||||||
|
On vérifie la permission de lecture, puis on renvoie les messages demandés.
|
||||||
|
|
||||||
|
:param channel_id: Identifiant du canal où récupérer les messages.
|
||||||
|
:param offset: Décalage pour la pagination, à partir du dernier message.
|
||||||
|
Par défaut : 0, on commence au dernier message.
|
||||||
|
:param limit: Nombre de messages à récupérer. Par défaut, on récupère 50 messages.
|
||||||
|
"""
|
||||||
|
# Récupération du canal
|
||||||
|
channel = await Channel.objects.aget(id=channel_id)
|
||||||
|
if not await self.read_channels.acontains(channel):
|
||||||
|
# L'utilisateur⋅rice n'a pas la permission de lire ce canal, on abandonne
|
||||||
|
return
|
||||||
|
|
||||||
|
limit = min(limit, 200) # On limite le nombre de messages à 200 maximum
|
||||||
|
|
||||||
|
# Récupération des messages, avec un indicateur de lecture pour l'utilisateur⋅ice courant⋅e
|
||||||
|
messages = Message.objects \
|
||||||
|
.filter(channel=channel) \
|
||||||
|
.annotate(read=Count('users_read', filter=Q(users_read=self.scope['user']))) \
|
||||||
|
.order_by('-created_at')[offset:offset + limit].all()
|
||||||
|
|
||||||
|
# Envoi de la liste des messages, en les renvoyant dans l'ordre chronologique
|
||||||
|
await self.send_json({
|
||||||
|
'type': 'fetch_messages',
|
||||||
|
'channel_id': channel_id,
|
||||||
|
'messages': list(reversed([
|
||||||
|
{
|
||||||
|
'id': message.id,
|
||||||
|
'timestamp': message.created_at.isoformat(),
|
||||||
|
'author_id': message.author_id,
|
||||||
|
'author': await message.aget_author_name(),
|
||||||
|
'content': message.content,
|
||||||
|
'read': message.read > 0,
|
||||||
|
}
|
||||||
|
async for message in messages
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
|
||||||
|
async def mark_read(self, message_ids: list[int], **_kwargs) -> None:
|
||||||
|
"""
|
||||||
|
L'utilisateur⋅ice marque des messages comme lus, après les avoir affichés à l'écran.
|
||||||
|
|
||||||
|
:param message_ids: Liste des identifiants des messages qu'il faut marquer comme lus.
|
||||||
|
"""
|
||||||
|
# Récupération des messages à marquer comme lus
|
||||||
|
messages = Message.objects.filter(id__in=message_ids)
|
||||||
|
async for message in messages.all():
|
||||||
|
# Ajout de l'utilisateur⋅ice courant⋅e à la liste des personnes ayant lu le message
|
||||||
|
await message.users_read.aadd(self.scope['user'])
|
||||||
|
|
||||||
|
# Actualisation du nombre de messages non lus par canal
|
||||||
|
unread_messages_by_channel = Message.objects.exclude(users_read=self.scope['user']).values('channel_id') \
|
||||||
|
.annotate(unread_messages=Count('channel_id'))
|
||||||
|
|
||||||
|
# Envoi des identifiants des messages non lus et du nombre de messages non lus par canal, actualisés
|
||||||
|
await self.send_json({
|
||||||
|
'type': 'mark_read',
|
||||||
|
'messages': [{'id': message.id, 'channel_id': message.channel_id} async for message in messages.all()],
|
||||||
|
'unread_messages': {group['channel_id']: group['unread_messages']
|
||||||
|
async for group in unread_messages_by_channel.all()},
|
||||||
|
})
|
||||||
|
|
||||||
|
async def start_private_chat(self, user_id: int, **kwargs) -> None:
|
||||||
|
"""
|
||||||
|
L'utilisateur⋅ice souhaite démarrer une conversation privée avec un⋅e autre utilisateur⋅ice.
|
||||||
|
Pour cela, on récupère le salon privé s'il existe, sinon on en crée un.
|
||||||
|
Dans le cas d'une création, les deux personnes sont transférées immédiatement dans ce nouveau canal.
|
||||||
|
|
||||||
|
:param user_id: L'utilisateur⋅rice avec qui démarrer la conversation privée.
|
||||||
|
"""
|
||||||
|
user = self.scope['user']
|
||||||
|
# Récupération de l'autre utilisateur⋅ice avec qui démarrer la conversation
|
||||||
|
other_user = await User.objects.aget(id=user_id)
|
||||||
|
|
||||||
|
# Vérification de l'existence d'un salon privé entre les deux personnes
|
||||||
|
channel_qs = Channel.objects.filter(private=True).filter(invited=user).filter(invited=other_user)
|
||||||
|
if not await channel_qs.aexists():
|
||||||
|
# Le salon privé n'existe pas, on le crée alors
|
||||||
|
channel = await Channel.objects.acreate(
|
||||||
|
name=f"{user.first_name} {user.last_name}, {other_user.first_name} {other_user.last_name}",
|
||||||
|
category=Channel.ChannelCategory.PRIVATE,
|
||||||
|
private=True,
|
||||||
|
)
|
||||||
|
await channel.invited.aset([user, other_user])
|
||||||
|
|
||||||
|
# On s'ajoute au salon privé
|
||||||
|
await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name)
|
||||||
|
|
||||||
|
if user != other_user:
|
||||||
|
# On transfère l'autre utilisateur⋅ice dans le salon privé
|
||||||
|
await self.channel_layer.group_send(f"user-{other_user.id}", {
|
||||||
|
'type': 'chat.start_private_chat',
|
||||||
|
'channel': {
|
||||||
|
'id': channel.id,
|
||||||
|
'name': f"{user.first_name} {user.last_name}",
|
||||||
|
'category': channel.category,
|
||||||
|
'read_access': True,
|
||||||
|
'write_access': True,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Récupération dudit salon privé
|
||||||
|
channel = await channel_qs.afirst()
|
||||||
|
|
||||||
|
# Invitation de l'autre utilisateur⋅rice à rejoindre le salon privé
|
||||||
|
await self.channel_layer.group_send(f"user-{user.id}", {
|
||||||
|
'type': 'chat.start_private_chat',
|
||||||
|
'channel': {
|
||||||
|
'id': channel.id,
|
||||||
|
'name': f"{other_user.first_name} {other_user.last_name}",
|
||||||
|
'category': channel.category,
|
||||||
|
'read_access': True,
|
||||||
|
'write_access': True,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async def chat_send_message(self, message) -> None:
|
||||||
|
"""
|
||||||
|
Envoi d'un message à tou⋅tes les personnes connectées sur un canal.
|
||||||
|
:param message: Dictionnaire contenant les informations du message à envoyer,
|
||||||
|
contenant l'identifiant du message "id", l'identifiant du canal "channel_id",
|
||||||
|
l'heure de création "timestamp", l'identifiant de l'auteur "author_id",
|
||||||
|
le nom de l'auteur "author" et le contenu du message "content".
|
||||||
|
"""
|
||||||
|
await self.send_json({'type': 'send_message', 'id': message['id'], 'channel_id': message['channel_id'],
|
||||||
|
'timestamp': message['timestamp'], 'author': message['author'],
|
||||||
|
'content': message['content']})
|
||||||
|
|
||||||
|
async def chat_edit_message(self, message) -> None:
|
||||||
|
"""
|
||||||
|
Envoi d'une modification de message à tou⋅tes les personnes connectées sur un canal.
|
||||||
|
:param message: Dictionnaire contenant les informations du message à modifier,
|
||||||
|
contenant l'identifiant du message "id", l'identifiant du canal "channel_id"
|
||||||
|
et le nouveau contenu "content".
|
||||||
|
"""
|
||||||
|
await self.send_json({'type': 'edit_message', 'id': message['id'], 'channel_id': message['channel_id'],
|
||||||
|
'content': message['content']})
|
||||||
|
|
||||||
|
async def chat_delete_message(self, message) -> None:
|
||||||
|
"""
|
||||||
|
Envoi d'une suppression de message à tou⋅tes les personnes connectées sur un canal.
|
||||||
|
:param message: Dictionnaire contenant les informations du message à supprimer,
|
||||||
|
contenant l'identifiant du message "id" et l'identifiant du canal "channel_id".
|
||||||
|
"""
|
||||||
|
await self.send_json({'type': 'delete_message', 'id': message['id'], 'channel_id': message['channel_id']})
|
||||||
|
|
||||||
|
async def chat_start_private_chat(self, message) -> None:
|
||||||
|
"""
|
||||||
|
Envoi d'un message pour démarrer une conversation privée à une personne connectée.
|
||||||
|
:param message: Dictionnaire contenant les informations du nouveau canal privé.
|
||||||
|
"""
|
||||||
|
await self.channel_layer.group_add(f"chat-{message['channel']['id']}", self.channel_name)
|
||||||
|
await self.send_json({'type': 'start_private_chat', 'channel': message['channel']})
|
0
chat/management/__init__.py
Normal file
0
chat/management/commands/__init__.py
Normal file
166
chat/management/commands/create_chat_channels.py
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
# Copyright (C) 2024 by Animath
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from django.core.management import BaseCommand
|
||||||
|
from django.utils.translation import activate
|
||||||
|
from participation.models import Team, Tournament
|
||||||
|
from tfjm.permissions import PermissionType
|
||||||
|
|
||||||
|
from ...models import Channel
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
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(
|
||||||
|
name="Annonces",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.GENERAL,
|
||||||
|
read_access=PermissionType.AUTHENTICATED,
|
||||||
|
write_access=PermissionType.ADMIN,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Un canal d'aide pour les bénévoles est dédié.
|
||||||
|
Channel.objects.update_or_create(
|
||||||
|
name="Aide jurys et orgas",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.GENERAL,
|
||||||
|
read_access=PermissionType.VOLUNTEER,
|
||||||
|
write_access=PermissionType.VOLUNTEER,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Un canal de discussion générale en lien avec le tournoi est accessible librement.
|
||||||
|
Channel.objects.update_or_create(
|
||||||
|
name="Général",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.GENERAL,
|
||||||
|
read_access=PermissionType.AUTHENTICATED,
|
||||||
|
write_access=PermissionType.AUTHENTICATED,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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(
|
||||||
|
name="Je cherche une équipe",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.GENERAL,
|
||||||
|
read_access=PermissionType.AUTHENTICATED,
|
||||||
|
write_access=PermissionType.AUTHENTICATED,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Un canal de discussion libre est accessible pour tous⋅tes.
|
||||||
|
Channel.objects.update_or_create(
|
||||||
|
name="Détente",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.GENERAL,
|
||||||
|
read_access=PermissionType.AUTHENTICATED,
|
||||||
|
write_access=PermissionType.AUTHENTICATED,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
name=f"{tournament.name} - Annonces",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.TOURNAMENT,
|
||||||
|
read_access=PermissionType.TOURNAMENT_MEMBER,
|
||||||
|
write_access=PermissionType.TOURNAMENT_ORGANIZER,
|
||||||
|
tournament=tournament,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Channel.objects.update_or_create(
|
||||||
|
name=f"{tournament.name} - Général",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.TOURNAMENT,
|
||||||
|
read_access=PermissionType.TOURNAMENT_MEMBER,
|
||||||
|
write_access=PermissionType.TOURNAMENT_MEMBER,
|
||||||
|
tournament=tournament,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Channel.objects.update_or_create(
|
||||||
|
name=f"{tournament.name} - Détente",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.TOURNAMENT,
|
||||||
|
read_access=PermissionType.TOURNAMENT_MEMBER,
|
||||||
|
write_access=PermissionType.TOURNAMENT_MEMBER,
|
||||||
|
tournament=tournament,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Un canal réservé à tous⋅tes les juré⋅es du tournoi est créé.
|
||||||
|
Channel.objects.update_or_create(
|
||||||
|
name=f"{tournament.name} - Juré⋅es",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.TOURNAMENT,
|
||||||
|
read_access=PermissionType.JURY_MEMBER,
|
||||||
|
write_access=PermissionType.JURY_MEMBER,
|
||||||
|
tournament=tournament,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
name=f"{tournament.name} - Président⋅es de jury",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.TOURNAMENT,
|
||||||
|
read_access=PermissionType.TOURNAMENT_JURY_PRESIDENT,
|
||||||
|
write_access=PermissionType.TOURNAMENT_JURY_PRESIDENT,
|
||||||
|
tournament=tournament,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
name=f"{tournament.name} - Poule {pool.short_name}",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.TOURNAMENT,
|
||||||
|
read_access=PermissionType.POOL_MEMBER,
|
||||||
|
write_access=PermissionType.POOL_MEMBER,
|
||||||
|
pool=pool,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Channel.objects.update_or_create(
|
||||||
|
name=f"{tournament.name} - Poule {pool.short_name} - Jury",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.TOURNAMENT,
|
||||||
|
read_access=PermissionType.JURY_MEMBER,
|
||||||
|
write_access=PermissionType.JURY_MEMBER,
|
||||||
|
pool=pool,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
name=f"Équipe {team.trigram}",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.TEAM,
|
||||||
|
read_access=PermissionType.TEAM_MEMBER,
|
||||||
|
write_access=PermissionType.TEAM_MEMBER,
|
||||||
|
team=team,
|
||||||
|
),
|
||||||
|
)
|
200
chat/migrations/0001_initial.py
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
# Generated by Django 5.0.3 on 2024-04-27 07:00
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("participation", "0013_alter_pool_options_pool_room"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Channel",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=255, verbose_name="name")),
|
||||||
|
(
|
||||||
|
"read_access",
|
||||||
|
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"),
|
||||||
|
],
|
||||||
|
max_length=16,
|
||||||
|
verbose_name="read permission",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"write_access",
|
||||||
|
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"),
|
||||||
|
],
|
||||||
|
max_length=16,
|
||||||
|
verbose_name="write permission",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"private",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="If checked, only users who have been explicitly added to the channel will be able to access it.",
|
||||||
|
verbose_name="private",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"invited",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Extra users who have been invited to the channel, in addition to the permitted group of the channel.",
|
||||||
|
related_name="+",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="invited users",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"pool",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
help_text="For a permission that concerns a pool, indicates what is the concerned pool.",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="chat_channels",
|
||||||
|
to="participation.pool",
|
||||||
|
verbose_name="pool",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"team",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
help_text="For a permission that concerns a team, indicates what is the concerned team.",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="chat_channels",
|
||||||
|
to="participation.team",
|
||||||
|
verbose_name="team",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"tournament",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
help_text="For a permission that concerns a tournament, indicates what is the concerned tournament.",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="chat_channels",
|
||||||
|
to="participation.tournament",
|
||||||
|
verbose_name="tournament",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "channel",
|
||||||
|
"verbose_name_plural": "channels",
|
||||||
|
"ordering": ("name",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Message",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(auto_now=True, verbose_name="updated at"),
|
||||||
|
),
|
||||||
|
("content", models.TextField(verbose_name="content")),
|
||||||
|
(
|
||||||
|
"author",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="chat_messages",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="author",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"channel",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="messages",
|
||||||
|
to="chat.channel",
|
||||||
|
verbose_name="channel",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "message",
|
||||||
|
"verbose_name_plural": "messages",
|
||||||
|
"ordering": ("created_at",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 5.0.3 on 2024-04-28 11:08
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("chat", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="channel",
|
||||||
|
options={
|
||||||
|
"ordering": ("category", "name"),
|
||||||
|
"verbose_name": "channel",
|
||||||
|
"verbose_name_plural": "channels",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="channel",
|
||||||
|
name="category",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("general", "General channels"),
|
||||||
|
("tournament", "Tournament channels"),
|
||||||
|
("team", "Team channels"),
|
||||||
|
("private", "Private channels"),
|
||||||
|
],
|
||||||
|
default="general",
|
||||||
|
max_length=255,
|
||||||
|
verbose_name="category",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
26
chat/migrations/0003_message_users_read.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -0,0 +1,94 @@
|
|||||||
|
# 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
2
chat/migrations/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Copyright (C) 2024 by Animath
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
365
chat/models.py
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
# Copyright (C) 2024 by Animath
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import Q, QuerySet
|
||||||
|
from django.utils.text import format_lazy
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from participation.models import Pool, Team, Tournament
|
||||||
|
from registration.models import ParticipantRegistration, Registration, VolunteerRegistration
|
||||||
|
from tfjm.permissions import PermissionType
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
GENERAL = 'general', _("General channels")
|
||||||
|
TOURNAMENT = 'tournament', _("Tournament channels")
|
||||||
|
TEAM = 'team', _("Team channels")
|
||||||
|
PRIVATE = 'private', _("Private channels")
|
||||||
|
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_("name"),
|
||||||
|
help_text=_("Visible name of the channel."),
|
||||||
|
)
|
||||||
|
|
||||||
|
category = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_("category"),
|
||||||
|
choices=ChannelCategory,
|
||||||
|
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(
|
||||||
|
max_length=16,
|
||||||
|
verbose_name=_("read permission"),
|
||||||
|
choices=PermissionType,
|
||||||
|
help_text=_("Permission type that is required to read the messages of the channels."),
|
||||||
|
)
|
||||||
|
|
||||||
|
write_access = models.CharField(
|
||||||
|
max_length=16,
|
||||||
|
verbose_name=_("write permission"),
|
||||||
|
choices=PermissionType,
|
||||||
|
help_text=_("Permission type that is required to write a message to a channel."),
|
||||||
|
)
|
||||||
|
|
||||||
|
tournament = models.ForeignKey(
|
||||||
|
'participation.Tournament',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
default=None,
|
||||||
|
verbose_name=_("tournament"),
|
||||||
|
related_name='chat_channels',
|
||||||
|
help_text=_("For a permission that concerns a tournament, indicates what is the concerned tournament."),
|
||||||
|
)
|
||||||
|
|
||||||
|
pool = models.ForeignKey(
|
||||||
|
'participation.Pool',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
default=None,
|
||||||
|
verbose_name=_("pool"),
|
||||||
|
related_name='chat_channels',
|
||||||
|
help_text=_("For a permission that concerns a pool, indicates what is the concerned pool."),
|
||||||
|
)
|
||||||
|
|
||||||
|
team = models.ForeignKey(
|
||||||
|
'participation.Team',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
default=None,
|
||||||
|
verbose_name=_("team"),
|
||||||
|
related_name='chat_channels',
|
||||||
|
help_text=_("For a permission that concerns a team, indicates what is the concerned team."),
|
||||||
|
)
|
||||||
|
|
||||||
|
private = models.BooleanField(
|
||||||
|
verbose_name=_("private"),
|
||||||
|
default=False,
|
||||||
|
help_text=_("If checked, only users who have been explicitly added to the channel will be able to access it."),
|
||||||
|
)
|
||||||
|
|
||||||
|
invited = models.ManyToManyField(
|
||||||
|
'auth.User',
|
||||||
|
verbose_name=_("invited users"),
|
||||||
|
related_name='+',
|
||||||
|
blank=True,
|
||||||
|
help_text=_("Extra users who have been invited to the channel, "
|
||||||
|
"in addition to the permitted group of the channel."),
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
# 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] \
|
||||||
|
or [f"{user.first_name} {user.last_name}"]
|
||||||
|
return ", ".join(users)
|
||||||
|
# Le canal est public, on renvoie directement le nom
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(format_lazy(_("Channel {name}"), name=self.name))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
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'
|
||||||
|
|
||||||
|
qs = Channel.objects.none()
|
||||||
|
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})
|
||||||
|
|
||||||
|
# Les utilisateur⋅rices connecté⋅es ont accès aux canaux publics pour les personnes connectées
|
||||||
|
qs |= Channel.objects.filter(**{permission_type: PermissionType.AUTHENTICATED})
|
||||||
|
registration = await Registration.objects.prefetch_related('user').aget(user_id=user.id)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
if registration.is_volunteer:
|
||||||
|
registration = await VolunteerRegistration.objects \
|
||||||
|
.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})
|
||||||
|
|
||||||
|
# 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),
|
||||||
|
**{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()),
|
||||||
|
**{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())
|
||||||
|
| Q(tournament__in=registration.organized_tournaments.all()),
|
||||||
|
**{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())
|
||||||
|
| Q(pool__tournament__in=registration.organized_tournaments.all())
|
||||||
|
| Q(pool__tournament__pools__in=registration.pools_presided.all()),
|
||||||
|
**{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())
|
||||||
|
| Q(pool__tournament__in=registration.organized_tournaments.all())
|
||||||
|
| Q(pool__tournament__pools__in=registration.pools_presided.all()),
|
||||||
|
**{permission_type: PermissionType.POOL_MEMBER})
|
||||||
|
else:
|
||||||
|
registration = await ParticipantRegistration.objects \
|
||||||
|
.prefetch_related('team__participation__pools', 'team__participation__tournament').aget(user_id=user.id)
|
||||||
|
|
||||||
|
team = registration.team
|
||||||
|
tournaments = []
|
||||||
|
if team.participation.valid:
|
||||||
|
tournaments.append(team.participation.tournament)
|
||||||
|
if team.participation.final:
|
||||||
|
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),
|
||||||
|
**{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()),
|
||||||
|
**{permission_type: PermissionType.POOL_MEMBER})
|
||||||
|
|
||||||
|
# Iels ont accès aux canaux propres à leur équipe
|
||||||
|
qs |= Channel.objects.filter(Q(team=team),
|
||||||
|
**{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')
|
||||||
|
|
||||||
|
return qs
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("channel")
|
||||||
|
verbose_name_plural = _("channels")
|
||||||
|
ordering = ('category', 'name',)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
verbose_name=_("channel"),
|
||||||
|
related_name='messages',
|
||||||
|
)
|
||||||
|
|
||||||
|
author = models.ForeignKey(
|
||||||
|
'auth.User',
|
||||||
|
verbose_name=_("author"),
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
related_name='chat_messages',
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
verbose_name=_("created at"),
|
||||||
|
auto_now_add=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_at = models.DateTimeField(
|
||||||
|
verbose_name=_("updated at"),
|
||||||
|
auto_now=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
content = models.TextField(
|
||||||
|
verbose_name=_("content"),
|
||||||
|
)
|
||||||
|
|
||||||
|
users_read = models.ManyToManyField(
|
||||||
|
'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
|
||||||
|
|
||||||
|
author_name = f"{self.author.first_name} {self.author.last_name}"
|
||||||
|
if registration.is_volunteer:
|
||||||
|
if registration.is_admin:
|
||||||
|
# Les administrateur⋅rices ont le suffixe (CNO)
|
||||||
|
author_name += " (CNO)"
|
||||||
|
|
||||||
|
if self.channel.pool:
|
||||||
|
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)"
|
||||||
|
elif registration in self.channel.pool.juries.all():
|
||||||
|
# Les juré⋅es de la poule ont le suffixe (Juré⋅e)
|
||||||
|
author_name += " (Juré⋅e)"
|
||||||
|
elif registration in self.channel.pool.tournament.organizers.all():
|
||||||
|
# Les organisateur⋅rices du tournoi ont le suffixe (CRO)
|
||||||
|
author_name += " (CRO)"
|
||||||
|
else:
|
||||||
|
# Les éventuel⋅les autres bénévoles ont le suffixe (Bénévole)
|
||||||
|
author_name += " (Bénévole)"
|
||||||
|
elif self.channel.tournament:
|
||||||
|
if registration in self.channel.tournament.organizers.all():
|
||||||
|
# Les organisateur⋅rices du tournoi ont le suffixe (CRO)
|
||||||
|
author_name += " (CRO)"
|
||||||
|
elif any([registration.id == pool.jury_president
|
||||||
|
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
|
||||||
|
for pool in self.channel.tournament.pools.all()
|
||||||
|
if pool.jury_president == registration])
|
||||||
|
author_name += f" (P. jury {pools})"
|
||||||
|
elif any([pool.juries.contains(registration)
|
||||||
|
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
|
||||||
|
for pool in self.channel.tournament.pools.all()
|
||||||
|
if pool.juries.acontains(registration)])
|
||||||
|
author_name += f" (Juré⋅e {pools})"
|
||||||
|
else:
|
||||||
|
# Les éventuel⋅les autres bénévoles ont le suffixe (Bénévole)
|
||||||
|
author_name += " (Bénévole)"
|
||||||
|
else:
|
||||||
|
if registration.organized_tournaments.exists():
|
||||||
|
# Les organisateur⋅rices de tournois ont le suffixe (CRO) mentionnant les tournois organisés
|
||||||
|
tournaments = ", ".join([tournament.name
|
||||||
|
for tournament in registration.organized_tournaments.all()])
|
||||||
|
author_name += f" (CRO {tournaments})"
|
||||||
|
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 = ", ".join([tournament.name for tournament in tournaments])
|
||||||
|
author_name += f" (P. jury {tournaments})"
|
||||||
|
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 = ", ".join([tournament.name for tournament in tournaments])
|
||||||
|
author_name += f" (Juré⋅e {tournaments})"
|
||||||
|
else:
|
||||||
|
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)
|
||||||
|
author_name += f" ({team.trigram})"
|
||||||
|
else:
|
||||||
|
author_name += " (sans équipe)"
|
||||||
|
|
||||||
|
return author_name
|
||||||
|
|
||||||
|
async def aget_author_name(self) -> str:
|
||||||
|
"""
|
||||||
|
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)()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("message")
|
||||||
|
verbose_name_plural = _("messages")
|
||||||
|
ordering = ('created_at',)
|
120
chat/signals.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
# Copyright (C) 2024 by Animath
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from chat.models import Channel
|
||||||
|
from participation.models import Participation, Pool, Tournament
|
||||||
|
from tfjm.permissions import PermissionType
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Création du canal « Tournoi - Annonces »
|
||||||
|
Channel.objects.update_or_create(
|
||||||
|
name=f"{tournament.name} - Annonces",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.TOURNAMENT,
|
||||||
|
read_access=PermissionType.TOURNAMENT_MEMBER,
|
||||||
|
write_access=PermissionType.TOURNAMENT_ORGANIZER,
|
||||||
|
tournament=tournament,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Création du canal « Tournoi - Général »
|
||||||
|
Channel.objects.update_or_create(
|
||||||
|
name=f"{tournament.name} - Général",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.TOURNAMENT,
|
||||||
|
read_access=PermissionType.TOURNAMENT_MEMBER,
|
||||||
|
write_access=PermissionType.TOURNAMENT_MEMBER,
|
||||||
|
tournament=tournament,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Création du canal « Tournoi - Détente »
|
||||||
|
Channel.objects.update_or_create(
|
||||||
|
name=f"{tournament.name} - Détente",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.TOURNAMENT,
|
||||||
|
read_access=PermissionType.TOURNAMENT_MEMBER,
|
||||||
|
write_access=PermissionType.TOURNAMENT_MEMBER,
|
||||||
|
tournament=tournament,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Création du canal « Tournoi - Juré⋅es »
|
||||||
|
Channel.objects.update_or_create(
|
||||||
|
name=f"{tournament.name} - Juré⋅es",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.TOURNAMENT,
|
||||||
|
read_access=PermissionType.JURY_MEMBER,
|
||||||
|
write_access=PermissionType.JURY_MEMBER,
|
||||||
|
tournament=tournament,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
name=f"{tournament.name} - Président⋅es de jury",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.TOURNAMENT,
|
||||||
|
read_access=PermissionType.TOURNAMENT_JURY_PRESIDENT,
|
||||||
|
write_access=PermissionType.TOURNAMENT_JURY_PRESIDENT,
|
||||||
|
tournament=tournament,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
tournament = pool.tournament
|
||||||
|
|
||||||
|
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(
|
||||||
|
name=f"{tournament.name} - Poule {pool.short_name}",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.TOURNAMENT,
|
||||||
|
read_access=PermissionType.POOL_MEMBER,
|
||||||
|
write_access=PermissionType.POOL_MEMBER,
|
||||||
|
pool=pool,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Channel.objects.update_or_create(
|
||||||
|
name=f"{tournament.name} - Poule {pool.short_name} - Jury",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.TOURNAMENT,
|
||||||
|
read_access=PermissionType.JURY_MEMBER,
|
||||||
|
write_access=PermissionType.JURY_MEMBER,
|
||||||
|
pool=pool,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_team_channel(instance: Participation, **_kwargs):
|
||||||
|
"""
|
||||||
|
Lorsqu'une équipe est validée, on crée un canal de chat associé.
|
||||||
|
"""
|
||||||
|
if instance.valid:
|
||||||
|
Channel.objects.update_or_create(
|
||||||
|
name=f"Équipe {instance.team.trigram}",
|
||||||
|
defaults=dict(
|
||||||
|
category=Channel.ChannelCategory.TEAM,
|
||||||
|
read_access=PermissionType.TEAM_MEMBER,
|
||||||
|
write_access=PermissionType.TEAM_MEMBER,
|
||||||
|
team=instance.team,
|
||||||
|
),
|
||||||
|
)
|
29
chat/static/tfjm/chat.webmanifest
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"background_color": "white",
|
||||||
|
"description": "Chat pour le TFJM²",
|
||||||
|
"display": "standalone",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/tfjm/img/tfjm-square.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/tfjm/img/tfjm-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/tfjm/img/tfjm-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "Chat TFJM²",
|
||||||
|
"short_name": "Chat TFJM²",
|
||||||
|
"start_url": "/chat/fullscreen",
|
||||||
|
"theme_color": "black"
|
||||||
|
}
|
912
chat/static/tfjm/js/chat.js
Normal file
@ -0,0 +1,912 @@
|
|||||||
|
(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
|
||||||
|
})
|
21
chat/templates/chat/chat.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load pipeline %}
|
||||||
|
|
||||||
|
{% block extracss %}
|
||||||
|
{# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #}
|
||||||
|
<link rel="manifest" href="{% static "tfjm/chat.webmanifest" %}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content-title %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include "chat/content.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extrajavascript %}
|
||||||
|
{# Ce script contient toutes les données pour la gestion du chat. #}
|
||||||
|
{% javascript 'chat' %}
|
||||||
|
{% endblock %}
|
126
chat/templates/chat/content.html
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<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." %}
|
||||||
|
</noscript>
|
||||||
|
<div class="offcanvas offcanvas-start" tabindex="-1" id="channelSelector" aria-labelledby="offcanvasTitle">
|
||||||
|
<div class="offcanvas-header">
|
||||||
|
{# Titre du sélecteur de canaux #}
|
||||||
|
<h3 class="offcanvas-title" id="offcanvasTitle">{% trans "Chat channels" %}</h3>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
{# Liste des différentes catégories, avec les canaux par catégorie #}
|
||||||
|
<li class="list-group-item d-none">
|
||||||
|
{# Canaux généraux #}
|
||||||
|
<h4>{% trans "General channels" %}</h4>
|
||||||
|
<ul class="list-group list-group-flush" id="nav-general-channels-tab"></ul>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item d-none">
|
||||||
|
{# Canaux liés à un tournoi #}
|
||||||
|
<h4>{% trans "Tournament channels" %}</h4>
|
||||||
|
<ul class="list-group list-group-flush" id="nav-tournament-channels-tab"></ul>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item d-none">
|
||||||
|
{# Canaux d'équipes #}
|
||||||
|
<h4>{% trans "Team channels" %}</h4>
|
||||||
|
<ul class="list-group list-group-flush" id="nav-team-channels-tab"></ul>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item d-none">
|
||||||
|
{# Échanges privés #}
|
||||||
|
<h4>{% trans "Private channels" %}</h4>
|
||||||
|
<ul class="list-group list-group-flush" id="nav-private-channels-tab"></ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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." %}
|
||||||
|
</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 %}"
|
||||||
|
style="height: 95vh" id="chat-container">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>
|
||||||
|
{% if fullscreen %}
|
||||||
|
{# Lorsque le chat est en plein écran, on affiche le bouton de déconnexion. #}
|
||||||
|
{# 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">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% 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"
|
||||||
|
aria-controls="channelSelector" aria-expanded="false" aria-label="Toggle channel selector">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span id="channel-title"></span> {# Titre du canal sélectionné #}
|
||||||
|
{% 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" %}">
|
||||||
|
<i class="fas fa-expand"></i>
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
{# Le bouton de déconnexion n'est affiché que sur l'application. #}
|
||||||
|
<button class="btn float-end" title="{% trans "Log out" %}">
|
||||||
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
|
</button>
|
||||||
|
{% 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" %}">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</button>
|
||||||
|
{% if fullscreen %}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
</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">
|
||||||
|
{# Correspond à la liste des messages à afficher. #}
|
||||||
|
<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">
|
||||||
|
<a href="#" class="nav-link" onclick="event.preventDefault(); fetchPreviousMessages()">
|
||||||
|
{% trans "Fetch previous messages…" %}
|
||||||
|
</a>
|
||||||
|
<hr>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Pied de la carte, contenant le formulaire pour envoyer un message. #}
|
||||||
|
<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()">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="input-message" class="input-group-text">
|
||||||
|
<i class="fas fa-comment"></i>
|
||||||
|
</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">
|
||||||
|
<button class="input-group-text btn btn-success" type="submit">
|
||||||
|
<i class="fas fa-paper-plane"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{# Récupération de l'utilisateur⋅rice courant⋅e afin de pouvoir effectuer des tests plus tard. #}
|
||||||
|
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" }}
|
||||||
|
</script>
|
35
chat/templates/chat/fullscreen.html
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{% load i18n pipeline static %}
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
|
||||||
|
<html lang="{{ LANGUAGE_CODE|default:"fr" }}" {% if LANGUAGE_BIDI %}dir="rtl"{% endif %}>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<title>
|
||||||
|
{% trans "TFJM² Chat" %}
|
||||||
|
</title>
|
||||||
|
<meta name="description" content="{% trans "TFJM² Chat" %}">
|
||||||
|
|
||||||
|
{# Favicon #}
|
||||||
|
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
|
||||||
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|
||||||
|
{# Bootstrap + Font Awesome CSS #}
|
||||||
|
{% stylesheet 'bootstrap_fontawesome' %}
|
||||||
|
|
||||||
|
{# Bootstrap JavaScript #}
|
||||||
|
{% javascript 'bootstrap' %}
|
||||||
|
|
||||||
|
{# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #}
|
||||||
|
<link rel="manifest" href="{% static "tfjm/chat.webmanifest" %}">
|
||||||
|
</head>
|
||||||
|
<body class="d-flex w-100 h-100 flex-column">
|
||||||
|
{% include "chat/content.html" with fullscreen=True %}
|
||||||
|
|
||||||
|
{# Inclusion du script permettant de gérer le thème sombre et le thème clair #}
|
||||||
|
{% javascript 'theme' %}
|
||||||
|
{# Inclusion du script gérant le chat #}
|
||||||
|
{% javascript 'chat' %}
|
||||||
|
</body>
|
||||||
|
</html>
|
36
chat/templates/chat/login.html
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{% load i18n pipeline static %}
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
|
||||||
|
<html lang="{{ LANGUAGE_CODE|default:"fr" }}" {% if LANGUAGE_BIDI %}dir="rtl"{% endif %}>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<title>
|
||||||
|
{% trans "TFJM² Chat" %} - {% trans "Log in" %}
|
||||||
|
</title>
|
||||||
|
<meta name="description" content="{% trans "TFJM² Chat" %}">
|
||||||
|
|
||||||
|
{# Favicon #}
|
||||||
|
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
|
||||||
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|
||||||
|
{# Bootstrap CSS #}
|
||||||
|
{% stylesheet 'bootstrap_fontawesome' %}
|
||||||
|
|
||||||
|
{# Bootstrap JavaScript #}
|
||||||
|
{% javascript 'bootstrap' %}
|
||||||
|
|
||||||
|
{# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #}
|
||||||
|
<link rel="manifest" href="{% static "tfjm/chat.webmanifest" %}">
|
||||||
|
</head>
|
||||||
|
<body class="d-flex w-100 h-100 flex-column">
|
||||||
|
<div class="container">
|
||||||
|
<h1>{% trans "Log in" %}</h1>
|
||||||
|
{% include "registration/includes/login.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Inclusion du script permettant de gérer le thème sombre et le thème clair #}
|
||||||
|
{% javascript 'theme' %}
|
||||||
|
</body>
|
||||||
|
</html>
|
2
chat/tests.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Copyright (C) 2024 by Animath
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
18
chat/urls.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Copyright (C) 2024 by Animath
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from django.contrib.auth.views import LoginView, LogoutView
|
||||||
|
from django.urls import path
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from tfjm.views import LoginRequiredTemplateView
|
||||||
|
|
||||||
|
app_name = 'chat'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', LoginRequiredTemplateView.as_view(template_name="chat/chat.html",
|
||||||
|
extra_context={'title': _("Chat")}), name='chat'),
|
||||||
|
path('fullscreen/', LoginRequiredTemplateView.as_view(template_name="chat/fullscreen.html", login_url='chat:login'),
|
||||||
|
name='fullscreen'),
|
||||||
|
path('login/', LoginView.as_view(template_name="chat/login.html"), name='login'),
|
||||||
|
path('logout/', LogoutView.as_view(next_page='chat:fullscreen'), name='logout'),
|
||||||
|
]
|
@ -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`, `plateforme.tfjm.org`)"
|
- "traefik.http.routers.inscription-tfjm2.rule=Host(`inscription.tfjm.org`, `inscriptions.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"
|
||||||
|
|
||||||
|
@ -9,6 +9,7 @@ from random import randint, shuffle
|
|||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.utils import translation
|
from django.utils import translation
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -44,6 +45,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
We accept only if this is a user of a team of the associated tournament, or a volunteer
|
We accept only if this is a user of a team of the associated tournament, or a volunteer
|
||||||
of the tournament.
|
of the tournament.
|
||||||
"""
|
"""
|
||||||
|
if '_fake_user_id' in self.scope['session']:
|
||||||
|
self.scope['user'] = await User.objects.aget(pk=self.scope['session']['_fake_user_id'])
|
||||||
|
|
||||||
# Fetch the registration of the current user
|
# Fetch the registration of the current user
|
||||||
user = self.scope['user']
|
user = self.scope['user']
|
||||||
|
28
draw/migrations/0003_alter_teamdraw_options.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 5.0.3 on 2024-04-22 22:11
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("draw", "0002_alter_teamdraw_purposed"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="teamdraw",
|
||||||
|
options={
|
||||||
|
"ordering": (
|
||||||
|
"round__draw__tournament__name",
|
||||||
|
"round__number",
|
||||||
|
"pool__letter",
|
||||||
|
"passage_index",
|
||||||
|
"choice_dice",
|
||||||
|
"passage_dice",
|
||||||
|
),
|
||||||
|
"verbose_name": "team draw",
|
||||||
|
"verbose_name_plural": "team draws",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
@ -1,10 +0,0 @@
|
|||||||
# Copyright (C) 2023 by Animath
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
|
|
||||||
from django.urls import path
|
|
||||||
|
|
||||||
from . import consumers
|
|
||||||
|
|
||||||
websocket_urlpatterns = [
|
|
||||||
path("ws/draw/", consumers.DrawConsumer.as_asgi()),
|
|
||||||
]
|
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
{% 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 #}
|
||||||
@ -40,5 +41,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 #}
|
||||||
<script src="{% static 'draw.js' %}"></script>
|
{% javascript 'draw' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -14,8 +14,8 @@ from django.contrib.sites.models import Site
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from participation.models import Team, Tournament
|
from participation.models import Team, Tournament
|
||||||
|
from tfjm import routing as websocket_routing
|
||||||
|
|
||||||
from . import routing
|
|
||||||
from .models import Draw, Pool, Round, TeamDraw
|
from .models import Draw, Pool, Round, TeamDraw
|
||||||
|
|
||||||
|
|
||||||
@ -55,7 +55,7 @@ class TestDraw(TestCase):
|
|||||||
|
|
||||||
# Connect to Websocket
|
# Connect to Websocket
|
||||||
headers = [(b'cookie', self.async_client.cookies.output(header='', sep='; ').encode())]
|
headers = [(b'cookie', self.async_client.cookies.output(header='', sep='; ').encode())]
|
||||||
communicator = WebsocketCommunicator(AuthMiddlewareStack(URLRouter(routing.websocket_urlpatterns)),
|
communicator = WebsocketCommunicator(AuthMiddlewareStack(URLRouter(websocket_routing.websocket_urlpatterns)),
|
||||||
"/ws/draw/", headers)
|
"/ws/draw/", headers)
|
||||||
connected, subprotocol = await communicator.connect()
|
connected, subprotocol = await communicator.connect()
|
||||||
self.assertTrue(connected)
|
self.assertTrue(connected)
|
||||||
|
@ -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-04-22 23:36+0200\n"
|
"POT-Creation-Date: 2024-05-26 22:06+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,14 +21,63 @@ msgstr ""
|
|||||||
msgid "API"
|
msgid "API"
|
||||||
msgstr "API"
|
msgstr "API"
|
||||||
|
|
||||||
#: draw/admin.py:39 draw/admin.py:57 draw/admin.py:75
|
#: chat/models.py:22 chat/templates/chat/content.html:23
|
||||||
#: participation/admin.py:109 participation/models.py:253
|
msgid "General channels"
|
||||||
#: participation/tables.py:88
|
msgstr "Canaux généraux"
|
||||||
msgid "teams"
|
|
||||||
msgstr "équipes"
|
|
||||||
|
|
||||||
#: draw/admin.py:53 draw/admin.py:71 draw/admin.py:88 draw/models.py:26
|
#: chat/models.py:23 chat/templates/chat/content.html:28
|
||||||
#: participation/admin.py:79 participation/admin.py:140
|
msgid "Tournament channels"
|
||||||
|
msgstr "Canaux de tournois"
|
||||||
|
|
||||||
|
#: chat/models.py:24 chat/templates/chat/content.html:33
|
||||||
|
msgid "Team channels"
|
||||||
|
msgstr "Canaux d'équipes"
|
||||||
|
|
||||||
|
#: chat/models.py:25 chat/templates/chat/content.html:38
|
||||||
|
msgid "Private channels"
|
||||||
|
msgstr "Messages privés"
|
||||||
|
|
||||||
|
#: chat/models.py:29 participation/models.py:35 participation/models.py:263
|
||||||
|
#: participation/tables.py:18 participation/tables.py:34
|
||||||
|
msgid "name"
|
||||||
|
msgstr "nom"
|
||||||
|
|
||||||
|
#: chat/models.py:30
|
||||||
|
msgid "Visible name of the channel."
|
||||||
|
msgstr "Nom visible du canal."
|
||||||
|
|
||||||
|
#: chat/models.py:35
|
||||||
|
msgid "category"
|
||||||
|
msgstr "catégorie"
|
||||||
|
|
||||||
|
#: chat/models.py:38
|
||||||
|
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"
|
||||||
|
msgstr "permission de lecture"
|
||||||
|
|
||||||
|
#: chat/models.py:46
|
||||||
|
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"
|
||||||
|
msgstr "permission d'écriture"
|
||||||
|
|
||||||
|
#: chat/models.py:53
|
||||||
|
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
|
||||||
#: 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
|
||||||
#: registration/models.py:756
|
#: registration/models.py:756
|
||||||
@ -36,6 +85,180 @@ msgstr "équipes"
|
|||||||
msgid "tournament"
|
msgid "tournament"
|
||||||
msgstr "tournoi"
|
msgstr "tournoi"
|
||||||
|
|
||||||
|
#: chat/models.py:64
|
||||||
|
msgid ""
|
||||||
|
"For a permission that concerns a tournament, indicates what is the concerned "
|
||||||
|
"tournament."
|
||||||
|
msgstr ""
|
||||||
|
"Pour une permission qui concerne un tournoi, indique quel est le tournoi "
|
||||||
|
"concerné."
|
||||||
|
|
||||||
|
#: chat/models.py:73 draw/models.py:429 draw/models.py:456
|
||||||
|
#: participation/admin.py:136 participation/admin.py:155
|
||||||
|
#: participation/models.py:1434 participation/models.py:1443
|
||||||
|
#: participation/tables.py:84
|
||||||
|
msgid "pool"
|
||||||
|
msgstr "poule"
|
||||||
|
|
||||||
|
#: chat/models.py:75
|
||||||
|
msgid ""
|
||||||
|
"For a permission that concerns a pool, indicates what is the concerned pool."
|
||||||
|
msgstr ""
|
||||||
|
"Pour une permission qui concerne une poule, indique quelle est la poule "
|
||||||
|
"concernée."
|
||||||
|
|
||||||
|
#: chat/models.py:84 draw/templates/draw/tournament_content.html:277
|
||||||
|
#: participation/admin.py:167 participation/models.py:252
|
||||||
|
#: participation/models.py:708
|
||||||
|
#: participation/templates/participation/tournament_harmonize.html:15
|
||||||
|
#: registration/models.py:157 registration/models.py:747
|
||||||
|
#: registration/tables.py:39
|
||||||
|
#: registration/templates/registration/payment_form.html:52
|
||||||
|
msgid "team"
|
||||||
|
msgstr "équipe"
|
||||||
|
|
||||||
|
#: chat/models.py:86
|
||||||
|
msgid ""
|
||||||
|
"For a permission that concerns a team, indicates what is the concerned team."
|
||||||
|
msgstr ""
|
||||||
|
"Pour une permission qui concerne une équipe, indique quelle est l'équipe "
|
||||||
|
"concernée."
|
||||||
|
|
||||||
|
#: chat/models.py:90
|
||||||
|
msgid "private"
|
||||||
|
msgstr "privé"
|
||||||
|
|
||||||
|
#: chat/models.py:92
|
||||||
|
msgid ""
|
||||||
|
"If checked, only users who have been explicitly added to the channel will be "
|
||||||
|
"able to access it."
|
||||||
|
msgstr ""
|
||||||
|
"Si sélectionné, seul⋅es les utilisateur⋅rices qui ont été explicitement "
|
||||||
|
"ajouté⋅es au canal pourront y accéder."
|
||||||
|
|
||||||
|
#: chat/models.py:97
|
||||||
|
msgid "invited users"
|
||||||
|
msgstr "Utilisateur⋅rices invité"
|
||||||
|
|
||||||
|
#: chat/models.py:100
|
||||||
|
msgid ""
|
||||||
|
"Extra users who have been invited to the channel, in addition to the "
|
||||||
|
"permitted group of the channel."
|
||||||
|
msgstr ""
|
||||||
|
"Utilisateur⋅rices supplémentaires qui ont été invité⋅es au canal, en plus du "
|
||||||
|
"groupe autorisé du canal."
|
||||||
|
|
||||||
|
#: chat/models.py:122
|
||||||
|
#, python-brace-format
|
||||||
|
msgid "Channel {name}"
|
||||||
|
msgstr "Canal {name}"
|
||||||
|
|
||||||
|
#: chat/models.py:231 chat/models.py:246
|
||||||
|
msgid "channel"
|
||||||
|
msgstr "canal"
|
||||||
|
|
||||||
|
#: chat/models.py:232
|
||||||
|
msgid "channels"
|
||||||
|
msgstr "canaux"
|
||||||
|
|
||||||
|
#: chat/models.py:252
|
||||||
|
msgid "author"
|
||||||
|
msgstr "auteur⋅rice"
|
||||||
|
|
||||||
|
#: chat/models.py:259
|
||||||
|
msgid "created at"
|
||||||
|
msgstr "créé le"
|
||||||
|
|
||||||
|
#: chat/models.py:264
|
||||||
|
msgid "updated at"
|
||||||
|
msgstr "modifié le"
|
||||||
|
|
||||||
|
#: chat/models.py:269
|
||||||
|
msgid "content"
|
||||||
|
msgstr "contenu"
|
||||||
|
|
||||||
|
#: chat/models.py:274
|
||||||
|
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"
|
||||||
|
msgstr "message"
|
||||||
|
|
||||||
|
#: chat/models.py:364
|
||||||
|
msgid "messages"
|
||||||
|
msgstr "messages"
|
||||||
|
|
||||||
|
#: chat/templates/chat/content.html:5
|
||||||
|
msgid "JavaScript must be enabled on your browser to access chat."
|
||||||
|
msgstr "JavaScript doit être activé sur votre navigateur pour accéder au chat."
|
||||||
|
|
||||||
|
#: chat/templates/chat/content.html:10
|
||||||
|
msgid "Chat channels"
|
||||||
|
msgstr "Canaux de chat"
|
||||||
|
|
||||||
|
#: chat/templates/chat/content.html:17
|
||||||
|
msgid "Sort by unread messages"
|
||||||
|
msgstr "Trier par messages non lus"
|
||||||
|
|
||||||
|
#: chat/templates/chat/content.html:47
|
||||||
|
msgid ""
|
||||||
|
"You can install a shortcut to the chat on your home screen using the "
|
||||||
|
"download button on the header."
|
||||||
|
msgstr ""
|
||||||
|
"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."
|
||||||
|
|
||||||
|
#: chat/templates/chat/content.html:71
|
||||||
|
msgid "Toggle fullscreen mode"
|
||||||
|
msgstr "Inverse le mode plein écran"
|
||||||
|
|
||||||
|
#: chat/templates/chat/content.html:76 tfjm/templates/navbar.html:117
|
||||||
|
msgid "Log out"
|
||||||
|
msgstr "Déconnexion"
|
||||||
|
|
||||||
|
#: chat/templates/chat/content.html:81
|
||||||
|
msgid "Install app on home screen"
|
||||||
|
msgstr "Installer l'application sur l'écran d'accueil"
|
||||||
|
|
||||||
|
#: chat/templates/chat/content.html:97
|
||||||
|
msgid "Fetch previous messages…"
|
||||||
|
msgstr "Récupérer les messages précédents…"
|
||||||
|
|
||||||
|
#: chat/templates/chat/content.html:112
|
||||||
|
msgid "Send message…"
|
||||||
|
msgstr "Envoyer un message…"
|
||||||
|
|
||||||
|
#: chat/templates/chat/fullscreen.html:10
|
||||||
|
#: 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
|
||||||
|
#: tfjm/templates/base.html:84 tfjm/templates/base.html:85
|
||||||
|
#: tfjm/templates/navbar.html:98
|
||||||
|
#: tfjm/templates/registration/includes/login.html:22
|
||||||
|
#: tfjm/templates/registration/login.html:7
|
||||||
|
#: tfjm/templates/registration/login.html:8
|
||||||
|
msgid "Log in"
|
||||||
|
msgstr "Connexion"
|
||||||
|
|
||||||
|
#: chat/urls.py:13 tfjm/templates/navbar.html:66
|
||||||
|
msgid "Chat"
|
||||||
|
msgstr "Chat"
|
||||||
|
|
||||||
|
#: draw/admin.py:39 draw/admin.py:57 draw/admin.py:75
|
||||||
|
#: participation/admin.py:109 participation/models.py:253
|
||||||
|
#: participation/tables.py:88
|
||||||
|
msgid "teams"
|
||||||
|
msgstr "équipes"
|
||||||
|
|
||||||
#: draw/admin.py:92 draw/models.py:234 draw/models.py:448
|
#: draw/admin.py:92 draw/models.py:234 draw/models.py:448
|
||||||
#: participation/models.py:939
|
#: participation/models.py:939
|
||||||
msgid "round"
|
msgid "round"
|
||||||
@ -45,68 +268,68 @@ msgstr "tour"
|
|||||||
msgid "Draw"
|
msgid "Draw"
|
||||||
msgstr "Tirage au sort"
|
msgstr "Tirage au sort"
|
||||||
|
|
||||||
#: draw/consumers.py:30
|
#: draw/consumers.py:31
|
||||||
msgid "You are not an organizer."
|
msgid "You are not an organizer."
|
||||||
msgstr "Vous n'êtes pas un⋅e organisateur⋅rice."
|
msgstr "Vous n'êtes pas un⋅e organisateur⋅rice."
|
||||||
|
|
||||||
#: draw/consumers.py:162
|
#: draw/consumers.py:165
|
||||||
msgid "The draw is already started."
|
msgid "The draw is already started."
|
||||||
msgstr "Le tirage a déjà commencé."
|
msgstr "Le tirage a déjà commencé."
|
||||||
|
|
||||||
#: draw/consumers.py:168
|
#: draw/consumers.py:171
|
||||||
msgid "Invalid format"
|
msgid "Invalid format"
|
||||||
msgstr "Format invalide"
|
msgstr "Format invalide"
|
||||||
|
|
||||||
#: draw/consumers.py:173
|
#: draw/consumers.py:176
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "The sum must be equal to the number of teams: expected {len}, got {sum}"
|
msgid "The sum must be equal to the number of teams: expected {len}, got {sum}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"La somme doit être égale au nombre d'équipes : attendu {len}, obtenu {sum}"
|
"La somme doit être égale au nombre d'équipes : attendu {len}, obtenu {sum}"
|
||||||
|
|
||||||
#: draw/consumers.py:178
|
#: draw/consumers.py:181
|
||||||
msgid "There can be at most one pool with 5 teams."
|
msgid "There can be at most one pool with 5 teams."
|
||||||
msgstr "Il ne peut y avoir au plus qu'une seule poule de 5 équipes."
|
msgstr "Il ne peut y avoir au plus qu'une seule poule de 5 équipes."
|
||||||
|
|
||||||
#: draw/consumers.py:218
|
#: draw/consumers.py:221
|
||||||
msgid "Draw started!"
|
msgid "Draw started!"
|
||||||
msgstr "Le tirage a commencé !"
|
msgstr "Le tirage a commencé !"
|
||||||
|
|
||||||
#: draw/consumers.py:240
|
#: draw/consumers.py:243
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "The draw for the tournament {tournament} will start."
|
msgid "The draw for the tournament {tournament} will start."
|
||||||
msgstr "Le tirage au sort du tournoi {tournament} va commencer."
|
msgstr "Le tirage au sort du tournoi {tournament} va commencer."
|
||||||
|
|
||||||
#: draw/consumers.py:251 draw/consumers.py:277 draw/consumers.py:687
|
#: draw/consumers.py:254 draw/consumers.py:280 draw/consumers.py:690
|
||||||
#: draw/consumers.py:904 draw/consumers.py:993 draw/consumers.py:1015
|
#: draw/consumers.py:907 draw/consumers.py:996 draw/consumers.py:1018
|
||||||
#: draw/consumers.py:1106 draw/templates/draw/tournament_content.html:5
|
#: draw/consumers.py:1109 draw/templates/draw/tournament_content.html:5
|
||||||
msgid "The draw has not started yet."
|
msgid "The draw has not started yet."
|
||||||
msgstr "Le tirage au sort n'a pas encore commencé."
|
msgstr "Le tirage au sort n'a pas encore commencé."
|
||||||
|
|
||||||
#: draw/consumers.py:264
|
#: draw/consumers.py:267
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "The draw for the tournament {tournament} is aborted."
|
msgid "The draw for the tournament {tournament} is aborted."
|
||||||
msgstr "Le tirage au sort du tournoi {tournament} est annulé."
|
msgstr "Le tirage au sort du tournoi {tournament} est annulé."
|
||||||
|
|
||||||
#: draw/consumers.py:304 draw/consumers.py:325 draw/consumers.py:621
|
#: draw/consumers.py:307 draw/consumers.py:328 draw/consumers.py:624
|
||||||
#: draw/consumers.py:692 draw/consumers.py:909
|
#: draw/consumers.py:695 draw/consumers.py:912
|
||||||
msgid "This is not the time for this."
|
msgid "This is not the time for this."
|
||||||
msgstr "Ce n'est pas le moment pour cela."
|
msgstr "Ce n'est pas le moment pour cela."
|
||||||
|
|
||||||
#: draw/consumers.py:317 draw/consumers.py:320
|
#: draw/consumers.py:320 draw/consumers.py:323
|
||||||
msgid "You've already launched the dice."
|
msgid "You've already launched the dice."
|
||||||
msgstr "Vous avez déjà lancé le dé."
|
msgstr "Vous avez déjà lancé le dé."
|
||||||
|
|
||||||
#: draw/consumers.py:323
|
#: draw/consumers.py:326
|
||||||
msgid "It is not your turn."
|
msgid "It is not your turn."
|
||||||
msgstr "Ce n'est pas votre tour."
|
msgstr "Ce n'est pas votre tour."
|
||||||
|
|
||||||
#: draw/consumers.py:410
|
#: draw/consumers.py:413
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Dices from teams {teams} are identical. Please relaunch your dices."
|
msgid "Dices from teams {teams} are identical. Please relaunch your dices."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Les dés des équipes {teams} sont identiques. Merci de relancer vos dés."
|
"Les dés des équipes {teams} sont identiques. Merci de relancer vos dés."
|
||||||
|
|
||||||
#: draw/consumers.py:1018
|
#: draw/consumers.py:1021
|
||||||
msgid "This is only available for the final tournament."
|
msgid "This is only available for the final tournament."
|
||||||
msgstr "Cela n'est possible que pour la finale."
|
msgstr "Cela n'est possible que pour la finale."
|
||||||
|
|
||||||
@ -213,12 +436,6 @@ msgstr "L'instance complète de la poule."
|
|||||||
msgid "Pool {letter}{number}"
|
msgid "Pool {letter}{number}"
|
||||||
msgstr "Poule {letter}{number}"
|
msgstr "Poule {letter}{number}"
|
||||||
|
|
||||||
#: draw/models.py:429 draw/models.py:456 participation/admin.py:136
|
|
||||||
#: participation/admin.py:155 participation/models.py:1434
|
|
||||||
#: participation/models.py:1443 participation/tables.py:84
|
|
||||||
msgid "pool"
|
|
||||||
msgstr "poule"
|
|
||||||
|
|
||||||
#: draw/models.py:430 participation/models.py:1435
|
#: draw/models.py:430 participation/models.py:1435
|
||||||
msgid "pools"
|
msgid "pools"
|
||||||
msgstr "poules"
|
msgstr "poules"
|
||||||
@ -352,15 +569,6 @@ msgstr "Tirer un problème pour"
|
|||||||
msgid "Pb."
|
msgid "Pb."
|
||||||
msgstr "Pb."
|
msgstr "Pb."
|
||||||
|
|
||||||
#: draw/templates/draw/tournament_content.html:277 participation/admin.py:167
|
|
||||||
#: participation/models.py:252 participation/models.py:708
|
|
||||||
#: participation/templates/participation/tournament_harmonize.html:15
|
|
||||||
#: registration/models.py:157 registration/models.py:747
|
|
||||||
#: registration/tables.py:39
|
|
||||||
#: registration/templates/registration/payment_form.html:52
|
|
||||||
msgid "team"
|
|
||||||
msgstr "équipe"
|
|
||||||
|
|
||||||
#: draw/templates/draw/tournament_content.html:287
|
#: draw/templates/draw/tournament_content.html:287
|
||||||
#: draw/templates/draw/tournament_content.html:288
|
#: draw/templates/draw/tournament_content.html:288
|
||||||
#: draw/templates/draw/tournament_content.html:289
|
#: draw/templates/draw/tournament_content.html:289
|
||||||
@ -395,8 +603,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:501
|
#: draw/views.py:31 participation/views.py:162 participation/views.py:504
|
||||||
#: participation/views.py:532
|
#: participation/views.py:535
|
||||||
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."
|
||||||
|
|
||||||
@ -508,7 +716,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:503
|
#: participation/forms.py:58 participation/views.py:506
|
||||||
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."
|
||||||
|
|
||||||
@ -589,11 +797,6 @@ msgstr "Ce⋅tte défenseur⋅se ne travaille pas sur ce problème."
|
|||||||
msgid "The PDF file must not have more than 2 pages."
|
msgid "The PDF file must not have more than 2 pages."
|
||||||
msgstr "Le fichier PDF ne doit pas avoir plus de 2 pages."
|
msgstr "Le fichier PDF ne doit pas avoir plus de 2 pages."
|
||||||
|
|
||||||
#: participation/models.py:35 participation/models.py:263
|
|
||||||
#: participation/tables.py:18 participation/tables.py:34
|
|
||||||
msgid "name"
|
|
||||||
msgstr "nom"
|
|
||||||
|
|
||||||
#: participation/models.py:41 participation/tables.py:39
|
#: participation/models.py:41 participation/tables.py:39
|
||||||
msgid "trigram"
|
msgid "trigram"
|
||||||
msgstr "trigramme"
|
msgstr "trigramme"
|
||||||
@ -1219,16 +1422,6 @@ msgstr "Pas d'équipe définie"
|
|||||||
msgid "Update"
|
msgid "Update"
|
||||||
msgstr "Modifier"
|
msgstr "Modifier"
|
||||||
|
|
||||||
#: participation/templates/participation/chat.html:7
|
|
||||||
msgid ""
|
|
||||||
"The chat feature is now out of usage. If you feel that having a chat feature "
|
|
||||||
"between participants is important, for example to build a team, please "
|
|
||||||
"contact us."
|
|
||||||
msgstr ""
|
|
||||||
"La fonctionnalité de chat est désormais hors-service. Si vous pensez "
|
|
||||||
"qu'avoir un chat entre les participant⋅es est important, par exemple pour "
|
|
||||||
"former une équipe, merci de nous contacter."
|
|
||||||
|
|
||||||
#: participation/templates/participation/create_team.html:11
|
#: participation/templates/participation/create_team.html:11
|
||||||
#: participation/templates/participation/tournament_form.html:14
|
#: participation/templates/participation/tournament_form.html:14
|
||||||
#: tfjm/templates/base.html:80
|
#: tfjm/templates/base.html:80
|
||||||
@ -1703,7 +1896,7 @@ msgid "Invalidate"
|
|||||||
msgstr "Invalider"
|
msgstr "Invalider"
|
||||||
|
|
||||||
#: participation/templates/participation/team_detail.html:237
|
#: participation/templates/participation/team_detail.html:237
|
||||||
#: participation/views.py:333
|
#: participation/views.py:336
|
||||||
msgid "Upload motivation letter"
|
msgid "Upload motivation letter"
|
||||||
msgstr "Envoyer la lettre de motivation"
|
msgstr "Envoyer la lettre de motivation"
|
||||||
|
|
||||||
@ -1712,7 +1905,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:495
|
#: participation/views.py:498
|
||||||
msgid "Leave team"
|
msgid "Leave team"
|
||||||
msgstr "Quitter l'équipe"
|
msgstr "Quitter l'équipe"
|
||||||
|
|
||||||
@ -1908,7 +2101,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:533
|
#: participation/views.py:163 participation/views.py:536
|
||||||
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."
|
||||||
|
|
||||||
@ -1944,169 +2137,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:276
|
#: participation/views.py:279
|
||||||
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:311
|
#: participation/views.py:314
|
||||||
#, 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:372 participation/views.py:480
|
#: participation/views.py:375 participation/views.py:483
|
||||||
#, 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:405
|
#: participation/views.py:408
|
||||||
#, 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:409
|
#: participation/views.py:412
|
||||||
#, 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:428
|
#: participation/views.py:431
|
||||||
#, 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:437
|
#: participation/views.py:440
|
||||||
#, 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:445
|
#: participation/views.py:448
|
||||||
#, 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:453
|
#: participation/views.py:456
|
||||||
#, 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:464
|
#: participation/views.py:467
|
||||||
#, 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:473
|
#: participation/views.py:476
|
||||||
#, 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:547
|
#: participation/views.py:550
|
||||||
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:561
|
#: participation/views.py:564
|
||||||
#, 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:649
|
#: participation/views.py:652
|
||||||
#, 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:748
|
#: participation/views.py:751
|
||||||
msgid "Notes published!"
|
msgid "Notes published!"
|
||||||
msgstr "Notes publiées !"
|
msgstr "Notes publiées !"
|
||||||
|
|
||||||
#: participation/views.py:750
|
#: participation/views.py:753
|
||||||
msgid "Notes hidden!"
|
msgid "Notes hidden!"
|
||||||
msgstr "Notes dissimulées !"
|
msgstr "Notes dissimulées !"
|
||||||
|
|
||||||
#: participation/views.py:781
|
#: participation/views.py:784
|
||||||
#, 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:894
|
#: participation/views.py:897
|
||||||
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:1014
|
#: participation/views.py:1017
|
||||||
#, 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:1014
|
#: participation/views.py:1017
|
||||||
#, 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:1031 participation/views.py:1046
|
#: participation/views.py:1034 participation/views.py:1049
|
||||||
#, 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:1031 participation/views.py:1046
|
#: participation/views.py:1034 participation/views.py:1049
|
||||||
#, 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:1055
|
#: participation/views.py:1058
|
||||||
#, 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:1056
|
#: participation/views.py:1059
|
||||||
#, 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:1098
|
#: participation/views.py:1101
|
||||||
#, 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:1114
|
#: participation/views.py:1117
|
||||||
#, 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:1134
|
#: participation/views.py:1137
|
||||||
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:1155
|
#: participation/views.py:1158
|
||||||
#, 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:1191
|
#: participation/views.py:1194
|
||||||
#, 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:1217
|
#: participation/views.py:1220
|
||||||
#, 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:1245
|
#: participation/views.py:1248
|
||||||
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:1261
|
#: participation/views.py:1264
|
||||||
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:1839
|
#: participation/views.py:1842
|
||||||
#, 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:1844
|
#: participation/views.py:1847
|
||||||
#, 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:2009
|
#: participation/views.py:2012
|
||||||
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."
|
||||||
|
|
||||||
@ -2899,14 +3092,6 @@ msgstr "Changer mon mot de passe"
|
|||||||
msgid "Your password has been set. You may go ahead and log in now."
|
msgid "Your password has been set. You may go ahead and log in now."
|
||||||
msgstr "Votre mot de passe a été changé. Vous pouvez désormais vous connecter."
|
msgstr "Votre mot de passe a été changé. Vous pouvez désormais vous connecter."
|
||||||
|
|
||||||
#: registration/templates/registration/password_reset_complete.html:10
|
|
||||||
#: tfjm/templates/base.html:84 tfjm/templates/base.html:85
|
|
||||||
#: tfjm/templates/navbar.html:98 tfjm/templates/registration/login.html:7
|
|
||||||
#: tfjm/templates/registration/login.html:8
|
|
||||||
#: tfjm/templates/registration/login.html:30
|
|
||||||
msgid "Log in"
|
|
||||||
msgstr "Connexion"
|
|
||||||
|
|
||||||
#: registration/templates/registration/password_reset_confirm.html:9
|
#: registration/templates/registration/password_reset_confirm.html:9
|
||||||
msgid ""
|
msgid ""
|
||||||
"Please enter your new password twice so we can verify you typed it in "
|
"Please enter your new password twice so we can verify you typed it in "
|
||||||
@ -3484,11 +3669,55 @@ msgstr "Autorisation parentale de {student}.{ext}"
|
|||||||
msgid "Payment receipt of {registrations}.{ext}"
|
msgid "Payment receipt of {registrations}.{ext}"
|
||||||
msgstr "Justificatif de paiement de {registrations}.{ext}"
|
msgstr "Justificatif de paiement de {registrations}.{ext}"
|
||||||
|
|
||||||
#: tfjm/settings.py:167
|
#: tfjm/permissions.py:9
|
||||||
|
msgid "Everyone, including anonymous users"
|
||||||
|
msgstr "Tout le monde, incluant les utilisateur⋅rices anonymes"
|
||||||
|
|
||||||
|
#: tfjm/permissions.py:10
|
||||||
|
msgid "Authenticated users"
|
||||||
|
msgstr "Utilisateur⋅rices connecté⋅es"
|
||||||
|
|
||||||
|
#: tfjm/permissions.py:11
|
||||||
|
msgid "All volunteers"
|
||||||
|
msgstr "Toustes les bénévoles"
|
||||||
|
|
||||||
|
#: tfjm/permissions.py:12
|
||||||
|
msgid "All members of a given tournament"
|
||||||
|
msgstr "Toustes les membres d'un tournoi donné"
|
||||||
|
|
||||||
|
#: tfjm/permissions.py:13
|
||||||
|
msgid "Tournament organizers only"
|
||||||
|
msgstr "Organisateur⋅rices du tournoi seulement"
|
||||||
|
|
||||||
|
#: tfjm/permissions.py:14
|
||||||
|
msgid "Tournament organizers and jury presidents of the tournament"
|
||||||
|
msgstr "Organisateur⋅rices du tournoi et président⋅es de jury du tournoi"
|
||||||
|
|
||||||
|
#: tfjm/permissions.py:15
|
||||||
|
msgid "Jury members of the pool"
|
||||||
|
msgstr "Membres du jury de la poule"
|
||||||
|
|
||||||
|
#: tfjm/permissions.py:16
|
||||||
|
msgid "Jury members and participants of the pool"
|
||||||
|
msgstr "Membre du jury et participant⋅es de la poule"
|
||||||
|
|
||||||
|
#: tfjm/permissions.py:17
|
||||||
|
msgid "Members of the team and organizers of concerned tournaments"
|
||||||
|
msgstr "Membres de l'équipe et organisateur⋅rices des tournois concernés"
|
||||||
|
|
||||||
|
#: tfjm/permissions.py:18
|
||||||
|
msgid "Private, reserved to explicit authorized users"
|
||||||
|
msgstr "Privé, réservé aux utilisateur⋅rices explicitement autorisé⋅es"
|
||||||
|
|
||||||
|
#: tfjm/permissions.py:19
|
||||||
|
msgid "Admin users"
|
||||||
|
msgstr "Administrateur⋅rices"
|
||||||
|
|
||||||
|
#: tfjm/settings.py:169
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "Anglais"
|
msgstr "Anglais"
|
||||||
|
|
||||||
#: tfjm/settings.py:168
|
#: tfjm/settings.py:170
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "Français"
|
msgstr "Français"
|
||||||
|
|
||||||
@ -3577,10 +3806,6 @@ msgstr "Mon équipe"
|
|||||||
msgid "My participation"
|
msgid "My participation"
|
||||||
msgstr "Ma participation"
|
msgstr "Ma participation"
|
||||||
|
|
||||||
#: tfjm/templates/navbar.html:67
|
|
||||||
msgid "Chat"
|
|
||||||
msgstr "Chat"
|
|
||||||
|
|
||||||
#: tfjm/templates/navbar.html:72
|
#: tfjm/templates/navbar.html:72
|
||||||
msgid "Administration"
|
msgid "Administration"
|
||||||
msgstr "Administration"
|
msgstr "Administration"
|
||||||
@ -3601,19 +3826,7 @@ msgstr "S'inscrire"
|
|||||||
msgid "My account"
|
msgid "My account"
|
||||||
msgstr "Mon compte"
|
msgstr "Mon compte"
|
||||||
|
|
||||||
#: tfjm/templates/navbar.html:115
|
#: tfjm/templates/registration/includes/login.html:5
|
||||||
msgid "Log out"
|
|
||||||
msgstr "Déconnexion"
|
|
||||||
|
|
||||||
#: tfjm/templates/registration/logged_out.html:8
|
|
||||||
msgid "Thanks for spending some quality time with the Web site today."
|
|
||||||
msgstr "Merci d'avoir utilisé la plateforme du TFJM²."
|
|
||||||
|
|
||||||
#: tfjm/templates/registration/logged_out.html:9
|
|
||||||
msgid "Log in again"
|
|
||||||
msgstr "Se reconnecter"
|
|
||||||
|
|
||||||
#: tfjm/templates/registration/login.html:13
|
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid ""
|
msgid ""
|
||||||
"You are authenticated as %(user)s, but are not authorized to access this "
|
"You are authenticated as %(user)s, but are not authorized to access this "
|
||||||
@ -3622,14 +3835,22 @@ msgstr ""
|
|||||||
"Vous êtes connecté⋅e en tant que %(user)s, mais n'êtes pas autorisé⋅e à "
|
"Vous êtes connecté⋅e en tant que %(user)s, mais n'êtes pas autorisé⋅e à "
|
||||||
"accéder à cette page. Voulez-vous vous reconnecter avec un autre compte ?"
|
"accéder à cette page. Voulez-vous vous reconnecter avec un autre compte ?"
|
||||||
|
|
||||||
#: tfjm/templates/registration/login.html:25
|
#: tfjm/templates/registration/includes/login.html:17
|
||||||
msgid "Your username is your e-mail address."
|
msgid "Your username is your e-mail address."
|
||||||
msgstr "Votre identifiant est votre adresse e-mail."
|
msgstr "Votre identifiant est votre adresse e-mail."
|
||||||
|
|
||||||
#: tfjm/templates/registration/login.html:28
|
#: tfjm/templates/registration/includes/login.html:20
|
||||||
msgid "Forgotten your password?"
|
msgid "Forgotten your password?"
|
||||||
msgstr "Mot de passe oublié ?"
|
msgstr "Mot de passe oublié ?"
|
||||||
|
|
||||||
|
#: tfjm/templates/registration/logged_out.html:8
|
||||||
|
msgid "Thanks for spending some quality time with the Web site today."
|
||||||
|
msgstr "Merci d'avoir utilisé la plateforme du TFJM²."
|
||||||
|
|
||||||
|
#: tfjm/templates/registration/logged_out.html:9
|
||||||
|
msgid "Log in again"
|
||||||
|
msgstr "Se reconnecter"
|
||||||
|
|
||||||
#: tfjm/templates/search/search.html:6 tfjm/templates/search/search.html:10
|
#: tfjm/templates/search/search.html:6 tfjm/templates/search/search.html:10
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
msgstr "Chercher"
|
msgstr "Chercher"
|
||||||
@ -3645,8 +3866,3 @@ msgstr "Aucun résultat."
|
|||||||
#: tfjm/templates/sidebar.html:10 tfjm/templates/sidebar.html:21
|
#: tfjm/templates/sidebar.html:10 tfjm/templates/sidebar.html:21
|
||||||
msgid "Informations"
|
msgid "Informations"
|
||||||
msgstr "Informations"
|
msgstr "Informations"
|
||||||
|
|
||||||
#~ msgid "Can't determine the pool size. Are you sure your file is correct?"
|
|
||||||
#~ msgstr ""
|
|
||||||
#~ "Impossible de déterminer la taille de la poule. Êtes-vous sûr⋅e que le "
|
|
||||||
#~ "fichier est correct ?"
|
|
||||||
|
@ -17,8 +17,8 @@ class Command(BaseCommand):
|
|||||||
self.w("")
|
self.w("")
|
||||||
self.w("")
|
self.w("")
|
||||||
|
|
||||||
def w(self, msg):
|
def w(self, msg, prefix="", suffix=""):
|
||||||
self.stdout.write(msg)
|
self.stdout.write(f"{prefix}{msg}{suffix}")
|
||||||
|
|
||||||
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'ITYM.</p>")
|
f"Les deux premières équipes sont sélectionnées pour représenter la France lors de l'ETEAM.</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,32 +52,29 @@ class Command(BaseCommand):
|
|||||||
self.w("<table>")
|
self.w("<table>")
|
||||||
self.w("<thead>")
|
self.w("<thead>")
|
||||||
self.w("<tr>")
|
self.w("<tr>")
|
||||||
self.w("\t<th>Équipe</th>")
|
self.w(" <th>Équipe</th>")
|
||||||
self.w("\t<th>Score Tour 1</th>")
|
self.w(" <th>Score Tour 1</th>")
|
||||||
self.w("\t<th>Score Tour 2</th>")
|
self.w(" <th>Score Tour 2</th>")
|
||||||
self.w("\t<th>Total</th>")
|
self.w(" <th>Total</th>")
|
||||||
self.w("\t<th class=\"has-text-align-center\">Prix</th>")
|
self.w(" <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>")
|
||||||
if i < (2 if len(notes) >= 7 else 1):
|
bold = (not tournament.final and participation.final) or (tournament.final and i < 2)
|
||||||
self.w(f"\t<th>{participation.team.name} ({participation.team.trigram})</td>")
|
if bold:
|
||||||
|
prefix, suffix = " <td><strong>", "</strong></td>"
|
||||||
else:
|
else:
|
||||||
self.w(f"\t<td>{participation.team.name} ({participation.team.trigram})</td>")
|
prefix, suffix = " <td>", "</td>"
|
||||||
for pool in tournament.pools.filter(participations=participation).all():
|
self.w(f"{participation.team.name} ({participation.team.trigram})", prefix, suffix)
|
||||||
pool_note = pool.average(participation)
|
for tournament_round in [1, 2]:
|
||||||
self.w(f"\t<td>{pool_note:.01f}</td>")
|
pool_note = sum(pool.average(participation)
|
||||||
self.w(f"\t<td>{note:.01f}</td>")
|
for pool in tournament.pools.filter(participations=participation,
|
||||||
if i == 0:
|
round=tournament_round).all())
|
||||||
self.w("\t<td class=\"has-text-align-center\">1<sup>er</sup> prix</td>")
|
self.w(f"{pool_note:.01f}", prefix, suffix)
|
||||||
elif i < (5 if tournament.final else 3):
|
self.w(f"{note:.01f}", prefix, suffix)
|
||||||
self.w(f"\t<td class=\"has-text-align-center\">{i + 1}<sup>ème</sup> prix</td>")
|
self.w(participation.mention_final if tournament.final else participation.mention, prefix, suffix)
|
||||||
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>")
|
||||||
|
@ -1,13 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="alert alert-warning">
|
|
||||||
{% blocktrans trimmed %}
|
|
||||||
The chat feature is now out of usage. If you feel that having a chat
|
|
||||||
feature between participants is important, for example to build a
|
|
||||||
team, please contact us.
|
|
||||||
{% endblocktrans %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
@ -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-end">{% trans "Name:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Trigram:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Email:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Access code:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Coaches:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Participants:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Tournament:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Photo authorizations:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Photo authorizations (final):" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Health sheets:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Vaccine sheets:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Parental authorizations:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Parental authorizations (final):" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Motivation letter:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">
|
<dt class="col-sm-6 text-sm-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-xl-6 text-end">{% trans 'organizers'|capfirst %}</dt>
|
<dt class="col-sm-6 text-sm-end">{% trans 'organizers'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ tournament.organizers.all|join:", " }}</dd>
|
<dd class="col-sm-6">{{ tournament.organizers.all|join:", " }}</dd>
|
||||||
|
|
||||||
<dt class="col-xl-6 text-end">{% trans 'size'|capfirst %}</dt>
|
<dt class="col-sm-6 text-sm-end">{% trans 'size'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ tournament.max_teams }}</dd>
|
<dd class="col-sm-6">{{ tournament.max_teams }}</dd>
|
||||||
|
|
||||||
<dt class="col-xl-6 text-end">{% trans 'place'|capfirst %}</dt>
|
<dt class="col-sm-6 text-sm-end">{% trans 'place'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ tournament.place }}</dd>
|
<dd class="col-sm-6">{{ tournament.place }}</dd>
|
||||||
|
|
||||||
<dt class="col-xl-6 text-end">{% trans 'price'|capfirst %}</dt>
|
<dt class="col-sm-6 text-sm-end">{% trans 'price'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{% if tournament.price %}{{ tournament.price }} €{% else %}{% trans "Free" %}{% endif %}</dd>
|
<dd class="col-sm-6">{% if tournament.price %}{{ tournament.price }} €{% else %}{% trans "Free" %}{% endif %}</dd>
|
||||||
|
|
||||||
<dt class="col-xl-6 text-end">{% trans 'remote'|capfirst %}</dt>
|
<dt class="col-sm-6 text-sm-end">{% trans 'remote'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ tournament.remote|yesno }}</dd>
|
<dd class="col-sm-6">{{ tournament.remote|yesno }}</dd>
|
||||||
|
|
||||||
<dt class="col-xl-6 text-end">{% trans 'dates'|capfirst %}</dt>
|
<dt class="col-sm-6 text-sm-end">{% trans 'dates'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{% trans "From" %} {{ tournament.date_start }} {% trans "to" %} {{ tournament.date_end }}</dd>
|
<dd class="col-sm-6">{% trans "From" %} {{ tournament.date_start }} {% trans "to" %} {{ tournament.date_end }}</dd>
|
||||||
|
|
||||||
<dt class="col-xl-6 text-end">{% trans 'date of registration closing'|capfirst %}</dt>
|
<dt class="col-sm-6 text-sm-end">{% trans 'date of registration closing'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ tournament.inscription_limit }}</dd>
|
<dd class="col-sm-6">{{ tournament.inscription_limit }}</dd>
|
||||||
|
|
||||||
<dt class="col-xl-6 text-end">{% trans 'date of maximal solution submission'|capfirst %}</dt>
|
<dt class="col-sm-6 text-sm-end">{% trans 'date of maximal solution submission'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ tournament.solution_limit }}</dd>
|
<dd class="col-sm-6">{{ tournament.solution_limit }}</dd>
|
||||||
|
|
||||||
<dt class="col-xl-6 text-end">{% trans 'date of the random draw'|capfirst %}</dt>
|
<dt class="col-sm-6 text-sm-end">{% trans 'date of the random draw'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ tournament.solutions_draw }}</dd>
|
<dd class="col-sm-6">{{ tournament.solutions_draw }}</dd>
|
||||||
|
|
||||||
<dt class="col-xl-6 text-end">{% trans 'date of maximal syntheses submission for the first round'|capfirst %}</dt>
|
<dt class="col-sm-6 text-sm-end">{% trans 'date of maximal syntheses submission for the first round'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ tournament.syntheses_first_phase_limit }}</dd>
|
<dd class="col-sm-6">{{ tournament.syntheses_first_phase_limit }}</dd>
|
||||||
|
|
||||||
<dt class="col-xl-6 text-end">{% trans 'date when solutions of round 2 are available'|capfirst %}</dt>
|
<dt class="col-sm-6 text-sm-end">{% trans 'date when solutions of round 2 are available'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ tournament.solutions_available_second_phase }}</dd>
|
<dd class="col-sm-6">{{ tournament.solutions_available_second_phase }}</dd>
|
||||||
|
|
||||||
<dt class="col-xl-6 text-end">{% trans 'date of maximal syntheses submission for the second round'|capfirst %}</dt>
|
<dt class="col-sm-6 text-sm-end">{% trans 'date of maximal syntheses submission for the second round'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ tournament.syntheses_second_phase_limit }}</dd>
|
<dd class="col-sm-6">{{ tournament.syntheses_second_phase_limit }}</dd>
|
||||||
|
|
||||||
<dt class="col-xl-6 text-end">{% trans 'description'|capfirst %}</dt>
|
<dt class="col-sm-6 text-sm-end">{% trans 'description'|capfirst %}</dt>
|
||||||
<dd class="col-xl-6">{{ tournament.description }}</dd>
|
<dd class="col-sm-6">{{ tournament.description }}</dd>
|
||||||
|
|
||||||
<dt class="col-xl-6 text-end">{% trans 'To contact organizers' %}</dt>
|
<dt class="col-sm-6 text-sm-end">{% trans 'To contact organizers' %}</dt>
|
||||||
<dd class="col-xl-6"><a href="mailto:{{ tournament.organizers_email }}">{{ tournament.organizers_email }}</a></dd>
|
<dd class="col-sm-6"><a href="mailto:{{ tournament.organizers_email }}">{{ tournament.organizers_email }}</a></dd>
|
||||||
|
|
||||||
<dt class="col-xl-6 text-end">{% trans 'To contact juries' %}</dt>
|
<dt class="col-sm-6 text-sm-end">{% trans 'To contact juries' %}</dt>
|
||||||
<dd class="col-xl-6"><a href="mailto:{{ tournament.jurys_email }}">{{ tournament.jurys_email }}</a></dd>
|
<dd class="col-sm-6"><a href="mailto:{{ tournament.jurys_email }}">{{ tournament.jurys_email }}</a></dd>
|
||||||
|
|
||||||
<dt class="col-xl-6 text-end">{% trans 'To contact valid teams' %}</dt>
|
<dt class="col-sm-6 text-sm-end">{% trans 'To contact valid teams' %}</dt>
|
||||||
<dd class="col-xl-6"><a href="mailto:{{ tournament.teams_email }}">{{ tournament.teams_email }}</a></dd>
|
<dd class="col-sm-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 %}
|
{% if available_notes_2 or user.registration.is_volunteer %}
|
||||||
{% 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 %}
|
{% if participation.final and not tournament.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 "Fiche_synthèse.pdf" %}"> PDF</a> —
|
<a class="alert-link" href="{% static "tfjm/Fiche_synthèse.pdf" %}"> PDF</a> —
|
||||||
<a class="alert-link" href="{% static "Fiche_synthèse.tex" %}"> TEX</a> —
|
<a class="alert-link" href="{% static "tfjm/Fiche_synthèse.tex" %}"> TEX</a> —
|
||||||
<a class="alert-link" href="{% static "Fiche_synthèse.odt" %}"> ODT</a> —
|
<a class="alert-link" href="{% static "tfjm/Fiche_synthèse.odt" %}"> ODT</a> —
|
||||||
<a class="alert-link" href="{% static "Fiche_synthèse.docx" %}" title="{% trans "Warning: non-free format" %}"> DOCX</a>
|
<a class="alert-link" href="{% static "tfjm/Fiche_synthèse.docx" %}" title="{% trans "Warning: non-free format" %}"> DOCX</a>
|
||||||
</div>
|
</div>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form|crispy }}
|
{{ form|crispy }}
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.views.generic import TemplateView
|
|
||||||
|
|
||||||
from .views import CreateTeamView, FinalNotationSheetTemplateView, GSheetNotificationsView, JoinTeamView, \
|
from .views import CreateTeamView, FinalNotationSheetTemplateView, GSheetNotificationsView, JoinTeamView, \
|
||||||
MyParticipationDetailView, MyTeamDetailView, NotationSheetsArchiveView, NoteUpdateView, ParticipationDetailView, \
|
MyParticipationDetailView, MyTeamDetailView, NotationSheetsArchiveView, NoteUpdateView, ParticipationDetailView, \
|
||||||
@ -74,5 +73,4 @@ urlpatterns = [
|
|||||||
path("pools/passages/<int:pk>/update/", PassageUpdateView.as_view(), name="passage_update"),
|
path("pools/passages/<int:pk>/update/", PassageUpdateView.as_view(), name="passage_update"),
|
||||||
path("pools/passages/<int:pk>/solution/", SynthesisUploadView.as_view(), name="upload_synthesis"),
|
path("pools/passages/<int:pk>/solution/", SynthesisUploadView.as_view(), name="upload_synthesis"),
|
||||||
path("pools/passages/notes/<int:pk>/", NoteUpdateView.as_view(), name="update_notes"),
|
path("pools/passages/notes/<int:pk>/", NoteUpdateView.as_view(), name="update_notes"),
|
||||||
path("chat/", TemplateView.as_view(template_name="participation/chat.html"), name="chat")
|
|
||||||
]
|
]
|
||||||
|
@ -259,17 +259,20 @@ 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 = dict(domain=domain, registration=registration, team=self.object, payment=payment,
|
mail_context_plain = dict(domain=domain, registration=registration, team=self.object, payment=payment,
|
||||||
message=form.cleaned_data["message"])
|
message=form.cleaned_data["message"])
|
||||||
mail_plain = render_to_string("participation/mails/team_validated.txt", mail_context)
|
mail_context_html = dict(domain=domain, registration=registration, team=self.object, payment=payment,
|
||||||
mail_html = render_to_string("participation/mails/team_validated.html", mail_context)
|
message=form.cleaned_data["message"].replace('\n', '<br>'))
|
||||||
|
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 = dict(team=self.object, message=form.cleaned_data["message"])
|
mail_context_plain = dict(team=self.object, message=form.cleaned_data["message"])
|
||||||
mail_plain = render_to_string("participation/mails/team_not_validated.txt", mail_context)
|
mail_context_html = dict(team=self.object, message=form.cleaned_data["message"].replace('\n', '<br>'))
|
||||||
mail_html = render_to_string("participation/mails/team_not_validated.html", 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_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:
|
||||||
|
@ -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 "Fiche_sanitaire.pdf" %}">{% trans "Download" %}</a>
|
<a class="alert-link" href="{% static "tfjm/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-end">{% trans "Last name:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "First name:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Email:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Password:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Team:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Birth date:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Gender:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Address:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Phone number:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Health issues:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Housing constraints:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Photo authorization:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Photo authorization (final):" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Health sheet:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Vaccine sheet:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Parental authorization:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Parental authorization (final):" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Student class:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "School:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Responsible name:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Responsible phone number:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Responsible email address:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Most recent degree:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Professional activity:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Professional activity:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Admin:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">{% trans "Grant Animath to contact me in the future about other actions:" %}</dt>
|
<dt class="col-sm-6 text-sm-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-end">
|
<dt class="col-sm-6 text-sm-end">
|
||||||
{% if payment.final %}
|
{% if payment.final %}
|
||||||
{% trans "Payment information (final):" %}
|
{% trans "Payment information (final):" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
@ -5,14 +5,15 @@ 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
|
||||||
elasticsearch~=7.17.9
|
git+https://github.com/django-haystack/django-haystack.git#v3.3b2
|
||||||
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
|
||||||
*/2 * * * * cd /code && python manage.py fix_sympa_lists &> /dev/null
|
7 3 * * * 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
|
||||||
|
@ -22,13 +22,13 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tfjm.settings')
|
|||||||
django_asgi_app = get_asgi_application()
|
django_asgi_app = get_asgi_application()
|
||||||
|
|
||||||
# useful since the import must be done after the application initialization
|
# useful since the import must be done after the application initialization
|
||||||
import draw.routing # noqa: E402, I202
|
import tfjm.routing # noqa: E402, I202
|
||||||
|
|
||||||
application = ProtocolTypeRouter(
|
application = ProtocolTypeRouter(
|
||||||
{
|
{
|
||||||
"http": django_asgi_app,
|
"http": django_asgi_app,
|
||||||
"websocket": AllowedHostsOriginValidator(
|
"websocket": AllowedHostsOriginValidator(
|
||||||
AuthMiddlewareStack(URLRouter(draw.routing.websocket_urlpatterns))
|
AuthMiddlewareStack(URLRouter(tfjm.routing.websocket_urlpatterns))
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
19
tfjm/permissions.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Copyright (C) 2024 by Animath
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionType(models.TextChoices):
|
||||||
|
ANONYMOUS = 'anonymous', _("Everyone, including anonymous users")
|
||||||
|
AUTHENTICATED = 'authenticated', _("Authenticated users")
|
||||||
|
VOLUNTEER = 'volunteer', _("All volunteers")
|
||||||
|
TOURNAMENT_MEMBER = 'tournament', _("All members of a given tournament")
|
||||||
|
TOURNAMENT_ORGANIZER = 'organizer', _("Tournament organizers only")
|
||||||
|
TOURNAMENT_JURY_PRESIDENT = 'jury_president', _("Tournament organizers and jury presidents of the tournament")
|
||||||
|
JURY_MEMBER = 'jury', _("Jury members of the pool")
|
||||||
|
POOL_MEMBER = 'pool', _("Jury members and participants of the pool")
|
||||||
|
TEAM_MEMBER = 'team', _("Members of the team and organizers of concerned tournaments")
|
||||||
|
PRIVATE = 'private', _("Private, reserved to explicit authorized users")
|
||||||
|
ADMIN = 'admin', _("Admin users")
|
11
tfjm/routing.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Copyright (C) 2024 by Animath
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import chat.consumers
|
||||||
|
from django.urls import path
|
||||||
|
import draw.consumers
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
path("ws/chat/", chat.consumers.ChatConsumer.as_asgi()),
|
||||||
|
path("ws/draw/", draw.consumers.DrawConsumer.as_asgi()),
|
||||||
|
]
|
@ -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/3.0/topics/settings/
|
https://docs.djangoproject.com/en/5.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/3.0/ref/settings/
|
https://docs.djangoproject.com/en/5.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/3.0/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/5.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,11 +63,13 @@ INSTALLED_APPS = [
|
|||||||
'haystack',
|
'haystack',
|
||||||
'logs',
|
'logs',
|
||||||
'phonenumber_field',
|
'phonenumber_field',
|
||||||
|
'pipeline',
|
||||||
'polymorphic',
|
'polymorphic',
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'rest_framework.authtoken',
|
'rest_framework.authtoken',
|
||||||
|
|
||||||
'api',
|
'api',
|
||||||
|
'chat',
|
||||||
'draw',
|
'draw',
|
||||||
'registration',
|
'registration',
|
||||||
'participation',
|
'participation',
|
||||||
@ -94,6 +96,8 @@ 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',
|
||||||
]
|
]
|
||||||
@ -101,6 +105,7 @@ MIDDLEWARE = [
|
|||||||
ROOT_URLCONF = 'tfjm.urls'
|
ROOT_URLCONF = 'tfjm.urls'
|
||||||
|
|
||||||
LOGIN_REDIRECT_URL = "index"
|
LOGIN_REDIRECT_URL = "index"
|
||||||
|
LOGOUT_REDIRECT_URL = "login"
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
@ -124,7 +129,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/3.0/ref/settings/#auth-password-validators
|
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
@ -159,7 +164,7 @@ REST_FRAMEWORK = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/3.0/topics/i18n/
|
# https://docs.djangoproject.com/en/5.0/topics/i18n/
|
||||||
|
|
||||||
LANGUAGE_CODE = 'en'
|
LANGUAGE_CODE = 'en'
|
||||||
|
|
||||||
@ -179,7 +184,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/3.0/howto/static-files/
|
# https://docs.djangoproject.com/en/5.0/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = '/static/'
|
STATIC_URL = '/static/'
|
||||||
|
|
||||||
@ -189,6 +194,70 @@ 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', 'plateforme.tfjm.org']
|
ALLOWED_HOSTS = ['inscription.tfjm.org', 'inscriptions.tfjm.org', 'plateforme.tfjm.org']
|
||||||
|
|
||||||
# Emails
|
# Emails
|
||||||
EMAIL_BACKEND = 'mailer.backend.DbBackend'
|
EMAIL_BACKEND = 'mailer.backend.DbBackend'
|
||||||
@ -27,7 +27,7 @@ SESSION_COOKIE_SECURE = False
|
|||||||
CSRF_COOKIE_SECURE = False
|
CSRF_COOKIE_SECURE = False
|
||||||
CSRF_COOKIE_HTTPONLY = False
|
CSRF_COOKIE_HTTPONLY = False
|
||||||
X_FRAME_OPTIONS = 'DENY'
|
X_FRAME_OPTIONS = 'DENY'
|
||||||
SESSION_COOKIE_AGE = 60 * 60 * 3
|
SESSION_COOKIE_AGE = 60 * 60 * 24 * 7 * 2 # 2 weeks
|
||||||
|
|
||||||
CHANNEL_LAYERS = {
|
CHANNEL_LAYERS = {
|
||||||
"default": {
|
"default": {
|
||||||
|
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 |
BIN
tfjm/static/tfjm/img/tfjm-192.png
Normal file
After Width: | Height: | Size: 7.1 KiB |
BIN
tfjm/static/tfjm/img/tfjm-512.png
Normal file
After Width: | Height: | Size: 20 KiB |
91
tfjm/static/tfjm/img/tfjm-square.svg
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
version="1.1"
|
||||||
|
id="svg27"
|
||||||
|
sodipodi:docname="logo.svg"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
inkscape:version="0.92.2 2405546, 2018-03-11">
|
||||||
|
<style>
|
||||||
|
path {
|
||||||
|
fill: black;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<metadata
|
||||||
|
id="metadata31">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title></dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<defs
|
||||||
|
id="defs15">
|
||||||
|
<path
|
||||||
|
id="b"
|
||||||
|
d="m 2.58,-3.347 c 0.409,0 1.405,0.02 1.485,1.135 0.01,0.12 0.02,0.25 0.18,0.25 0.168,0 0.168,-0.14 0.168,-0.32 v -2.7 c 0,-0.159 0,-0.318 -0.169,-0.318 -0.13,0 -0.17,0.1 -0.18,0.21 -0.059,1.155 -0.756,1.354 -1.484,1.384 v -2.102 c 0,-0.668 0.19,-0.668 0.429,-0.668 h 0.468 c 1.275,0 1.923,0.688 1.983,1.375 0.01,0.08 0.02,0.23 0.179,0.23 0.17,0 0.17,-0.16 0.17,-0.33 v -1.295 c 0,-0.308 -0.02,-0.328 -0.33,-0.328 h -5 c -0.18,0 -0.34,0 -0.34,0.179 0,0.17 0.19,0.17 0.27,0.17 0.567,0 0.607,0.079 0.607,0.567 v 4.991 c 0,0.469 -0.03,0.568 -0.558,0.568 -0.15,0 -0.319,0 -0.319,0.17 C 0.14,0 0.3,0 0.48,0 h 2.878 c 0.18,0 0.33,0 0.33,-0.18 0,-0.169 -0.17,-0.169 -0.3,-0.169 -0.767,0 -0.807,-0.07 -0.807,-0.597 v -2.401 z m 2.88,-3.129 v 0.469 A 2.557,2.557 0 0 0 4.922,-6.476 Z M 4.065,-3.158 A 1.51,1.51 0 0 0 3.537,-3.547 c 0.189,-0.09 0.388,-0.249 0.528,-0.418 z m -2.7,-2.77 c 0,-0.12 0,-0.368 -0.08,-0.548 h 1.056 c -0.11,0.23 -0.11,0.558 -0.11,0.648 v 4.901 c 0,0.15 0,0.389 0.1,0.578 H 1.285 c 0.08,-0.179 0.08,-0.428 0.08,-0.548 v -5.03 z" />
|
||||||
|
<path
|
||||||
|
id="c"
|
||||||
|
d="m 1.564,-6.824 c -0.18,0 -0.339,0 -0.339,0.179 0,0.17 0.18,0.17 0.29,0.17 0.687,0 0.727,0.069 0.727,0.577 v 5.59 c 0,0.169 0,0.358 -0.17,0.527 -0.08,0.07 -0.239,0.18 -0.478,0.18 -0.07,0 -0.369,0 -0.369,-0.11 0,-0.08 0.04,-0.12 0.09,-0.17 A 0.704,0.704 0 0 0 0.777,-1.057 0.704,0.704 0 0 0 0.06,-0.359 c 0,0.629 0.637,1.106 1.604,1.106 1.106,0 2.042,-0.387 2.192,-1.614 0.01,-0.09 0.01,-0.647 0.01,-0.966 v -4.184 c 0,-0.449 0.139,-0.449 0.707,-0.459 0.09,0 0.17,-0.08 0.17,-0.17 0,-0.178 -0.15,-0.178 -0.33,-0.178 z M 0.867,0.239 C 0.767,0.19 0.408,0.02 0.408,-0.349 c 0,-0.259 0.22,-0.358 0.37,-0.358 0.168,0 0.368,0.12 0.368,0.348 0,0.15 -0.08,0.24 -0.12,0.27 -0.04,0.04 -0.13,0.139 -0.16,0.328 z M 2.59,-5.918 c 0,-0.11 0,-0.378 -0.09,-0.558 h 1.097 c -0.08,0.18 -0.08,0.369 -0.08,0.708 v 4.015 c 0,0.298 0,0.797 -0.01,0.896 C 3.427,-0.349 3.198,0.11 2.44,0.31 2.59,0.08 2.59,-0.109 2.59,-0.288 v -5.629 z" />
|
||||||
|
<path
|
||||||
|
id="d"
|
||||||
|
d="M 4.643,-2.092 2.74,-6.625 c -0.08,-0.2 -0.09,-0.2 -0.359,-0.2 H 0.528 c -0.18,0 -0.329,0 -0.329,0.18 0,0.17 0.18,0.17 0.23,0.17 0.119,0 0.388,0.02 0.607,0.099 v 5.32 c 0,0.21 0,0.648 -0.677,0.707 -0.19,0.02 -0.19,0.16 -0.19,0.17 C 0.17,0 0.33,0 0.51,0 h 1.543 c 0.18,0 0.33,0 0.33,-0.18 0,-0.089 -0.08,-0.159 -0.16,-0.169 -0.767,-0.06 -0.767,-0.478 -0.767,-0.707 v -4.961 l 0.01,-0.01 2.429,5.817 c 0.08,0.18 0.15,0.209 0.21,0.209 0.12,0 0.149,-0.08 0.199,-0.2 l 2.44,-5.827 0.01,0.01 v 4.961 c 0,0.21 0,0.648 -0.677,0.707 -0.19,0.02 -0.19,0.16 -0.19,0.17 0,0.179 0.16,0.179 0.34,0.179 h 2.66 c 0.179,0 0.328,0 0.328,-0.18 C 9.215,-0.27 9.135,-0.34 9.056,-0.35 8.289,-0.41 8.289,-0.828 8.289,-1.057 v -4.712 c 0,-0.21 0,-0.648 0.677,-0.708 0.1,-0.01 0.19,-0.06 0.19,-0.17 0,-0.178 -0.15,-0.178 -0.33,-0.178 H 6.905 c -0.259,0 -0.279,0 -0.369,0.209 z m -0.3,0.18 c 0.08,0.169 0.09,0.178 0.21,0.218 L 4.115,-0.638 H 4.095 L 1.823,-6.058 C 1.773,-6.187 1.693,-6.356 1.554,-6.476 h 0.867 l 1.923,4.563 z M 1.336,-0.35 h -0.17 c 0.02,-0.03 0.04,-0.06 0.06,-0.08 0.01,-0.01 0.01,-0.02 0.02,-0.03 z M 7.104,-6.477 H 8.16 c -0.219,0.25 -0.219,0.508 -0.219,0.688 v 4.752 c 0,0.18 0,0.438 0.23,0.687 H 6.883 c 0.22,-0.249 0.22,-0.508 0.22,-0.687 v -5.44 z" />
|
||||||
|
<path
|
||||||
|
id="a"
|
||||||
|
d="m 4.135,-6.466 c 1.305,0.07 1.793,0.917 1.833,1.385 0.01,0.15 0.02,0.299 0.179,0.299 0.18,0 0.18,-0.17 0.18,-0.359 v -1.325 c 0,-0.348 -0.04,-0.358 -0.34,-0.358 H 0.658 c -0.308,0 -0.328,0.02 -0.328,0.318 V -5.1 c 0,0.16 0,0.319 0.17,0.319 0.17,0 0.178,-0.18 0.178,-0.2 0.04,-0.826 0.788,-1.424 1.834,-1.484 v 5.54 c 0,0.498 -0.04,0.577 -0.668,0.577 -0.12,0 -0.299,0 -0.299,0.17 0,0.179 0.16,0.179 0.339,0.179 h 2.89 C 4.95,0 5.1,0 5.1,-0.18 c 0,-0.169 -0.17,-0.169 -0.28,-0.169 -0.647,0 -0.686,-0.07 -0.686,-0.578 v -5.539 z m -3.458,-0.01 h 0.598 c -0.249,0.15 -0.458,0.349 -0.598,0.518 z m 5.3,0 v 0.528 A 2.606,2.606 0 0 0 5.37,-6.476 H 5.978 Z M 2.77,-0.349 c 0.09,-0.179 0.09,-0.428 0.09,-0.558 v -5.569 h 0.926 v 5.57 c 0,0.129 0,0.378 0.09,0.557 H 2.77 Z" />
|
||||||
|
<path
|
||||||
|
id="e"
|
||||||
|
d="M 3.522,-1.27 H 3.285 c -0.021,0.154 -0.091,0.566 -0.182,0.635 -0.055,0.042 -0.592,0.042 -0.69,0.042 H 1.13 c 0.732,-0.648 0.976,-0.844 1.395,-1.171 0.516,-0.412 0.997,-0.844 0.997,-1.507 0,-0.844 -0.74,-1.36 -1.632,-1.36 -0.865,0 -1.45,0.607 -1.45,1.249 0,0.355 0.3,0.39 0.369,0.39 0.167,0 0.37,-0.118 0.37,-0.37 0,-0.125 -0.05,-0.369 -0.412,-0.369 0.216,-0.495 0.69,-0.649 1.018,-0.649 0.698,0 1.06,0.544 1.06,1.11 0,0.606 -0.432,1.087 -0.655,1.338 l -1.68,1.66 C 0.44,-0.209 0.44,-0.195 0.44,0 h 2.873 z" />
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%"
|
||||||
|
rx="10px" ry="10px" stroke-linejoin="round"
|
||||||
|
style="fill: white;" />
|
||||||
|
<use
|
||||||
|
x="0.5"
|
||||||
|
y="19.5"
|
||||||
|
xlink:href="#a"
|
||||||
|
id="use17"
|
||||||
|
width="100%"
|
||||||
|
height="100%" />
|
||||||
|
<use
|
||||||
|
x="7.5"
|
||||||
|
y="19.5"
|
||||||
|
xlink:href="#b"
|
||||||
|
id="use19"
|
||||||
|
width="100%"
|
||||||
|
height="100%" />
|
||||||
|
<use
|
||||||
|
x="13"
|
||||||
|
y="19.5"
|
||||||
|
xlink:href="#c"
|
||||||
|
id="use21"
|
||||||
|
width="100%"
|
||||||
|
height="100%" />
|
||||||
|
<use
|
||||||
|
x="18"
|
||||||
|
y="19.5"
|
||||||
|
xlink:href="#d"
|
||||||
|
id="use23"
|
||||||
|
width="100%"
|
||||||
|
height="100%" />
|
||||||
|
<use
|
||||||
|
x="27"
|
||||||
|
y="14"
|
||||||
|
xlink:href="#e"
|
||||||
|
id="use25"
|
||||||
|
width="100%"
|
||||||
|
height="100%" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 6.2 KiB |
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
@ -1,4 +1,6 @@
|
|||||||
{% load i18n static %}
|
{% load i18n %}
|
||||||
|
{% 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 %}
|
||||||
@ -16,19 +18,12 @@
|
|||||||
<meta name="theme-color" content="#ffffff">
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|
||||||
{# Bootstrap CSS #}
|
{# Bootstrap CSS #}
|
||||||
<link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
|
{% stylesheet 'bootstrap_fontawesome' %}
|
||||||
<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 #}
|
||||||
<script src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script>
|
{% javascript 'bootstrap' %}
|
||||||
|
|
||||||
{# bootstrap-select for beautiful selects and JQuery dependency #}
|
{# bootstrap-select for beautiful selects and JQuery dependency #}
|
||||||
<script src="{% static 'jquery/jquery.min.js' %}"></script>
|
{% javascript 'bootstrap_select' %}
|
||||||
<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 %}
|
||||||
@ -40,18 +35,18 @@
|
|||||||
<body class="d-flex w-100 h-100 flex-column">
|
<body class="d-flex w-100 h-100 flex-column">
|
||||||
{% include "navbar.html" %}
|
{% include "navbar.html" %}
|
||||||
|
|
||||||
<div id="body-wrapper" class="row w-100 my-3">
|
<div id="body-wrapper" class="row w-100 my-3 flex-grow-1">
|
||||||
<aside class="col-lg-2 px-2">
|
<aside class="col-lg-2 px-2">
|
||||||
{% include "sidebar.html" %}
|
{% include "sidebar.html" %}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="col d-flex flex-column">
|
<main class="col d-flex flex-column flex-grow-1">
|
||||||
<div class="container">
|
<div class="container d-flex flex-column flex-grow-1">
|
||||||
{% block content-title %}<h1 id="content-title">{{ title }}</h1>{% endblock %}
|
{% block content-title %}<h1 id="content-title">{{ title }}</h1>{% endblock %}
|
||||||
|
|
||||||
{% include "messages.html" %}
|
{% include "messages.html" %}
|
||||||
|
|
||||||
<div id="content">
|
<div id="content" class="d-flex flex-column flex-grow-1">
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p>Default content...</p>
|
<p>Default content...</p>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
@ -87,8 +82,7 @@
|
|||||||
{% include "base_modal.html" with modal_id="login" %}
|
{% include "base_modal.html" with modal_id="login" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script src="{% static 'main.js' %}"></script>
|
{% javascript 'main' %}
|
||||||
<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-end">
|
<div class="col-sm-1 text-sm-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-end">
|
<div class="col-sm text-sm-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.svg" %}" style="height: 2em;" alt="Logo TFJM²" id="navbar-logo">
|
<img src="{% static "tfjm/img/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"
|
||||||
@ -61,12 +61,12 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
<li class="nav-item active">
|
||||||
<li class="nav-item active d-none">
|
<a class="nav-link" href="{% url "chat:chat" %}">
|
||||||
<a class="nav-link" href="{% url "participation:chat" %}">
|
|
||||||
<i class="fas fa-comments"></i> {% trans "Chat" %}
|
<i class="fas fa-comments"></i> {% trans "Chat" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% endif %}
|
||||||
{% if user.registration.is_admin %}
|
{% if user.registration.is_admin %}
|
||||||
<li class="nav-item active">
|
<li class="nav-item active">
|
||||||
<a class="nav-link" href="{% url "admin:index" %}"><i class="fas fa-cog"></i> {% trans "Administration" %}</a>
|
<a class="nav-link" href="{% url "admin:index" %}"><i class="fas fa-cog"></i> {% trans "Administration" %}</a>
|
||||||
@ -111,9 +111,12 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item" href="{% url "logout" %}">
|
<form action="{% url 'logout' %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="dropdown-item">
|
||||||
<i class="fas fa-sign-out-alt"></i> {% trans "Log out" %}
|
<i class="fas fa-sign-out-alt"></i> {% trans "Log out" %}
|
||||||
</a>
|
</button>
|
||||||
|
</form>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
23
tfjm/templates/registration/includes/login.html
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{% load i18n crispy_forms_filters %}
|
||||||
|
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<p class="errornote">
|
||||||
|
{% blocktrans trimmed %}
|
||||||
|
You are authenticated as {{ user }}, but are not authorized to
|
||||||
|
access this page. Would you like to login to a different account?
|
||||||
|
{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" id="login-form">
|
||||||
|
<div id="form-content">
|
||||||
|
{{ form|as_crispy_errors }}
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.username|as_crispy_field }}
|
||||||
|
<div class="form-text mb-3">
|
||||||
|
<i class="fas fa-info-circle"></i> {% trans "Your username is your e-mail address." %}
|
||||||
|
</div>
|
||||||
|
{{ form.password|as_crispy_field }}
|
||||||
|
<a href="{% url 'password_reset' %}" class="badge text-bg-warning">{% trans 'Forgotten your password?' %}</a>
|
||||||
|
</div>
|
||||||
|
<input type="submit" value="{% trans 'Log in' %}" class="btn btn-primary">
|
||||||
|
</form>
|
@ -2,31 +2,11 @@
|
|||||||
{% comment %}
|
{% comment %}
|
||||||
SPDX-License-Identifier: GPL-2.0-or-later
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
{% load i18n crispy_forms_filters %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block title %}{% trans "Log in" %}{% endblock %}
|
{% block title %}{% trans "Log in" %}{% endblock %}
|
||||||
{% block content-title %}<h1>{% trans "Log in" %}</h1>{% endblock %}
|
{% block content-title %}<h1>{% trans "Log in" %}</h1>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if user.is_authenticated %}
|
{% include "registration/includes/login.html" %}
|
||||||
<p class="errornote">
|
|
||||||
{% blocktrans trimmed %}
|
|
||||||
You are authenticated as {{ user }}, but are not authorized to
|
|
||||||
access this page. Would you like to login to a different account?
|
|
||||||
{% endblocktrans %}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
<form method="post" id="login-form">
|
|
||||||
<div id="form-content">
|
|
||||||
{{ form|as_crispy_errors }}
|
|
||||||
{% csrf_token %}
|
|
||||||
{{ form.username|as_crispy_field }}
|
|
||||||
<div class="form-text mb-3">
|
|
||||||
<i class="fas fa-info-circle"></i> {% trans "Your username is your e-mail address." %}
|
|
||||||
</div>
|
|
||||||
{{ form.password|as_crispy_field }}
|
|
||||||
<a href="{% url 'password_reset' %}" class="badge text-bg-warning">{% trans 'Forgotten your password?' %}</a>
|
|
||||||
</div>
|
|
||||||
<input type="submit" value="{% trans 'Log in' %}" class="btn btn-primary">
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -37,6 +37,7 @@ urlpatterns = [
|
|||||||
path('search/', AdminSearchView.as_view(), name="haystack_search"),
|
path('search/', AdminSearchView.as_view(), name="haystack_search"),
|
||||||
|
|
||||||
path('api/', include('api.urls')),
|
path('api/', include('api.urls')),
|
||||||
|
path('chat/', include('chat.urls')),
|
||||||
path('draw/', include('draw.urls')),
|
path('draw/', include('draw.urls')),
|
||||||
path('participation/', include('participation.urls')),
|
path('participation/', include('participation.urls')),
|
||||||
path('registration/', include('registration.urls')),
|
path('registration/', include('registration.urls')),
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.views.generic import TemplateView
|
||||||
from haystack.generic_views import SearchView
|
from haystack.generic_views import SearchView
|
||||||
|
|
||||||
|
|
||||||
@ -40,5 +41,9 @@ class UserRegistrationMixin(LoginRequiredMixin):
|
|||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequiredTemplateView(LoginRequiredMixin, TemplateView):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class AdminSearchView(AdminMixin, SearchView):
|
class AdminSearchView(AdminMixin, SearchView):
|
||||||
pass
|
pass
|
||||||
|
4
tox.ini
@ -13,7 +13,7 @@ deps = coverage
|
|||||||
|
|
||||||
commands =
|
commands =
|
||||||
python manage.py compilemessages -i .tox -i venv
|
python manage.py compilemessages -i .tox -i venv
|
||||||
coverage run --source=api,draw,logs,participation,registration,tfjm ./manage.py test api/ draw/ logs/ participation/ registration/ tfjm/
|
coverage run --source=api,draw,logs,participation,registration,tfjm ./manage.py test api/ chat/ draw/ logs/ participation/ registration/ tfjm/
|
||||||
coverage report -m
|
coverage report -m
|
||||||
|
|
||||||
[testenv:linters]
|
[testenv:linters]
|
||||||
@ -26,7 +26,7 @@ deps =
|
|||||||
pep8-naming
|
pep8-naming
|
||||||
pyflakes
|
pyflakes
|
||||||
commands =
|
commands =
|
||||||
flake8 api/ draw/ logs/ participation/ registration/ tfjm/
|
flake8 api/ chat/ss draw/ logs/ participation/ registration/ tfjm/
|
||||||
|
|
||||||
[flake8]
|
[flake8]
|
||||||
exclude =
|
exclude =
|
||||||
|