mirror of
https://gitlab.com/animath/si/plateforme.git
synced 2025-06-24 18:20:33 +02:00
Compare commits
155 Commits
a44439671e
...
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
|
|||
a594b268ea
|
|||
0bc5ef0a7f
|
|||
943276ef71
|
|||
13c815c62c
|
|||
35e3be8af3
|
|||
720de380d1
|
|||
ecf80f8b81
|
|||
3ca0148934
|
|||
58608ea5ff
|
|||
68da61a33b
|
|||
86e978faf2
|
|||
0845d0bfb6
|
|||
f457a2355e
|
|||
bacdd5cfcf
|
|||
3e24e10780
|
|||
adc4634f3e
|
|||
266afaf5c9
|
|||
059cae75c5
|
|||
91a1837c99
|
|||
b24201c529
|
|||
53302db56a
|
|||
49fda3df49
|
|||
3a0a98a331
|
|||
21c4d5d7f5
|
|||
338a19ec32
|
|||
5bfcaab831
|
|||
49e5d97ec9
|
|||
0e185f5046
|
|||
ab7cdd56cc
|
|||
7edd43f626
|
|||
aca23eaf8b
|
|||
a02697a3a7
|
|||
d3d72e090c
|
|||
6c76f1e633
|
|||
4a094002f0
|
|||
3045857897
|
|||
7a0b93b151
|
|||
7073f64aa6
|
|||
b4fc976197
|
|||
7a004596ca
|
|||
1493df0078
|
|||
7732a737bb
|
|||
b942baea17
|
|||
188b83ce2d
|
|||
29d9432ca2
|
|||
0181a1392d
|
|||
ec0419a6d7
|
|||
54016a1fbf
|
|||
7ae015cef9
|
|||
ea264fbca6
|
|||
758f714096
|
|||
40d24740ed
|
|||
b7344566ef
|
|||
0f5d0c8b40
|
|||
c45071c038
|
|||
aac4fc59e6
|
|||
78a43148a8
|
|||
ceedd0678c
|
|||
d13385fa01
|
|||
8996fc2cca
|
|||
65dcc978c1
|
|||
923b07b97e
|
|||
84860a2875
|
|||
6add9a1419
|
|||
eddb741eb7
|
|||
a763abf781
|
|||
78e8a92c3a
|
|||
424dee4aea
|
|||
a381b5583c
|
|||
867ee7efe1
|
|||
32b2d7239c
|
|||
6ce179bd60
|
|||
dba937fb03
|
|||
4efce6e325
|
|||
10a42d3633
|
|||
bb579d640c
|
|||
d7b4233282
|
|||
9092cf1846
|
|||
37b86d4ea0
|
|||
40988348d3
|
|||
1cbf95e6e1
|
|||
c4ec6a6f29
|
|||
779aec5e55
|
|||
bf5c673739
|
|||
a62e906b0e
|
|||
630633bab4
|
|||
8d7d7cd645
|
|||
e53575d31d
|
|||
412ff4e067
|
|||
29b01ebb13
|
|||
30b9a73df8
|
|||
572a6c3299
|
|||
c135da1f47
|
|||
6867c2cc2d
|
|||
1e7bd209a1
|
|||
109b603b7a
|
|||
6595409df0
|
|||
f1012efcaa
|
|||
5261a52401
|
|||
a914237f66
|
|||
2019c5c434
|
|||
234b84ef60
|
|||
b9295cc199
|
|||
3fae6a00dd
|
|||
37ad3cf8a6
|
|||
c522387482
|
|||
0006ecc90d
|
|||
6b16ed3cc8
|
@ -3,10 +3,12 @@ FROM python:3.12-alpine
|
||||
ENV PYTHONUNBUFFERED 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 npm install -g yuglify
|
||||
|
||||
RUN mkdir /code /code/docs
|
||||
WORKDIR /code
|
||||
COPY requirements.txt /code/requirements.txt
|
||||
|
2
chat/__init__.py
Normal file
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
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
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
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/__init__.py
Normal file
0
chat/management/commands/__init__.py
Normal file
0
chat/management/commands/__init__.py
Normal file
166
chat/management/commands/create_chat_channels.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
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
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
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
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
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
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
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
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
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
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
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
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
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:
|
||||
- tfjm
|
||||
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.tls.certresolver=mytlschallenge"
|
||||
|
||||
|
@ -3,10 +3,13 @@
|
||||
|
||||
from collections import OrderedDict
|
||||
import json
|
||||
import os
|
||||
from random import randint, shuffle
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils import translation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@ -42,10 +45,17 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
We accept only if this is a user of a team of the associated tournament, or a volunteer
|
||||
of the tournament.
|
||||
"""
|
||||
if '_fake_user_id' in self.scope['session']:
|
||||
self.scope['user'] = await User.objects.aget(pk=self.scope['session']['_fake_user_id'])
|
||||
|
||||
# Fetch the registration of the current user
|
||||
user = self.scope['user']
|
||||
reg = await Registration.objects.aget(user=user)
|
||||
if user.is_anonymous:
|
||||
# User is not authenticated
|
||||
await self.close()
|
||||
return
|
||||
|
||||
reg = await Registration.objects.aget(user_id=user.id)
|
||||
self.registration = reg
|
||||
|
||||
# Accept the connection
|
||||
@ -69,6 +79,10 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
Called when the websocket got disconnected, for any reason.
|
||||
:param close_code: The error code.
|
||||
"""
|
||||
if self.scope['user'].is_anonymous:
|
||||
# User is not authenticated
|
||||
return
|
||||
|
||||
# Unregister from channel layers
|
||||
if not self.registration.is_volunteer:
|
||||
await self.channel_layer.group_discard(f"team-{self.registration.team.trigram}", self.channel_name)
|
||||
@ -445,10 +459,10 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
td2 = await TeamDraw.objects.filter(participation=td.participation, round=round2).aget()
|
||||
td2.pool = round2_pools[current_pool_id]
|
||||
td2.passage_index = current_passage_index
|
||||
if len(round2_pools) == 1 and len(tds) == 5:
|
||||
# Exchange teams 1 and 5 if there is only one pool with 5 teams
|
||||
if i == 0 or i == 4:
|
||||
td2.passage_index = 4 - i
|
||||
if len(round2_pools) == 1:
|
||||
# Exchange first and last team if there is only one pool
|
||||
if i == 0 or i == len(tds) - 1:
|
||||
td2.passage_index = len(tds) - 1 - i
|
||||
current_passage_index += 1
|
||||
await td2.asave()
|
||||
|
||||
@ -491,8 +505,9 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
msg = "Les résultats des dés sont les suivants : "
|
||||
msg += ", ".join(f"<strong>{td.participation.team.trigram}</strong> ({td.passage_dice})" for td in tds)
|
||||
msg += ". L'ordre de passage et les compositions des différentes poules sont affiché⋅es sur le côté. "
|
||||
msg += "Attention : les ordres de passage sont déterminés à partir des scores des dés, mais ne sont pas "
|
||||
msg += "directement l'ordre croissant des dés, afin d'avoir des poules mélangées."
|
||||
msg += "Les ordres de passage pour le premier tour sont déterminés à partir des scores des dés, "
|
||||
msg += "dans l'ordre croissant. Pour le deuxième tour, les ordres de passage sont déterminés à partir "
|
||||
msg += "des ordres de passage du premier tour."
|
||||
self.tournament.draw.last_message = msg
|
||||
await self.tournament.draw.asave()
|
||||
|
||||
@ -625,6 +640,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
and isinstance(kwargs['problem'], int) and (1 <= kwargs['problem'] <= len(settings.PROBLEMS)):
|
||||
# Admins can force the draw
|
||||
problem = int(kwargs['problem'])
|
||||
break
|
||||
|
||||
# Check that the user didn't already accept this problem for the first round
|
||||
# if this is the second round
|
||||
@ -925,7 +941,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
if already_refused:
|
||||
msg += "Cela n'ajoute pas de pénalité."
|
||||
else:
|
||||
msg += "Cela ajoute une pénalité de 0.5 sur le coefficient de l'oral de la défense."
|
||||
msg += "Cela ajoute une pénalité de 25 % sur le coefficient de l'oral de la défense."
|
||||
self.tournament.draw.last_message = msg
|
||||
await self.tournament.draw.asave()
|
||||
|
||||
@ -979,15 +995,19 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
if not await Draw.objects.filter(tournament=self.tournament).aexists():
|
||||
return await self.alert(_("The draw has not started yet."), 'danger')
|
||||
|
||||
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.export_visibility',
|
||||
'visible': False})
|
||||
|
||||
# Export each exportable pool
|
||||
async for r in self.tournament.draw.round_set.all():
|
||||
async for pool in r.pool_set.all():
|
||||
if await pool.is_exportable():
|
||||
await pool.export()
|
||||
|
||||
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.export_visibility',
|
||||
'visible': False})
|
||||
# Update Google Sheets final sheet
|
||||
if os.getenv('GOOGLE_PRIVATE_KEY_ID', None):
|
||||
await sync_to_async(self.tournament.update_ranking_spreadsheet)()
|
||||
|
||||
@ensure_orga
|
||||
async def continue_final(self, **kwargs):
|
||||
@ -1003,7 +1023,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
r2 = await self.tournament.draw.round_set.filter(number=2).aget()
|
||||
self.tournament.draw.current_round = r2
|
||||
msg = "Le tirage au sort pour le tour 2 va commencer. " \
|
||||
"L'ordre de passage est déterminé à partir du classement du premier tour."
|
||||
"L'ordre de passage est déterminé à partir du classement du premier tour, " \
|
||||
"de sorte à mélanger les équipes entre les deux jours."
|
||||
self.tournament.draw.last_message = msg
|
||||
await self.tournament.draw.asave()
|
||||
|
||||
|
28
draw/migrations/0003_alter_teamdraw_options.py
Normal file
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,6 +1,8 @@
|
||||
# Copyright (C) 2023 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import os
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
@ -146,7 +148,7 @@ class Draw(models.Model):
|
||||
# The problem can be rejected
|
||||
s += "Elle peut décider d'accepter ou de refuser ce problème. "
|
||||
if len(td.rejected) >= len(settings.PROBLEMS) - 5:
|
||||
s += "Refuser ce problème ajoutera une nouvelle pénalité de 0.5 sur le coefficient de l'oral de la défense."
|
||||
s += "Refuser ce problème ajoutera une nouvelle pénalité de 25 % sur le coefficient de l'oral de la défense."
|
||||
else:
|
||||
s += f"Il reste {len(settings.PROBLEMS) - 5 - len(td.rejected)} refus sans pénalité."
|
||||
case 'WAITING_FINAL':
|
||||
@ -290,7 +292,7 @@ class Pool(models.Model):
|
||||
"""
|
||||
Returns a query set ordered by passage index of all team draws in this pool.
|
||||
"""
|
||||
return self.teamdraw_set.order_by('passage_index').all()
|
||||
return self.teamdraw_set.all()
|
||||
|
||||
@property
|
||||
def trigrams(self) -> list[str]:
|
||||
@ -347,7 +349,7 @@ class Pool(models.Model):
|
||||
Translates this Pool instance in a :model:`participation.Pool` instance, with the passage orders.
|
||||
"""
|
||||
# Create the pool
|
||||
self.associated_pool = await PPool.objects.acreate(
|
||||
self.associated_pool, _created = await PPool.objects.aget_or_create(
|
||||
tournament=self.round.draw.tournament,
|
||||
round=self.round.number,
|
||||
letter=self.letter,
|
||||
@ -359,6 +361,17 @@ class Pool(models.Model):
|
||||
.prefetch_related('participation')])
|
||||
await self.asave()
|
||||
|
||||
pool2 = None
|
||||
if self.size == 5:
|
||||
pool2, _created = await PPool.objects.aget_or_create(
|
||||
tournament=self.round.draw.tournament,
|
||||
round=self.round.number,
|
||||
letter=self.letter,
|
||||
room=2,
|
||||
)
|
||||
await pool2.participations.aset([td.participation async for td in self.team_draws
|
||||
.prefetch_related('participation')])
|
||||
|
||||
# Define the passage matrix according to the number of teams
|
||||
table = []
|
||||
if self.size == 3:
|
||||
@ -376,28 +389,36 @@ class Pool(models.Model):
|
||||
]
|
||||
elif self.size == 5:
|
||||
table = [
|
||||
[0, 3, 2],
|
||||
[1, 4, 3],
|
||||
[2, 0, 4],
|
||||
[3, 1, 0],
|
||||
[4, 2, 1],
|
||||
[0, 2, 3],
|
||||
[1, 3, 4],
|
||||
[2, 4, 0],
|
||||
[3, 0, 1],
|
||||
[4, 1, 2],
|
||||
]
|
||||
|
||||
for i, line in enumerate(table):
|
||||
passage_pool = self.associated_pool
|
||||
passage_position = i + 1
|
||||
if self.size == 5:
|
||||
# In 5-teams pools, we may create some passages in the second room
|
||||
if i % 2 == 1:
|
||||
passage_pool = pool2
|
||||
passage_position = 1 + i // 2
|
||||
|
||||
# Create the passage
|
||||
passage = await Passage.objects.acreate(
|
||||
pool=self.associated_pool,
|
||||
position=i + 1,
|
||||
await Passage.objects.acreate(
|
||||
pool=passage_pool,
|
||||
position=passage_position,
|
||||
solution_number=tds[line[0]].accepted,
|
||||
defender=tds[line[0]].participation,
|
||||
opponent=tds[line[1]].participation,
|
||||
reporter=tds[line[2]].participation,
|
||||
defender_penalties=tds[line[0]].penalty_int,
|
||||
)
|
||||
if self.size == 4:
|
||||
# Add observer for 4-teams pools
|
||||
passage.observer = tds[line[3]].participation
|
||||
await passage.asave()
|
||||
|
||||
# Update Google Sheets
|
||||
if os.getenv('GOOGLE_PRIVATE_KEY_ID', None):
|
||||
await sync_to_async(self.associated_pool.update_spreadsheet)()
|
||||
|
||||
return self.associated_pool
|
||||
|
||||
@ -511,9 +532,9 @@ class TeamDraw(models.Model):
|
||||
@property
|
||||
def penalty(self):
|
||||
"""
|
||||
The penalty multiplier on the defender oral, which is a malus of 0.5 for each penalty.
|
||||
The penalty multiplier on the defender oral, in percentage, which is a malus of 25% for each penalty.
|
||||
"""
|
||||
return 0.5 * self.penalty_int
|
||||
return 25 * self.penalty_int
|
||||
|
||||
def __str__(self):
|
||||
return str(format_lazy(_("Draw of the team {trigram} for the pool {letter}{number}"),
|
||||
@ -524,4 +545,5 @@ class TeamDraw(models.Model):
|
||||
class Meta:
|
||||
verbose_name = _('team draw')
|
||||
verbose_name_plural = _('team draws')
|
||||
ordering = ('round__draw__tournament__name', 'round__number', 'pool__letter', 'passage_index',)
|
||||
ordering = ('round__draw__tournament__name', 'round__number', 'pool__letter', 'passage_index',
|
||||
'choice_dice', 'passage_dice',)
|
||||
|
@ -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()),
|
||||
]
|
@ -40,6 +40,20 @@ function drawDice(tid, trigram = null, result = null) {
|
||||
socket.send(JSON.stringify({'tid': tid, 'type': 'dice', 'trigram': trigram, 'result': result}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the requested dice from the buttons and request to draw it.
|
||||
* Only available for debug purposes and for admins.
|
||||
* @param tid The tournament id
|
||||
*/
|
||||
function drawDebugDice(tid) {
|
||||
let dice_10 = parseInt(document.querySelector(`input[name="debug-dice-${tid}-10"]:checked`).value)
|
||||
let dice_1 = parseInt(document.querySelector(`input[name="debug-dice-${tid}-1"]:checked`).value)
|
||||
let result = (dice_10 + dice_1) || 100
|
||||
let team_div = document.querySelector(`div[id="dices-${tid}"] > div > div[class*="text-bg-warning"]`)
|
||||
let team = team_div.getAttribute("data-team")
|
||||
drawDice(tid, team, result)
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to draw a new problem.
|
||||
* @param tid The tournament id
|
||||
@ -203,6 +217,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
elem.classList.add('text-bg-success')
|
||||
elem.innerText = `${trigram} 🎲 ${result}`
|
||||
}
|
||||
|
||||
let nextTeam = document.querySelector(` div[id="dices-${tid}"] > div > div[class*="text-bg-warning"]`).getAttribute("data-team")
|
||||
if (nextTeam) {
|
||||
// If there is one team that does not have launched its dice, then we update the debug section
|
||||
let debugSpan = document.getElementById(`debug-dice-${tid}-team`)
|
||||
if (debugSpan)
|
||||
debugSpan.innerText = nextTeam
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -212,10 +234,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
*/
|
||||
function updateDiceVisibility(tid, visible) {
|
||||
let div = document.getElementById(`launch-dice-${tid}`)
|
||||
if (visible)
|
||||
let div_debug = document.getElementById(`debug-dice-form-${tid}`)
|
||||
if (visible) {
|
||||
div.classList.remove('d-none')
|
||||
else
|
||||
div_debug.classList.remove('d-none')
|
||||
}
|
||||
else {
|
||||
div.classList.add('d-none')
|
||||
div_debug.classList.add('d-none')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -225,10 +252,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
*/
|
||||
function updateBoxVisibility(tid, visible) {
|
||||
let div = document.getElementById(`draw-problem-${tid}`)
|
||||
if (visible)
|
||||
let div_debug = document.getElementById(`debug-problem-form-${tid}`)
|
||||
if (visible) {
|
||||
div.classList.remove('d-none')
|
||||
else
|
||||
div_debug.classList.remove('d-none')
|
||||
}
|
||||
else {
|
||||
div.classList.add('d-none')
|
||||
div_debug.classList.add('d-none')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -582,6 +614,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
let teamLi = document.getElementById(`recap-${tid}-round-${round}-team-${team}`)
|
||||
if (teamLi !== null)
|
||||
teamLi.classList.add('list-group-item-info')
|
||||
|
||||
let debugSpan = document.getElementById(`debug-problem-${tid}-team`)
|
||||
if (debugSpan && team) {
|
||||
debugSpan.innerText = team
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -622,14 +659,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
let penaltyDiv = document.getElementById(`recap-${tid}-round-${round}-team-${team}-penalty`)
|
||||
if (rejected.length > problems_count - 5) {
|
||||
// If more than P - 5 problems were rejected, add a penalty of 0.5 of the coefficient of the oral defender
|
||||
// If more than P - 5 problems were rejected, add a penalty of 25% of the coefficient of the oral defender
|
||||
if (penaltyDiv === null) {
|
||||
penaltyDiv = document.createElement('div')
|
||||
penaltyDiv.id = `recap-${tid}-round-${round}-team-${team}-penalty`
|
||||
penaltyDiv.classList.add('badge', 'rounded-pill', 'text-bg-info')
|
||||
recapDiv.parentNode.append(penaltyDiv)
|
||||
}
|
||||
penaltyDiv.textContent = `❌ ${0.5 * (rejected.length - (problems_count - 5))}`
|
||||
penaltyDiv.textContent = `❌ ${25 * (rejected.length - (problems_count - 5))} %`
|
||||
} else {
|
||||
// Eventually remove this div
|
||||
if (penaltyDiv !== null)
|
||||
@ -736,7 +773,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function setupSocket() {
|
||||
function setupSocket(nextDelay = 1000) {
|
||||
// Open a global websocket
|
||||
socket = new WebSocket(
|
||||
(document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/ws/draw/'
|
||||
@ -753,7 +790,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Manage errors
|
||||
socket.addEventListener('close', e => {
|
||||
console.error('Chat socket closed unexpectedly, restarting…')
|
||||
setupSocket()
|
||||
setTimeout(() => setupSocket(2 * nextDelay), nextDelay)
|
||||
})
|
||||
|
||||
// When the socket is opened, set the language in order to receive alerts in the good language
|
@ -2,6 +2,7 @@
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load pipeline %}
|
||||
|
||||
{% block content %}
|
||||
{# The navbar to select the tournament #}
|
||||
@ -40,5 +41,5 @@
|
||||
{{ problems|length|json_script:'problems_count' }}
|
||||
|
||||
{# This script contains all data for the draw management #}
|
||||
<script src="{% static 'draw.js' %}"></script>
|
||||
{% javascript 'draw' %}
|
||||
{% endblock %}
|
||||
|
@ -37,6 +37,7 @@
|
||||
{% for td in tournament.draw.current_round.team_draws %}
|
||||
<div class="col-md-1" style="order: {{ forloop.counter }};">
|
||||
<div id="dice-{{ tournament.id }}-{{ td.participation.team.trigram }}"
|
||||
data-team="{{ td.participation.team.trigram }}"
|
||||
class="badge rounded-pill text-bg-{% if td.last_dice %}success{% else %}warning{% endif %}"
|
||||
{% if request.user.registration.is_volunteer %}
|
||||
{# Volunteers can click on dices to launch the dice of a team #}
|
||||
@ -99,7 +100,7 @@
|
||||
{# If needed, add the penalty of the team #}
|
||||
<div id="recap-{{ tournament.id }}-round-{{ round.number }}-team-{{ td.participation.team.trigram }}-penalty"
|
||||
class="badge rounded-pill text-bg-info">
|
||||
❌ {{ td.penalty }}
|
||||
❌ {{ td.penalty }} %
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
@ -186,6 +187,66 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if user.registration.is_admin %}
|
||||
<div class="card my-3">
|
||||
<div class="card-header">
|
||||
<div style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#debug-draw-{{ tournament.id }}-body"
|
||||
aria-controls="debug-draw-{{ tournament.id }}-body" aria-expanded="false">
|
||||
<h4>{% trans "Debug draw" %}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body collapse" id="debug-draw-{{ tournament.id }}-body">
|
||||
<div id="debug-dice-form-{{ tournament.id }}" {% if tournament.draw.get_state != 'DICE_SELECT_POULES' and tournament.draw.get_state != 'DICE_ORDER_POULE' %}class="d-none"{% endif %}>
|
||||
<h5>
|
||||
{% trans "Draw dice for" %}
|
||||
<span id="debug-dice-{{ tournament.id }}-team">
|
||||
{% regroup tournament.draw.current_round.team_draws by last_dice as td_dices %}
|
||||
{% for group in td_dices %}
|
||||
{% if group.grouper is None %}
|
||||
{{ group }}
|
||||
{% with group.list|first as td %}
|
||||
{{ td.participation.team.trigram }}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</span>
|
||||
</h5>
|
||||
<div class="btn-group w-100" role="group">
|
||||
{% for i in range_100 %}
|
||||
<input type="radio" class="btn-check" name="debug-dice-{{ tournament.id }}-10" id="debug-dice-{{ tournament.id }}-{{ i|stringformat:"02d" }}" value="{{ i }}" {% if i == 0 %}checked{% endif %}>
|
||||
<label class="btn btn-outline-warning" for="debug-dice-{{ tournament.id }}-{{ i|stringformat:"02d" }}">{{ i|stringformat:"02d" }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="btn-group w-100" role="group">
|
||||
{% for i in range_10 %}
|
||||
<input type="radio" class="btn-check" name="debug-dice-{{ tournament.id }}-1" id="debug-dice-{{ tournament.id }}-{{ i }}" value="{{ i }}" {% if i == 0 %}checked{% endif %}>
|
||||
<label class="btn btn-outline-warning" for="debug-dice-{{ tournament.id }}-{{ i }}">{{ i }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="my-2 text-center">
|
||||
<button class="btn btn-success" onclick="drawDebugDice({{ tournament.id }})">
|
||||
{% trans "Draw dice" %} 🎲
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="debug-problem-form-{{ tournament.id }}" {% if tournament.draw.get_state != 'WAITING_DRAW_PROBLEM' %}class="d-none"{% endif %}>
|
||||
<h5>
|
||||
{% trans "Draw problem for" %}
|
||||
<span id="debug-problem-{{ tournament.id }}-team">{{ tournament.draw.current_round.current_pool.current_team.participation.team.trigram }}</span>
|
||||
</h5>
|
||||
<div class="btn-group w-100" role="group">
|
||||
{% for problem in problems %}
|
||||
<button class="btn btn-outline-info" id="debug-problem-{{ tournament.id }}-{{ forloop.counter }}" onclick="drawProblem({{ tournament.id }}, {{ forloop.counter }})">
|
||||
{% trans "Pb." %} {{ forloop.counter }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -308,10 +369,9 @@
|
||||
{% elif forloop.counter == 5 %}
|
||||
<td></td>
|
||||
<td class="text-center">Rap</td>
|
||||
<td>Opp</td>
|
||||
<td class="text-center"></td>
|
||||
<td class="text-center">Déf</td>
|
||||
<td class="text-center">Opp</td>
|
||||
<td></td>
|
||||
<td class="text-center">Déf</td>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</tr>
|
||||
|
@ -14,8 +14,8 @@ from django.contrib.sites.models import Site
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from participation.models import Team, Tournament
|
||||
from tfjm import routing as websocket_routing
|
||||
|
||||
from . import routing
|
||||
from .models import Draw, Pool, Round, TeamDraw
|
||||
|
||||
|
||||
@ -55,7 +55,7 @@ class TestDraw(TestCase):
|
||||
|
||||
# Connect to Websocket
|
||||
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)
|
||||
connected, subprotocol = await communicator.connect()
|
||||
self.assertTrue(connected)
|
||||
|
@ -40,4 +40,7 @@ class DisplayView(LoginRequiredMixin, TemplateView):
|
||||
context['tournaments_simplified'] = [{'id': t.id, 'name': t.name} for t in tournaments]
|
||||
context['problems'] = settings.PROBLEMS
|
||||
|
||||
context['range_100'] = range(0, 100, 10)
|
||||
context['range_10'] = range(0, 10, 1)
|
||||
|
||||
return context
|
||||
|
@ -3,7 +3,6 @@
|
||||
crond -l 0
|
||||
|
||||
python manage.py migrate
|
||||
python manage.py loaddata initial
|
||||
python manage.py update_index
|
||||
|
||||
nginx
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -51,7 +51,7 @@ class PassageInline(admin.TabularInline):
|
||||
model = Passage
|
||||
extra = 0
|
||||
ordering = ('position',)
|
||||
autocomplete_fields = ('defender', 'opponent', 'reporter', 'observer',)
|
||||
autocomplete_fields = ('defender', 'opponent', 'reporter',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
@ -100,8 +100,8 @@ class ParticipationAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Pool)
|
||||
class PoolAdmin(admin.ModelAdmin):
|
||||
list_display = ('__str__', 'tournament', 'round', 'letter', 'teams', 'jury_president',)
|
||||
list_filter = ('tournament', 'round', 'letter',)
|
||||
list_display = ('__str__', 'tournament', 'round', 'letter', 'room', 'teams', 'jury_president',)
|
||||
list_filter = ('tournament', 'round', 'letter', 'room',)
|
||||
search_fields = ('participations__team__name', 'participations__team__trigram',)
|
||||
autocomplete_fields = ('tournament', 'participations', 'jury_president', 'juries',)
|
||||
inlines = (PassageInline, TweakInline,)
|
||||
@ -118,7 +118,7 @@ class PassageAdmin(admin.ModelAdmin):
|
||||
list_filter = ('pool__tournament', 'pool__round', 'pool__letter', 'solution_number',)
|
||||
search_fields = ('pool__participations__team__name', 'pool__participations__team__trigram',)
|
||||
ordering = ('pool__tournament', 'pool__round', 'pool__letter', 'position',)
|
||||
autocomplete_fields = ('pool', 'defender', 'opponent', 'reporter', 'observer',)
|
||||
autocomplete_fields = ('pool', 'defender', 'opponent', 'reporter',)
|
||||
inlines = (NoteInline,)
|
||||
|
||||
@admin.display(description=_("defender"), ordering='defender__team__trigram')
|
||||
@ -135,7 +135,7 @@ class PassageAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.display(description=_("pool"), ordering='pool__letter')
|
||||
def pool_abbr(self, record):
|
||||
return f"{record.pool.get_letter_display()}{record.pool.round}"
|
||||
return f"{record.pool.short_name}"
|
||||
|
||||
@admin.display(description=_("tournament"), ordering='pool__tournament__name')
|
||||
def tournament(self, record: Passage):
|
||||
@ -154,7 +154,7 @@ class NoteAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.display(description=_("pool"))
|
||||
def pool(self, record):
|
||||
return record.passage.pool.get_letter_display()
|
||||
return record.passage.pool.short_name
|
||||
|
||||
|
||||
@admin.register(Solution)
|
||||
@ -201,4 +201,6 @@ class TournamentAdmin(admin.ModelAdmin):
|
||||
@admin.register(Tweak)
|
||||
class TweakAdmin(admin.ModelAdmin):
|
||||
list_display = ('participation', 'pool', 'diff',)
|
||||
list_filter = ('pool__tournament', 'pool__round',)
|
||||
search_fields = ('participation__team__name', 'participation__team__trigram',)
|
||||
autocomplete_fields = ('participation', 'pool',)
|
||||
|
@ -1,10 +1,8 @@
|
||||
# Copyright (C) 2020 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import csv
|
||||
from io import StringIO
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Div, Field, Submit
|
||||
@ -13,6 +11,7 @@ from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import FileExtensionValidator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
import pandas
|
||||
from pypdf import PdfReader
|
||||
from registration.models import VolunteerRegistration
|
||||
|
||||
@ -128,7 +127,7 @@ class ValidateParticipationForm(forms.Form):
|
||||
class TournamentForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Tournament
|
||||
fields = '__all__'
|
||||
exclude = ('notes_sheet_id', )
|
||||
widgets = {
|
||||
'date_start': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
|
||||
'date_end': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
|
||||
@ -192,24 +191,6 @@ class PoolForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class PoolTeamsForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["participations"].queryset = self.instance.tournament.participations.all()
|
||||
|
||||
class Meta:
|
||||
model = Pool
|
||||
fields = ('participations',)
|
||||
widgets = {
|
||||
"participations": forms.SelectMultiple(attrs={
|
||||
'class': 'selectpicker',
|
||||
'data-live-search': 'true',
|
||||
'data-live-search-normalize': 'true',
|
||||
'data-width': 'fit',
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
class AddJuryForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@ -259,62 +240,68 @@ class AddJuryForm(forms.ModelForm):
|
||||
|
||||
class UploadNotesForm(forms.Form):
|
||||
file = forms.FileField(
|
||||
label=_("CSV file:"),
|
||||
validators=[FileExtensionValidator(allowed_extensions=["csv"])],
|
||||
label=_("Spreadsheet file:"),
|
||||
validators=[FileExtensionValidator(allowed_extensions=["csv", "ods"])],
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['file'].widget.attrs['accept'] = 'text/csv'
|
||||
self.fields['file'].widget.attrs['accept'] = 'text/csv,application/vnd.oasis.opendocument.spreadsheet'
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
if 'file' in cleaned_data:
|
||||
file = cleaned_data['file']
|
||||
with file:
|
||||
try:
|
||||
data: bytes = file.read()
|
||||
if file.name.endswith('.csv'):
|
||||
with file:
|
||||
try:
|
||||
content = data.decode()
|
||||
data: bytes = file.read()
|
||||
try:
|
||||
content = data.decode()
|
||||
except UnicodeDecodeError:
|
||||
# This is not UTF-8, grrrr
|
||||
content = data.decode('latin1')
|
||||
|
||||
table = pandas.read_csv(StringIO(content), sep=None, header=None)
|
||||
self.process(table, cleaned_data)
|
||||
except UnicodeDecodeError:
|
||||
# This is not UTF-8, grrrr
|
||||
content = data.decode('latin1')
|
||||
csvfile = csv.reader(StringIO(content))
|
||||
self.process(csvfile, cleaned_data)
|
||||
except UnicodeDecodeError:
|
||||
self.add_error('file', _("This file contains non-UTF-8 and non-ISO-8859-1 content. "
|
||||
"Please send your sheet as a CSV file."))
|
||||
self.add_error('file', _("This file contains non-UTF-8 and non-ISO-8859-1 content. "
|
||||
"Please send your sheet as a CSV file."))
|
||||
elif file.name.endswith('.ods'):
|
||||
table = pandas.read_excel(file, header=None, engine='odf')
|
||||
self.process(table, cleaned_data)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def process(self, csvfile: Iterable[str], cleaned_data: dict):
|
||||
def process(self, df: pandas.DataFrame, cleaned_data: dict):
|
||||
parsed_notes = {}
|
||||
valid_lengths = [2 + 6 * 3, 2 + 7 * 4, 2 + 6 * 5] # Per pool sizes
|
||||
pool_size = 0
|
||||
line_length = 0
|
||||
for line in csvfile:
|
||||
line = [s.strip() for s in line if s]
|
||||
for line in df.values.tolist():
|
||||
# Remove NaN
|
||||
line = [s for s in line if s == s]
|
||||
# Strip cases
|
||||
line = [str(s).strip() for s in line if str(s)]
|
||||
if line and line[0] == 'Problème':
|
||||
pool_size = len(line) - 1
|
||||
if pool_size < 3 or pool_size > 5:
|
||||
self.add_error('file', _("Can't determine the pool size. Are you sure your file is correct?"))
|
||||
return
|
||||
line_length = valid_lengths[pool_size - 3]
|
||||
line_length = 2 + 6 * pool_size
|
||||
continue
|
||||
|
||||
if pool_size == 0 or len(line) < line_length:
|
||||
continue
|
||||
|
||||
name = line[0]
|
||||
if name.lower() in ["rôle", "juré", "moyenne", "coefficient", "sous-total", "équipe", "equipe"]:
|
||||
if name.lower() in ["rôle", "juré⋅e", "juré?e", "moyenne", "coefficient", "sous-total", "équipe", "equipe"]:
|
||||
continue
|
||||
notes = line[2:line_length]
|
||||
print(name, notes)
|
||||
if not all(s.isnumeric() or s[0] == '-' and s[1:].isnumeric() for s in notes):
|
||||
continue
|
||||
notes = list(map(int, notes))
|
||||
notes = list(map(lambda x: int(float(x)), notes))
|
||||
print(notes)
|
||||
|
||||
max_notes = pool_size * ([20, 20, 10, 10, 10, 10] + ([4] if pool_size == 4 else []))
|
||||
max_notes = pool_size * [20, 20, 10, 10, 10, 10]
|
||||
for n, max_n in zip(notes, max_notes):
|
||||
if n > max_n:
|
||||
self.add_error('file',
|
||||
@ -322,12 +309,14 @@ class UploadNotesForm(forms.Form):
|
||||
+ str(n) + " > " + str(max_n))
|
||||
|
||||
# Search by volunteer id
|
||||
jury = VolunteerRegistration.objects.filter(pk=line[1])
|
||||
jury = VolunteerRegistration.objects.filter(pk=int(float(line[1])))
|
||||
if jury.count() != 1:
|
||||
raise ValidationError({'file': _("The following user was not found:") + " " + name})
|
||||
jury = jury.get()
|
||||
parsed_notes[jury] = notes
|
||||
|
||||
print(parsed_notes)
|
||||
|
||||
cleaned_data['parsed_notes'] = parsed_notes
|
||||
|
||||
return cleaned_data
|
||||
@ -347,7 +336,7 @@ class PassageForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Passage
|
||||
fields = ('position', 'solution_number', 'defender', 'opponent', 'reporter', 'observer', 'defender_penalties',)
|
||||
fields = ('position', 'solution_number', 'defender', 'opponent', 'reporter', 'defender_penalties',)
|
||||
|
||||
|
||||
class SynthesisForm(forms.ModelForm):
|
||||
@ -378,4 +367,4 @@ class NoteForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Note
|
||||
fields = ('defender_writing', 'defender_oral', 'opponent_writing',
|
||||
'opponent_oral', 'reporter_writing', 'reporter_oral', 'observer_oral', )
|
||||
'opponent_oral', 'reporter_writing', 'reporter_oral', )
|
||||
|
@ -17,8 +17,8 @@ class Command(BaseCommand):
|
||||
self.w("")
|
||||
self.w("")
|
||||
|
||||
def w(self, msg):
|
||||
self.stdout.write(msg)
|
||||
def w(self, msg, prefix="", suffix=""):
|
||||
self.stdout.write(f"{prefix}{msg}{suffix}")
|
||||
|
||||
def handle_tournament(self, tournament):
|
||||
name = tournament.name
|
||||
@ -40,7 +40,7 @@ class Command(BaseCommand):
|
||||
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 "
|
||||
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:
|
||||
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>")
|
||||
@ -52,32 +52,29 @@ class Command(BaseCommand):
|
||||
self.w("<table>")
|
||||
self.w("<thead>")
|
||||
self.w("<tr>")
|
||||
self.w("\t<th>Équipe</th>")
|
||||
self.w("\t<th>Score Tour 1</th>")
|
||||
self.w("\t<th>Score Tour 2</th>")
|
||||
self.w("\t<th>Total</th>")
|
||||
self.w("\t<th class=\"has-text-align-center\">Prix</th>")
|
||||
self.w(" <th>Équipe</th>")
|
||||
self.w(" <th>Score Tour 1</th>")
|
||||
self.w(" <th>Score Tour 2</th>")
|
||||
self.w(" <th>Total</th>")
|
||||
self.w(" <th class=\"has-text-align-center\">Prix</th>")
|
||||
self.w("</tr>")
|
||||
self.w("</thead>")
|
||||
self.w("<tbody>")
|
||||
for i, (participation, note) in enumerate(notes):
|
||||
self.w("<tr>")
|
||||
if i < (2 if len(notes) >= 7 else 1):
|
||||
self.w(f"\t<th>{participation.team.name} ({participation.team.trigram})</td>")
|
||||
bold = (not tournament.final and participation.final) or (tournament.final and i < 2)
|
||||
if bold:
|
||||
prefix, suffix = " <td><strong>", "</strong></td>"
|
||||
else:
|
||||
self.w(f"\t<td>{participation.team.name} ({participation.team.trigram})</td>")
|
||||
for pool in tournament.pools.filter(participations=participation).all():
|
||||
pool_note = pool.average(participation)
|
||||
self.w(f"\t<td>{pool_note:.01f}</td>")
|
||||
self.w(f"\t<td>{note:.01f}</td>")
|
||||
if i == 0:
|
||||
self.w("\t<td class=\"has-text-align-center\">1<sup>er</sup> prix</td>")
|
||||
elif i < (5 if tournament.final else 3):
|
||||
self.w(f"\t<td class=\"has-text-align-center\">{i + 1}<sup>ème</sup> prix</td>")
|
||||
elif i < 2 * len(notes) / 3:
|
||||
self.w("\t<td class=\"has-text-align-center\">Mention très honorable</td>")
|
||||
else:
|
||||
self.w("\t<td class=\"has-text-align-center\">Mention honorable</td>")
|
||||
prefix, suffix = " <td>", "</td>"
|
||||
self.w(f"{participation.team.name} ({participation.team.trigram})", prefix, suffix)
|
||||
for tournament_round in [1, 2]:
|
||||
pool_note = sum(pool.average(participation)
|
||||
for pool in tournament.pools.filter(participations=participation,
|
||||
round=tournament_round).all())
|
||||
self.w(f"{pool_note:.01f}", prefix, suffix)
|
||||
self.w(f"{note:.01f}", prefix, suffix)
|
||||
self.w(participation.mention_final if tournament.final else participation.mention, prefix, suffix)
|
||||
self.w("</tr>")
|
||||
self.w("</tbody>")
|
||||
self.w("</table>")
|
||||
|
@ -22,11 +22,11 @@ class Command(BaseCommand):
|
||||
"Liste de diffusion pour contacter toutes les equipes non validees du TFJM2.",
|
||||
"education", raise_error=False)
|
||||
|
||||
sympa.create_list("admins", "Administrateurs du TFJM2", "hotline",
|
||||
"Liste de diffusion pour contacter tous les administrateurs du TFJM2.",
|
||||
sympa.create_list("admins", "Administrateur⋅rices du TFJM2", "hotline",
|
||||
"Liste de diffusion pour contacter toustes les administrateur.rices du TFJM2.",
|
||||
"education", raise_error=False)
|
||||
sympa.create_list("organisateurs", "Organisateurs du TFJM2", "hotline",
|
||||
"Liste de diffusion pour contacter tous les organisateurs du TFJM2.",
|
||||
"Liste de diffusion pour contacter toustes les organisateur.rices du TFJM2.",
|
||||
"education", raise_error=False)
|
||||
sympa.create_list("jurys", "Jurys du TFJM2", "hotline",
|
||||
"Liste de diffusion pour contacter tous les jurys du TFJM2.",
|
||||
|
149
participation/management/commands/generate_seconds_sheet.py
Normal file
149
participation/management/commands/generate_seconds_sheet.py
Normal file
@ -0,0 +1,149 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import BaseCommand
|
||||
from django.utils.translation import activate
|
||||
import gspread
|
||||
from gspread.utils import a1_range_to_grid_range, MergeType
|
||||
|
||||
from ...models import Passage, Tournament
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
activate('fr')
|
||||
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
|
||||
try:
|
||||
spreadsheet = gc.open("Tableau des deuxièmes", folder_id=settings.NOTES_DRIVE_FOLDER_ID)
|
||||
except gspread.SpreadsheetNotFound:
|
||||
spreadsheet = gc.create("Tableau des deuxièmes", folder_id=settings.NOTES_DRIVE_FOLDER_ID)
|
||||
spreadsheet.update_locale("fr_FR")
|
||||
spreadsheet.share(None, "anyone", "writer", with_link=True)
|
||||
|
||||
sheet = spreadsheet.sheet1
|
||||
|
||||
header1 = ["Tournoi", "Équipe 2", "Tour 1", "", "", "", "", "", "", "", "Tour 2", "", "", "", "", "", "", "",
|
||||
"Score total", "Score équipe 1", "Score équipe 3"]
|
||||
header2 = ["", ""] + 2 * ["PJ", "Problème", "Défenseur⋅se", "", "Opposant⋅e", "", "Rapporteur⋅rice", ""]
|
||||
header2 += ["", "", ""]
|
||||
header3 = ["", ""] + 2 * (["", ""] + 3 * ["Écrit", "Oral"]) + ["", "", ""]
|
||||
lines = [header1, header2, header3]
|
||||
nb_tournaments = Tournament.objects.filter(final=False).count()
|
||||
for tournament in Tournament.objects.filter(final=False).all():
|
||||
line = [tournament.name]
|
||||
lines.append(line)
|
||||
|
||||
notes = dict()
|
||||
for participation in tournament.participations.filter(valid=True).all():
|
||||
note = sum(pool.average(participation)
|
||||
for pool in tournament.pools.filter(participations=participation).all())
|
||||
if note:
|
||||
notes[participation] = note
|
||||
|
||||
if not notes:
|
||||
continue
|
||||
|
||||
sorted_notes = sorted(notes.items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
team1, score1 = sorted_notes[0]
|
||||
team2, score2 = sorted_notes[1]
|
||||
team3, score3 = sorted_notes[2]
|
||||
|
||||
pool1 = tournament.pools.filter(round=1, participations=team2).first()
|
||||
defender_passage_1 = Passage.objects.get(pool__tournament=tournament, pool__round=1, defender=team2)
|
||||
opponent_passage_1 = Passage.objects.get(pool__tournament=tournament, pool__round=1, opponent=team2)
|
||||
reporter_passage_1 = Passage.objects.get(pool__tournament=tournament, pool__round=1, reporter=team2)
|
||||
pool2 = tournament.pools.filter(round=2, participations=team2).first()
|
||||
defender_passage_2 = Passage.objects.get(pool__tournament=tournament, pool__round=2, defender=team2)
|
||||
opponent_passage_2 = Passage.objects.get(pool__tournament=tournament, pool__round=2, opponent=team2)
|
||||
reporter_passage_2 = Passage.objects.get(pool__tournament=tournament, pool__round=2, reporter=team2)
|
||||
|
||||
line.append(team2.team.trigram)
|
||||
line.append(str(pool1.jury_president or ""))
|
||||
line.append(f"Pb. {defender_passage_1.solution_number}")
|
||||
line.extend([defender_passage_1.average_defender_writing, defender_passage_1.average_defender_oral,
|
||||
opponent_passage_1.average_opponent_writing, opponent_passage_1.average_opponent_oral,
|
||||
reporter_passage_1.average_reporter_writing, reporter_passage_1.average_reporter_oral])
|
||||
line.append(str(pool2.jury_president or ""))
|
||||
line.append(f"Pb. {defender_passage_2.solution_number}")
|
||||
line.extend([defender_passage_2.average_defender_writing, defender_passage_2.average_defender_oral,
|
||||
opponent_passage_2.average_opponent_writing, opponent_passage_2.average_opponent_oral,
|
||||
reporter_passage_2.average_reporter_writing, reporter_passage_2.average_reporter_oral])
|
||||
line.extend([score2, f"{score1:.1f} ({team1.team.trigram})",
|
||||
f"{score3:.1f} ({team3.team.trigram})"])
|
||||
|
||||
sheet.update(lines)
|
||||
|
||||
format_requests = []
|
||||
merge_cells = ["A1:A3", "B1:B3", "C1:J1", "K1:R1", "E2:F2", "G2:H2", "I2:J2", "M2:N2", "O2:P2", "Q2:R2",
|
||||
"C2:C3", "D2:D3", "K2:K3", "L2:L3", "S1:S3", "T1:T3", "U1:U3"]
|
||||
format_requests.append({"unmergeCells": {"range": a1_range_to_grid_range("A1:AF", sheet.id)}})
|
||||
for name in merge_cells:
|
||||
grid_range = a1_range_to_grid_range(name, sheet.id)
|
||||
format_requests.append({"mergeCells": {"mergeType": MergeType.merge_all, "range": grid_range}})
|
||||
|
||||
bold_ranges = [("A1:AF", False), ("A1:U3", True), (f"A4:A{3 + nb_tournaments}", True)]
|
||||
for bold_range, bold in bold_ranges:
|
||||
format_requests.append({
|
||||
"repeatCell": {
|
||||
"range": a1_range_to_grid_range(bold_range, sheet.id),
|
||||
"cell": {"userEnteredFormat": {"textFormat": {"bold": bold}}},
|
||||
"fields": "userEnteredFormat(textFormat)",
|
||||
}
|
||||
})
|
||||
|
||||
border_ranges = [("A1:AF", "0000"),
|
||||
(f"A1:U{3 + nb_tournaments}", "1111")]
|
||||
sides_names = ['top', 'bottom', 'left', 'right']
|
||||
styles = ["NONE", "SOLID", "SOLID_MEDIUM", "SOLID_THICK", "DOUBLE"]
|
||||
for border_range, sides in border_ranges:
|
||||
borders = {}
|
||||
for side_name, side in zip(sides_names, sides):
|
||||
borders[side_name] = {"style": styles[int(side)]}
|
||||
format_requests.append({
|
||||
"repeatCell": {
|
||||
"range": a1_range_to_grid_range(border_range, sheet.id),
|
||||
"cell": {
|
||||
"userEnteredFormat": {
|
||||
"borders": borders,
|
||||
"horizontalAlignment": "CENTER",
|
||||
},
|
||||
},
|
||||
"fields": "userEnteredFormat(borders,horizontalAlignment)",
|
||||
}
|
||||
})
|
||||
|
||||
column_widths = [("A", 120), ("B", 80), ("C", 180), ("D", 80)] + [(chr(ord("E") + i), 60) for i in range(6)]
|
||||
column_widths += [("K", 180), ("L", 80)] + [(chr(ord("M") + i), 60) for i in range(6)]
|
||||
column_widths += [("S", 100), ("T", 120), ("U", 120)]
|
||||
for column, width in column_widths:
|
||||
grid_range = a1_range_to_grid_range(column, sheet.id)
|
||||
format_requests.append({
|
||||
"updateDimensionProperties": {
|
||||
"range": {
|
||||
"sheetId": sheet.id,
|
||||
"dimension": "COLUMNS",
|
||||
"startIndex": grid_range['startColumnIndex'],
|
||||
"endIndex": grid_range['endColumnIndex'],
|
||||
},
|
||||
"properties": {
|
||||
"pixelSize": width,
|
||||
},
|
||||
"fields": "pixelSize",
|
||||
}
|
||||
})
|
||||
|
||||
# Set number format, display only one decimal
|
||||
number_format_ranges = [f"E4:J{3 + nb_tournaments}", f"M4:S{3 + nb_tournaments}"]
|
||||
for number_format_range in number_format_ranges:
|
||||
format_requests.append({
|
||||
"repeatCell": {
|
||||
"range": a1_range_to_grid_range(number_format_range, sheet.id),
|
||||
"cell": {"userEnteredFormat": {"numberFormat": {"type": "NUMBER", "pattern": "0.0"}}},
|
||||
"fields": "userEnteredFormat.numberFormat",
|
||||
}
|
||||
})
|
||||
|
||||
body = {"requests": format_requests}
|
||||
sheet.client.batch_update(spreadsheet.id, body)
|
56
participation/management/commands/parse_notation_sheets.py
Normal file
56
participation/management/commands/parse_notation_sheets.py
Normal file
@ -0,0 +1,56 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
from time import sleep
|
||||
|
||||
from django.core.management import BaseCommand
|
||||
from participation.models import Tournament
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--tournament', '-t', help="Tournament name to update (if not set, all tournaments will be updated)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--round', '-r', type=int, help="Round number to update (if not set, all rounds will be updated)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--letter', '-l', help="Letter of the pool to update (if not set, all pools will be updated)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
tournaments = Tournament.objects.all() if not options['tournament'] \
|
||||
else Tournament.objects.filter(name=options['tournament']).all()
|
||||
|
||||
for tournament in tournaments:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(f"Parsing notation sheet for {tournament}")
|
||||
|
||||
if not tournament.notes_sheet_id:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f"No spreadsheet found for {tournament}. Please create it first"))
|
||||
continue
|
||||
|
||||
pools = tournament.pools.all()
|
||||
if options['round']:
|
||||
pools = pools.filter(round=options['round'])
|
||||
if options['letter']:
|
||||
pools = pools.filter(letter=ord(options['letter']) - 64)
|
||||
for pool in pools.all():
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(f"Parsing notation sheet for pool {pool.short_name} for {tournament}")
|
||||
try:
|
||||
pool.parse_spreadsheet()
|
||||
except Exception as e:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stderr.write(
|
||||
self.style.ERROR(f"Error while parsing pool {pool.short_name} for {tournament.name}: {e}"))
|
||||
finally:
|
||||
sleep(3) # Three calls = 3s sleep
|
||||
|
||||
try:
|
||||
tournament.parse_tweaks_spreadsheets()
|
||||
except Exception as e:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stderr.write(self.style.ERROR(f"Error while parsing tweaks for {tournament.name}: {e}"))
|
@ -0,0 +1,61 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from hashlib import sha1
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.management import BaseCommand
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import localtime
|
||||
import gspread
|
||||
|
||||
from ...models import Tournament
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--tournament', '-t', help="Tournament name to update (if not set, all tournaments will be updated)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
tournaments = Tournament.objects.all() if not options['tournament'] \
|
||||
else Tournament.objects.filter(name=options['tournament']).all()
|
||||
|
||||
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
|
||||
http_client = gc.http_client
|
||||
http_client.login()
|
||||
|
||||
site = Site.objects.get(pk=settings.SITE_ID)
|
||||
|
||||
now = localtime(timezone.now())
|
||||
tomorrow = now + timezone.timedelta(days=1)
|
||||
tomorrow -= timezone.timedelta(hours=now.hour, minutes=now.minute, seconds=now.second,
|
||||
microseconds=now.microsecond)
|
||||
|
||||
for tournament in tournaments:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(f"Renewing Google Drive notifications for {tournament}")
|
||||
|
||||
if not tournament.notes_sheet_id:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f"No spreadsheet found for {tournament}. Please create it first"))
|
||||
continue
|
||||
|
||||
channel_id = sha1(f"{tournament.name}-{now.date()}-{site.domain}".encode()).hexdigest()
|
||||
url = f"https://www.googleapis.com/drive/v3/files/{tournament.notes_sheet_id}/watch?supportsAllDrives=true"
|
||||
notif_path = reverse('participation:tournament_gsheet_notifications', args=[tournament.pk])
|
||||
notif_url = f"https://{site.domain}{notif_path}"
|
||||
body = {
|
||||
"id": channel_id,
|
||||
"type": "web_hook",
|
||||
"address": notif_url,
|
||||
"expiration": str(int(1000 * tomorrow.timestamp())),
|
||||
}
|
||||
try:
|
||||
http_client.request(method="POST", endpoint=url, json=body).raise_for_status()
|
||||
except Exception as e:
|
||||
self.stderr.write(self.style.ERROR(f"Error while renewing notifications for {tournament.name}: {e}"))
|
39
participation/management/commands/update_notation_sheets.py
Normal file
39
participation/management/commands/update_notation_sheets.py
Normal file
@ -0,0 +1,39 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.core.management import BaseCommand
|
||||
from participation.models import Tournament
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--tournament', '-t', help="Tournament name to update (if not set, all tournaments will be updated)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--round', '-r', type=int, help="Round number to update (if not set, all rounds will be updated)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--letter', '-l', help="Letter of the pool to update (if not set, all pools will be updated)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
tournaments = Tournament.objects.all() if not options['tournament'] \
|
||||
else Tournament.objects.filter(name=options['tournament']).all()
|
||||
|
||||
for tournament in tournaments:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(f"Updating notation sheet for {tournament}")
|
||||
tournament.create_spreadsheet()
|
||||
|
||||
pools = tournament.pools.all()
|
||||
if options['round']:
|
||||
pools = pools.filter(round=options['round'])
|
||||
if options['letter']:
|
||||
pools = pools.filter(letter=ord(options['letter']) - 64)
|
||||
for pool in pools.all():
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(f"Updating notation sheet for pool {pool.short_name} for {tournament}")
|
||||
pool.update_spreadsheet()
|
||||
|
||||
tournament.update_ranking_spreadsheet()
|
@ -0,0 +1,93 @@
|
||||
# Generated by Django 5.0.3 on 2024-03-29 22:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("participation", "0009_pool_jury_president"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="tournament",
|
||||
name="notes_sheet_id",
|
||||
field=models.CharField(
|
||||
blank=True, default="", max_length=64, verbose_name="Google Sheet ID"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="note",
|
||||
name="defender_oral",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(2, 2),
|
||||
(3, 3),
|
||||
(4, 4),
|
||||
(5, 5),
|
||||
(6, 6),
|
||||
(7, 7),
|
||||
(8, 8),
|
||||
(9, 9),
|
||||
(10, 10),
|
||||
(11, 11),
|
||||
(12, 12),
|
||||
(13, 13),
|
||||
(14, 14),
|
||||
(15, 15),
|
||||
(16, 16),
|
||||
(17, 17),
|
||||
(18, 18),
|
||||
(19, 19),
|
||||
(20, 20),
|
||||
],
|
||||
default=0,
|
||||
verbose_name="defender oral note",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="note",
|
||||
name="opponent_writing",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(2, 2),
|
||||
(3, 3),
|
||||
(4, 4),
|
||||
(5, 5),
|
||||
(6, 6),
|
||||
(7, 7),
|
||||
(8, 8),
|
||||
(9, 9),
|
||||
(10, 10),
|
||||
],
|
||||
default=0,
|
||||
verbose_name="opponent writing note",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="note",
|
||||
name="reporter_writing",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(2, 2),
|
||||
(3, 3),
|
||||
(4, 4),
|
||||
(5, 5),
|
||||
(6, 6),
|
||||
(7, 7),
|
||||
(8, 8),
|
||||
(9, 9),
|
||||
(10, 10),
|
||||
],
|
||||
default=0,
|
||||
verbose_name="reporter writing note",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-16 21:59
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"participation",
|
||||
"0010_tournament_notes_sheet_id_alter_note_defender_oral_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="note",
|
||||
name="observer_oral",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="passage",
|
||||
name="observer",
|
||||
),
|
||||
]
|
@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-16 22:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("participation", "0011_remove_note_observer_oral_remove_passage_observer"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="participation",
|
||||
name="mention",
|
||||
field=models.CharField(
|
||||
blank=True, default="", max_length=255, verbose_name="mention"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="participation",
|
||||
name="mention_final",
|
||||
field=models.CharField(
|
||||
blank=True, default="", max_length=255, verbose_name="mention (final)"
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-17 20:50
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("participation", "0012_participation_mention_participation_mention_final"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="pool",
|
||||
options={
|
||||
"ordering": ("round", "letter", "room"),
|
||||
"verbose_name": "pool",
|
||||
"verbose_name_plural": "pools",
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="pool",
|
||||
name="room",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[(1, "Room 1"), (2, "Room 2")],
|
||||
default=1,
|
||||
help_text="For 5-teams pools only",
|
||||
verbose_name="room",
|
||||
),
|
||||
),
|
||||
]
|
File diff suppressed because it is too large
Load Diff
@ -64,6 +64,15 @@ def create_payments(instance: Participation, created, raw, **_):
|
||||
else:
|
||||
payment = Payment.objects.create(final=True)
|
||||
payment.registrations.add(student)
|
||||
|
||||
payment_regional = Payment.objects.get(registrations=student, final=False)
|
||||
if payment_regional.type == 'scholarship':
|
||||
payment.type = 'scholarship'
|
||||
with open(payment_regional.receipt.path, 'rb') as f:
|
||||
payment.receipt.save(payment_regional.receipt.name, f)
|
||||
payment.additional_information = payment_regional.additional_information
|
||||
payment.fee = 0
|
||||
payment.valid = payment_regional.valid
|
||||
payment.save()
|
||||
payment.amount = Tournament.final_tournament().price
|
||||
if payment.amount == 0:
|
||||
|
@ -91,7 +91,7 @@ class PoolTable(tables.Table):
|
||||
)
|
||||
|
||||
def render_letter(self, record):
|
||||
return format_lazy(_("Pool {letter}{round}"), letter=record.get_letter_display(), round=record.round)
|
||||
return format_lazy(_("Pool {code}"), code=record.short_name)
|
||||
|
||||
def render_teams(self, record):
|
||||
return ", ".join(participation.team.trigram for participation in record.participations.all()) \
|
||||
@ -155,4 +155,4 @@ class NoteTable(tables.Table):
|
||||
}
|
||||
model = Note
|
||||
fields = ('jury', 'defender_writing', 'defender_oral', 'opponent_writing', 'opponent_oral',
|
||||
'reporter_writing', 'reporter_oral', 'observer_oral', 'update',)
|
||||
'reporter_writing', 'reporter_oral', 'update',)
|
||||
|
@ -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 %}
|
@ -6,6 +6,7 @@
|
||||
<form method="post">
|
||||
<div id="form-content">
|
||||
<h4>{% trans "Notes of" %} {{ note.jury }}</h4>
|
||||
<h5>{% trans "Defense of" %} {{ note.passage.defender.team.trigram }}, {% trans "Pb." %} {{ note.passage.solution_number }}</h5>
|
||||
<hr>
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
|
@ -6,7 +6,16 @@
|
||||
{% trans "any" as any %}
|
||||
<div class="card bg-body shadow">
|
||||
<div class="card-header text-center">
|
||||
<h4>{{ passage }}</h4>
|
||||
<h4>
|
||||
{{ passage }}
|
||||
{% if user.registration.is_admin or user.registration in passage.pool.tournament.organizers.all %}
|
||||
<button class="btn btn-sm btn-secondary"
|
||||
data-bs-toggle="modal" data-bs-target="#updatePassageModal">
|
||||
<i class="fas fa-edit"></i>
|
||||
{% trans "Update" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
@ -25,11 +34,6 @@
|
||||
<dt class="col-sm-3">{% trans "Reporter:" %}</dt>
|
||||
<dd class="col-sm-9"><a href="{{ passage.reporter.get_absolute_url }}">{{ passage.reporter.team }}</a></dd>
|
||||
|
||||
{% if passage.observer %}
|
||||
<dt class="col-sm-3">{% trans "Observer:" %}</dt>
|
||||
<dd class="col-sm-9"><a href="{{ passage.observer.get_absolute_url }}">{{ passage.observer.team }}</a></dd>
|
||||
{% endif %}
|
||||
|
||||
<dt class="col-sm-3">{% trans "Defended solution:" %}</dt>
|
||||
<dd class="col-sm-9"><a href="{{ passage.defended_solution.file.url }}">{{ passage.defended_solution }}</a></dd>
|
||||
|
||||
@ -51,7 +55,6 @@
|
||||
{% if my_note is not None %}
|
||||
<button class="btn btn-info" data-bs-toggle="modal" data-bs-target="#{{ my_note.modal_name }}Modal">{% trans "Update notes" %}</button>
|
||||
{% endif %}
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updatePassageModal">{% trans "Update" %}</button>
|
||||
</div>
|
||||
{% elif user.registration.participates %}
|
||||
<div class="card-footer text-center">
|
||||
@ -70,46 +73,63 @@
|
||||
<div class="card bg-body shadow">
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-8">{% trans "Average points for the defender writing:" %}</dt>
|
||||
<dt class="col-sm-8">
|
||||
{% trans "Average points for the defender writing" %}
|
||||
({{ passage.defender.team.trigram }}) :
|
||||
</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_defender_writing|floatformat }}/20</dd>
|
||||
|
||||
<dt class="col-sm-8">{% trans "Average points for the defender oral:" %}</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_defender_oral|floatformat }}/16</dd>
|
||||
<dt class="col-sm-8">
|
||||
{% trans "Average points for the defender oral" %}
|
||||
({{ passage.defender.team.trigram }}) :
|
||||
</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_defender_oral|floatformat }}/20</dd>
|
||||
|
||||
<dt class="col-sm-8">{% trans "Average points for the opponent writing:" %}</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_opponent_writing|floatformat }}/9</dd>
|
||||
<dt class="col-sm-8">
|
||||
{% trans "Average points for the opponent writing" %}
|
||||
({{ passage.opponent.team.trigram }}) :
|
||||
</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_opponent_writing|floatformat }}/10</dd>
|
||||
|
||||
<dt class="col-sm-8">{% trans "Average points for the opponent oral:" %}</dt>
|
||||
<dt class="col-sm-8">
|
||||
{% trans "Average points for the opponent oral" %}
|
||||
({{ passage.opponent.team.trigram }}) :
|
||||
</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_opponent_oral|floatformat }}/10</dd>
|
||||
|
||||
<dt class="col-sm-8">{% trans "Average points for the reporter writing:" %}</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_reporter_writing|floatformat }}/9</dd>
|
||||
<dt class="col-sm-8">
|
||||
{% trans "Average points for the reporter writing" %}
|
||||
({{ passage.reporter.team.trigram }}) :
|
||||
</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_reporter_writing|floatformat }}/10</dd>
|
||||
|
||||
<dt class="col-sm-8">{% trans "Average points for the reporter oral:" %}</dt>
|
||||
<dt class="col-sm-8">
|
||||
{% trans "Average points for the reporter oral" %}
|
||||
({{ passage.reporter.team.trigram }}) :
|
||||
</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_reporter_oral|floatformat }}/10</dd>
|
||||
|
||||
{% if passage.observer %}
|
||||
<dt class="col-sm-8">{% trans "Average points for the observer oral:" %}</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_observer|floatformat }}/4</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
<hr>
|
||||
|
||||
<dl class="row">
|
||||
<dt class="col-sm-8">{% trans "Defender points:" %}</dt>
|
||||
<dt class="col-sm-8">
|
||||
{% trans "Defender points" %}
|
||||
({{ passage.defender.team.trigram }}) :
|
||||
</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_defender|floatformat }}/52</dd>
|
||||
|
||||
<dt class="col-sm-8">{% trans "Opponent points:" %}</dt>
|
||||
<dt class="col-sm-8">
|
||||
{% trans "Opponent points" %}
|
||||
({{ passage.opponent.team.trigram }}) :
|
||||
</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_opponent|floatformat }}/29</dd>
|
||||
|
||||
<dt class="col-sm-8">{% trans "Reporter points:" %}</dt>
|
||||
<dt class="col-sm-8">
|
||||
{% trans "Reporter points" %}
|
||||
({{ passage.reporter.team.trigram }}) :
|
||||
</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_reporter|floatformat }}/19</dd>
|
||||
|
||||
{% if passage.observer %}
|
||||
<dt class="col-sm-8">{% trans "Observer points:" %}</dt>
|
||||
<dd class="col-sm-4">{{ passage.average_observer|floatformat }}/4</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -5,7 +5,15 @@
|
||||
{% block content %}
|
||||
<div class="card bg-body shadow">
|
||||
<div class="card-header text-center">
|
||||
<h4>{{ pool }}</h4>
|
||||
<h4>
|
||||
{{ pool }}
|
||||
{% if user.registration.is_admin or user.registration in pool.tournament.organizers.all %}
|
||||
<button class="btn btn-sm btn-secondary" data-bs-toggle="modal" data-bs-target="#updatePoolModal">
|
||||
<i class="fas fa-edit"></i>
|
||||
{% trans "Update" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
@ -17,6 +25,11 @@
|
||||
|
||||
<dt class="col-sm-3">{% trans "Letter:" %}</dt>
|
||||
<dd class="col-sm-9">{{ pool.get_letter_display }}</dd>
|
||||
|
||||
{% if pool.participations.count == 5 %}
|
||||
<dt class="col-sm-3">{% trans "Room:" %}</dt>
|
||||
<dd class="col-sm-9">{{ pool.get_room_display }}</dd>
|
||||
{% endif %}
|
||||
|
||||
<dt class="col-sm-3">{% trans "Teams:" %}</dt>
|
||||
<dd class="col-sm-9">
|
||||
@ -62,8 +75,40 @@
|
||||
</a>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">{% trans "BigBlueButton link:" %}</dt>
|
||||
<dd class="col-sm-9">{{ pool.bbb_url|urlize }}</dd>
|
||||
{% if pool.bbb_url %}
|
||||
<dt class="col-sm-3">{% trans "BigBlueButton link:" %}</dt>
|
||||
<dd class="col-sm-9">{{ pool.bbb_url|urlize }}</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if user.registration.is_admin or user.registration.is_volunteer %}
|
||||
{% if user.registration.is_admin or user.registration in pool.tournament.organizers.all or user.registration == pool.jury_president %}
|
||||
<dt class="col-sm-3">{% trans "Notation sheets:" %}</dt>
|
||||
<dd class="col-sm-9">
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-sm btn-info" href="{% url 'participation:pool_scale_note_sheet' pk=pool.pk %}">
|
||||
<i class="fas fa-download"></i>
|
||||
{% trans "Download the scale sheet" %}
|
||||
</a>
|
||||
<a class="btn btn-sm btn-info" href="{% url 'participation:pool_final_note_sheet' pk=pool.pk %}">
|
||||
<i class="fas fa-download"></i>
|
||||
{% trans "Download the final notation sheet" %}
|
||||
</a>
|
||||
<a class="btn btn-sm btn-info" href="{% url 'participation:pool_notation_sheets' pool_id=pool.id %}">
|
||||
<i class="fas fa-archive"></i>
|
||||
{% trans "Download all notation sheets" %}
|
||||
</a>
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">{% trans "Google Sheets Spreadsheet:" %}</dt>
|
||||
<dd class="col-sm-9">
|
||||
<a class="btn btn-sm btn-success" href="https://docs.google.com/spreadsheets/d/{{ pool.tournament.notes_sheet_id }}/edit">
|
||||
<i class="fas fa-table"></i>
|
||||
{% trans "Go to the Google Sheets page of the pool" %}
|
||||
</a>
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
<div class="card bg-body shadow">
|
||||
@ -77,40 +122,24 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% if user.registration.is_volunteer %}
|
||||
<div class="card-footer text-center">
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-info" href="{% url 'participation:pool_scale_note_sheet' pk=pool.pk %}">
|
||||
{% trans "Download the scale sheet" %}{% if pool.passages.count == 5 %} — {% trans "Room" %} 1{% endif %}
|
||||
</a>
|
||||
{% if pool.passages.count == 5 %}
|
||||
<a class="btn btn-info" href="{% url 'participation:pool_scale_note_sheet' pk=pool.pk %}?page=2">
|
||||
{% trans "Room" %} 2
|
||||
{% if user.registration.is_admin or user.registration.is_volunteer %}
|
||||
{% if user.registration.is_admin or user.registration in pool.tournament.organizers.all or user.registration == pool.jury_president %}
|
||||
<div class="card-footer text-center">
|
||||
<div class="btn btn-group">
|
||||
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#uploadNotesModal">
|
||||
<i class="fas fa-upload"></i>
|
||||
{% trans "Upload notes from a spreadsheet file" %}
|
||||
</button>
|
||||
<a class="btn btn-sm btn-info" href="{% url 'participation:pool_notes_template' pk=pool.pk %}">
|
||||
<i class="fas fa-download"></i>
|
||||
{% trans "Download notation spreadsheet" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-info" href="{% url 'participation:pool_final_note_sheet' pk=pool.pk %}">
|
||||
{% trans "Download the final notation sheet" %}{% if pool.passages.count == 5 %} — {% trans "Room" %} 1{% endif %}
|
||||
</a>
|
||||
{% if pool.passages.count == 5 %}
|
||||
<a class="btn btn-info" href="{% url 'participation:pool_final_note_sheet' pk=pool.pk %}?page=2">
|
||||
{% trans "Room" %} 2
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadNotesModal">{% trans "Upload notes from a CSV file" %}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if user.registration.is_volunteer %}
|
||||
<div class="card-footer text-center">
|
||||
<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#addPassageModal">{% trans "Add passage" %}</button>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updatePoolModal">{% trans "Update" %}</button>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updateTeamsModal">{% trans "Update teams" %}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
@ -119,21 +148,11 @@
|
||||
|
||||
{% render_table passages %}
|
||||
|
||||
{% trans "Add passage" as modal_title %}
|
||||
{% trans "Add" as modal_button %}
|
||||
{% url "participation:passage_create" pk=pool.pk as modal_action %}
|
||||
{% include "base_modal.html" with modal_id="addPassage" modal_button_type="success" %}
|
||||
|
||||
{% trans "Update pool" as modal_title %}
|
||||
{% trans "Update" as modal_button %}
|
||||
{% url "participation:pool_update" pk=pool.pk as modal_action %}
|
||||
{% include "base_modal.html" with modal_id="updatePool" %}
|
||||
|
||||
{% trans "Update teams" as modal_title %}
|
||||
{% trans "Update" as modal_button %}
|
||||
{% url "participation:pool_update_teams" pk=pool.pk as modal_action %}
|
||||
{% include "base_modal.html" with modal_id="updateTeams" %}
|
||||
|
||||
{% trans "Upload notes" as modal_title %}
|
||||
{% trans "Upload" as modal_button %}
|
||||
{% url "participation:pool_upload_notes" pk=pool.pk as modal_action %}
|
||||
@ -144,8 +163,6 @@
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initModal("updatePool", "{% url "participation:pool_update" pk=pool.pk %}")
|
||||
initModal("updateTeams", "{% url "participation:pool_update_teams" pk=pool.pk %}")
|
||||
initModal("addPassage", "{% url "participation:passage_create" pk=pool.pk %}")
|
||||
initModal("uploadNotes", "{% url "participation:pool_upload_notes" pk=pool.pk %}")
|
||||
})
|
||||
</script>
|
||||
|
@ -10,19 +10,19 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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">
|
||||
{% for coach in team.coaches.all %}
|
||||
<a href="{% url "registration:user_detail" pk=coach.user.pk %}">{{ coach }}</a>{% if not forloop.last %},{% endif %}
|
||||
@ -31,7 +31,7 @@
|
||||
{% endfor %}
|
||||
</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">
|
||||
{% for student in team.students.all %}
|
||||
<a href="{% url "registration:user_detail" pk=student.user.pk %}">{{ student }}</a>{% if not forloop.last %},{% endif %}
|
||||
@ -40,7 +40,7 @@
|
||||
{% endfor %}
|
||||
</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">
|
||||
{% if team.participation.tournament %}
|
||||
<a href="{% url "participation:tournament_detail" pk=team.participation.tournament.pk %}">{{ team.participation.tournament }}</a>
|
||||
@ -49,7 +49,7 @@
|
||||
{% endif %}
|
||||
</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">
|
||||
{% for participant in team.participants.all %}
|
||||
{% if participant.photo_authorization %}
|
||||
@ -60,8 +60,21 @@
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
{% if team.participation.final %}
|
||||
<dt class="col-sm-6 text-sm-end">{% trans "Photo authorizations (final):" %}</dt>
|
||||
<dd class="col-sm-6">
|
||||
{% for participant in team.participants.all %}
|
||||
{% if participant.photo_authorization_final %}
|
||||
<a href="{{ participant.photo_authorization_final.url }}">{{ participant }}</a>{% if not forloop.last %},{% endif %}
|
||||
{% else %}
|
||||
{{ participant }} ({% trans "Not uploaded yet" %}){% if not forloop.last %},{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
|
||||
{% 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">
|
||||
{% for student in team.students.all %}
|
||||
{% if student.under_18 %}
|
||||
@ -74,7 +87,7 @@
|
||||
{% endfor %}
|
||||
</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">
|
||||
{% for student in team.students.all %}
|
||||
{% if student.under_18 %}
|
||||
@ -87,7 +100,7 @@
|
||||
{% endfor %}
|
||||
</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">
|
||||
{% for student in team.students.all %}
|
||||
{% if student.under_18 %}
|
||||
@ -99,9 +112,24 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</dd>
|
||||
|
||||
{% if team.participation.final %}
|
||||
<dt class="col-sm-6 text-sm-end">{% trans "Parental authorizations (final):" %}</dt>
|
||||
<dd class="col-sm-6">
|
||||
{% for student in team.students.all %}
|
||||
{% if student.under_18_final %}
|
||||
{% if student.parental_authorization_final %}
|
||||
<a href="{{ student.parental_authorization_final.url }}">{{ student }}</a>{% if not forloop.last %},{% endif %}
|
||||
{% else %}
|
||||
{{ student }} ({% trans "Not uploaded yet" %}){% if not forloop.last %},{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</dd>
|
||||
{% 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">
|
||||
{% if team.motivation_letter %}
|
||||
<a href="{{ team.motivation_letter.url }}">{% trans "Download" %}</a>
|
||||
@ -127,7 +155,7 @@
|
||||
<hr class="my-3">
|
||||
{% for student in team.students.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 }}
|
||||
{% if payment.grouped %}({% trans "grouped" %}){% endif %}
|
||||
{% if payment.final %} ({% trans "final" %}){% endif %} :
|
||||
|
@ -73,18 +73,6 @@
|
||||
|
||||
\end{tabular}
|
||||
|
||||
{% if passages.count == 4 %}
|
||||
|
||||
\vfill
|
||||
|
||||
%%%%%%% INTERVENTION EXCEPTIONNELLE
|
||||
\begin{tabular}{|p{14.7cm}|c|p{2cm}|p{2cm}|p{2cm}|p{2cm}|}\hline
|
||||
\multicolumn{2}{|l|}{L'{\bf Intervention exceptionnelle} \normalsize permet de signaler une erreur grave omise par tous.} {% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.observer.team.trigram }} {% endfor %}\\ \hline \hline
|
||||
%ORAL
|
||||
Toute intervention exceptionnelle non pertinente est sanctionn\'ee par une note n\'egative, l'absence d'intervention re\c coit un z\'ero forfaitaire. \phantom{pour avoir oral en entier dans la} \phantom{colonne il} \phantom{faut blablater un peu}& [-4,4] {{ esp|safe }}\\ \hline
|
||||
\end{tabular}
|
||||
{% endif %}
|
||||
|
||||
\newpage
|
||||
|
||||
%%%%%%%%%%%%%%%%%OPPOSANT
|
||||
@ -103,16 +91,16 @@ Toute intervention exceptionnelle non pertinente est sanctionn\'ee par une note
|
||||
\multirow{6}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{3}{20mm}{Questions et discours de l'opposant\textperiodcentered{}e} & Pertinence des questions (importance des sujets abordés, des points soulevés) & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Gestion de l'échange (formulation des questions, réaction aux réponses, articulation entre les questions, gestion du temps) & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Capacité à évaluer la qualité de la prestation de læ Défenseur⋅se (présentation et réponses à l'Opposant⋅e) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&& Réponses aux questions du Rapporteur et du jury (fond et capacité à faire avancer le débat) & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&& Réponses aux questions de læ Rapporteur\textperiodcentered{}rice et du jury (fond et capacité à faire avancer le débat) & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
& Malus & Attitude irrespectueuse ? & [-3,0] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&\multicolumn{3}{|l|}{\bf TOTAL ORAL (/10)} {{ esp|safe }}\\ \hline
|
||||
\end{tabular}
|
||||
|
||||
\vfill
|
||||
|
||||
%%%%%%%%%%%%%%%%%%%%%%RAPPORTEUR
|
||||
%%%%%%%%%%%%%%%%%%%%%%RAPPORTEUR.RICE
|
||||
\begin{tabular}{|c|p{24mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
|
||||
\multicolumn{4}{|l|}{Læ {\bf Rapporteur\textperiodcentered{}e} \normalsize \'evalue le d\'ebat entre læ D\'efenseur\textperiodcentered{}se et l'Opposant\textperiodcentered{}e.} {% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.reporter.team.trigram }} {% endfor %}\\ \hline \hline
|
||||
\multicolumn{4}{|l|}{Læ {\bf Rapporteur\textperiodcentered{}rice} \normalsize \'evalue le d\'ebat entre læ D\'efenseur\textperiodcentered{}se et l'Opposant\textperiodcentered{}e.} {% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.reporter.team.trigram }} {% endfor %}\\ \hline \hline
|
||||
|
||||
%ECRIT
|
||||
\multirow{4}{3mm}{\centering\bf\'E\\ C\\ R\\ I\\ T} &\multirow{3}{20mm}{Partie scientifique} & Recul et esprit critique par rapport à la solution proposée & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
@ -122,10 +110,10 @@ Toute intervention exceptionnelle non pertinente est sanctionn\'ee par une note
|
||||
&\multicolumn{3}{|l|}{\bf TOTAL \'ECRIT (/10)} {{ esp|safe }}\\ \hline \hline
|
||||
|
||||
%ORAL
|
||||
\multirow{6}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{3}{20mm}{Questions et discours de læ rapporteur\textperiodcentered{}e} & \footnotesize Faire prendre de la hauteur au débat (par les sujets abordés, la pertinence des questions posées, les points soulevés, gestion du temps) & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
\multirow{6}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{3}{20mm}{Questions et discours de læ rapporteur\textperiodcentered{}rice} & \footnotesize Faire prendre de la hauteur au débat (par les sujets abordés, la pertinence des questions posées, les points soulevés, gestion du temps) & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& \footnotesize Créer un échange constructif entre les participants (formulation des questions, réaction aux réponses, articulation entre les questions, circulation de la parole) & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
|
||||
&& Capacité à évaluer la qualité des échanges (Défenseur⋅se-Opposant⋅e et à trois) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&& Réponses aux questions du Rapporteur et du jury (fond et capacité à faire avancer le débat) & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&& Réponses aux questions de læ Rapporteur\textperiodcentered{}rice et du jury (fond et capacité à faire avancer le débat) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
& Malus & Attitude irrespectueuse ? & [-3,0] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
|
||||
&\multicolumn{3}{|l|}{\bf TOTAL ORAL (/10)} {{ esp|safe }}\\ \hline
|
||||
\end{tabular}
|
||||
|
@ -37,14 +37,14 @@
|
||||
|
||||
\Large {\bf \tfjmedition$^{e}$ Tournoi Fran\c cais des Jeunes Math\'ematiciennes et Math\'ematiciens \tfjm}\\
|
||||
\vspace{3mm}
|
||||
Tour {{ pool.round }} \;-- Poule {{ pool.get_letter_display }}{{ page }} \;-- {% if pool.round == 1 %}{{ pool.tournament.date_start }}{% else %}{{ pool.tournament.date_end }}{% endif %}
|
||||
Tour {{ pool.round }} \;-- Poule {{ pool.get_letter_display }}{% if pool.participations.count == 5 %} \;-- {{ pool.get_room_display }}{% endif %} \;-- {% if pool.round == 1 %}{{ pool.tournament.date_start }}{% else %}{{ pool.tournament.date_end }}{% endif %}
|
||||
|
||||
|
||||
\vspace{15mm}
|
||||
|
||||
|
||||
\begin{tabular}{|p{35mm}{% for passage in passages.all %}{% if passages.count == 3 %}|p{3cm}|p{3cm}{% else %}|p{2.5cm}|p{2.5cm}{% endif %}{% endfor %}|}\hline
|
||||
\multirow{2}{35mm}{\LARGE R\^ole} {% for passage in passages.all %}& \multicolumn{2}{c|}{ \Large Probl\`eme {{ passage.solution_number }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
|
||||
\begin{tabular}{|p{40mm}{% for passage in passages.all %}{% if passages.count == 3 %}|p{3cm}|p{3cm}{% else %}|p{2.5cm}|p{2.5cm}{% endif %}{% endfor %}|}\hline
|
||||
\multirow{2}{40mm}{\LARGE R\^ole} {% for passage in passages.all %}& \multicolumn{2}{c|}{ \Large Probl\`eme {{ passage.solution_number }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
|
||||
{% for passage in passages.all %}& \hspace{4mm} {\Large \'ECRIT} & \hspace{4mm} {\Large ORAL}{% endfor %} \\ \hline
|
||||
\multirow{2}{35mm}{\LARGE D\'efenseur\textperiodcentered{}se} {% for passage in passages.all %}& \multicolumn{2}{c|}{\Large {{ passage.defender.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
|
||||
{% for passage in passages.all %}
|
||||
@ -56,33 +56,17 @@ Tour {{ pool.round }} \;-- Poule {{ pool.get_letter_display }}{{ page }} \;-- {%
|
||||
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 10$
|
||||
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 10$
|
||||
{% endfor %} & \hline
|
||||
\multirow{2}{35mm}{\LARGE Rapporteur\textperiodcentered{}e} {% for passage in passages.all %}& \multicolumn{2}{c|}{\Large {{ passage.reporter.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
|
||||
\multirow{2}{35mm}{\LARGE Rapporteur\textperiodcentered{}rice} {% for passage in passages.all %}& \multicolumn{2}{c|}{\Large {{ passage.reporter.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
|
||||
{% for passage in passages.all %}
|
||||
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 10$
|
||||
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 10$
|
||||
{% endfor %} & \hline
|
||||
{% if passages.count == 4 %}
|
||||
\multirow{4}{35mm}{\Large Intervention exceptionnelle}{% for passage in passages.all %} & \multicolumn{2}{c|}{\Large {{ passage.observer.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
|
||||
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
|
||||
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
|
||||
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
|
||||
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}\\
|
||||
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
|
||||
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
|
||||
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
|
||||
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}\\
|
||||
& \multicolumn{2}{c|}{\centering \normalsize$-4\leq x\leq 4$}
|
||||
& \multicolumn{2}{c|}{\centering \normalsize$-4\leq x\leq 4$}
|
||||
& \multicolumn{2}{c|}{\centering \normalsize$-4\leq x\leq 4$}
|
||||
& \multicolumn{2}{c|}{\centering \normalsize$-4\leq x\leq 4$} & \hline
|
||||
{% endif %}
|
||||
|
||||
\end{tabular}
|
||||
|
||||
\vspace{15mm}
|
||||
|
||||
\LARGE Nom jur\'e\textperiodcentered{}e :
|
||||
{% if is_jury %}\underline{ {{ user.first_name|safe }} {{ user.last_name|safe }} }{% else %}\underline{\phantom{Phrase suffisamment longue pour le nom}}{% endif %}
|
||||
{% if jury %}\underline{ {{ jury.user.first_name|safe }} {{ jury.user.last_name|safe }} }{% else %}\underline{\phantom{Phrase suffisamment longue pour le nom}}{% endif %}
|
||||
$\qquad$ Signature : \underline{\phantom{Phrase moins longue}}
|
||||
|
||||
\newpage
|
||||
|
@ -9,59 +9,62 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-xl-6 text-end">{% trans 'organizers'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ tournament.organizers.all|join:", " }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'organizers'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{{ tournament.organizers.all|join:", " }}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'size'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ tournament.max_teams }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'size'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{{ tournament.max_teams }}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'place'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ tournament.place }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'place'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{{ tournament.place }}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'price'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{% if tournament.price %}{{ tournament.price }} €{% else %}{% trans "Free" %}{% endif %}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'price'|capfirst %}</dt>
|
||||
<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>
|
||||
<dd class="col-xl-6">{{ tournament.remote|yesno }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'remote'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{{ tournament.remote|yesno }}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'dates'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{% trans "From" %} {{ tournament.date_start }} {% trans "to" %} {{ tournament.date_end }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'dates'|capfirst %}</dt>
|
||||
<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>
|
||||
<dd class="col-xl-6">{{ tournament.inscription_limit }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'date of registration closing'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{{ tournament.inscription_limit }}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'date of maximal solution submission'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ tournament.solution_limit }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'date of maximal solution submission'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{{ tournament.solution_limit }}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'date of the random draw'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ tournament.solutions_draw }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'date of the random draw'|capfirst %}</dt>
|
||||
<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>
|
||||
<dd class="col-xl-6">{{ tournament.syntheses_first_phase_limit }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'date of maximal syntheses submission for the first round'|capfirst %}</dt>
|
||||
<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>
|
||||
<dd class="col-xl-6">{{ tournament.solutions_available_second_phase }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'date when solutions of round 2 are available'|capfirst %}</dt>
|
||||
<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>
|
||||
<dd class="col-xl-6">{{ tournament.syntheses_second_phase_limit }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'date of maximal syntheses submission for the second round'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{{ tournament.syntheses_second_phase_limit }}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'description'|capfirst %}</dt>
|
||||
<dd class="col-xl-6">{{ tournament.description }}</dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'description'|capfirst %}</dt>
|
||||
<dd class="col-sm-6">{{ tournament.description }}</dd>
|
||||
|
||||
<dt class="col-xl-6 text-end">{% trans 'To contact organizers' %}</dt>
|
||||
<dd class="col-xl-6"><a href="mailto:{{ tournament.organizers_email }}">{{ tournament.organizers_email }}</a></dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'To contact organizers' %}</dt>
|
||||
<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>
|
||||
<dd class="col-xl-6"><a href="mailto:{{ tournament.jurys_email }}">{{ tournament.jurys_email }}</a></dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'To contact juries' %}</dt>
|
||||
<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>
|
||||
<dd class="col-xl-6"><a href="mailto:{{ tournament.teams_email }}">{{ tournament.teams_email }}</a></dd>
|
||||
<dt class="col-sm-6 text-sm-end">{% trans 'To contact valid teams' %}</dt>
|
||||
<dd class="col-sm-6"><a href="mailto:{{ tournament.teams_email }}">{{ tournament.teams_email }}</a></dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{% if user.registration.is_admin or user.registration in tournament.organizers.all %}
|
||||
<div class="card-footer text-center">
|
||||
<a href="{% url "participation:tournament_update" pk=tournament.pk %}"><button class="btn btn-secondary">{% trans "Edit tournament" %}</button></a>
|
||||
<a class="btn btn-secondary" href="{% url "participation:tournament_update" pk=tournament.pk %}">
|
||||
<i class="fas fa-edit"></i>
|
||||
{% trans "Edit tournament" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -100,29 +103,74 @@
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
{% for participation, note in notes %}
|
||||
<li><strong>{{ participation.team }} :</strong> {{ note|floatformat }}</li>
|
||||
<li>
|
||||
<strong>{{ participation.team }} :</strong> {{ note|floatformat }}
|
||||
{% if available_notes_2 or user.registration.is_volunteer %}
|
||||
{% if not tournament.final and participation.mention %}
|
||||
— {{ participation.mention }}
|
||||
{% endif %}
|
||||
{% if tournament.final and participation.mention_final %}
|
||||
— {{ participation.mention_final }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if participation.final and not tournament.final %}
|
||||
<span class="badge badge-sm text-bg-warning">
|
||||
<i class="fas fa-medal"></i>
|
||||
{% trans "Selected for final tournament" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if user.registration.is_admin or user.registration in tournament.organizers.all %}
|
||||
{% if team_selectable_for_final == participation %}
|
||||
<a href="{% url 'participation:select_team_final' pk=tournament.pk participation_id=participation.pk %}"
|
||||
class="badge badge-sm text-bg-success">
|
||||
<i class="fas fa-medal"></i>
|
||||
{% trans "Select for final tournament" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% if not available_notes_1 or not available_notes_2 %}
|
||||
{% if user.registration.is_admin or user.registration in tournament.organizers.all %}
|
||||
<div class="card-footer text-center">
|
||||
<div class="btn-group">
|
||||
{% if not available_notes_1 %}
|
||||
<a href="{% url 'participation:tournament_publish_notes' pk=tournament.pk round=1 %}" class="btn btn-info">
|
||||
<i class="fas fa-upload"></i>
|
||||
{% trans "Publish notes for first round" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if not available_notes_2 %}
|
||||
<a href="{% url 'participation:tournament_publish_notes' pk=tournament.pk round=2 %}" class="btn btn-info">
|
||||
<i class="fas fa-upload"></i>
|
||||
{% trans "Publish notes for second round" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if user.registration.is_admin or user.registration in tournament.organizers.all %}
|
||||
<div class="card-footer text-center">
|
||||
<div class="btn-group">
|
||||
<a href="{% url 'participation:tournament_harmonize' pk=tournament.pk round=1 %}" class="btn btn-secondary">
|
||||
<i class="fas fa-ranking-star"></i>
|
||||
{% trans "Harmonize" %} - {% trans "Day" %} 1
|
||||
</a>
|
||||
<a href="{% url 'participation:tournament_harmonize' pk=tournament.pk round=2 %}" class="btn btn-secondary">
|
||||
<i class="fas fa-ranking-star"></i>
|
||||
{% trans "Harmonize" %} - {% trans "Day" %} 2
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<div class="btn-group">
|
||||
{% if not available_notes_1 %}
|
||||
<a href="{% url 'participation:tournament_publish_notes' pk=tournament.pk round=1 %}" class="btn btn-sm btn-info">
|
||||
<i class="fas fa-eye"></i>
|
||||
{% trans "Publish notes for first round" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'participation:tournament_publish_notes' pk=tournament.pk round=1 %}?hide" class="btn btn-sm btn-danger">
|
||||
<i class="fas fa-eye-slash"></i>
|
||||
{% trans "Unpublish notes for first round" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if not available_notes_2 %}
|
||||
<a href="{% url 'participation:tournament_publish_notes' pk=tournament.pk round=2 %}" class="btn btn-sm btn-info">
|
||||
<i class="fas fa-eye"></i>
|
||||
{% trans "Publish notes for second round" %}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'participation:tournament_publish_notes' pk=tournament.pk round=2 %}?hide" class="btn btn-sm btn-danger">
|
||||
<i class="fas fa-eye-slash"></i>
|
||||
{% trans "Unpublish notes for second round" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -195,13 +243,14 @@
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a>
|
||||
Archive de tous les barèmes de notes à imprimer triés par poule
|
||||
<a href="https://docs.google.com/spreadsheets/d/{{ tournament.notes_sheet_id }}/edit">
|
||||
<i class="fas fa-table"></i>
|
||||
Tableur de notes sur Google Sheets
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a>
|
||||
Archive de tous les tableurs de notes à saisir triés par poule
|
||||
<a href="{% url "participation:tournament_notation_sheets" tournament_id=tournament.id %}">
|
||||
Archive de toutes les feuilles de notes à imprimer triées par poule
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card bg-body shadow">
|
||||
<div class="card-header text-center">
|
||||
<h5>{% trans "Ranking" %}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-striped text-center">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Rank" %}</th>
|
||||
<th>{% trans "team"|capfirst %}</th>
|
||||
<th>{% trans "Note" %}</th>
|
||||
<th>{% trans "Including bonus / malus" %}</th>
|
||||
<th>{% trans "Add bonus / malus" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for participation, note in notes %}
|
||||
<tr>
|
||||
<td>{{ forloop.counter }}</td>
|
||||
<td>{{ participation.team }}</td>
|
||||
<td>{{ note.note|floatformat }}</td>
|
||||
<td>{% if note.tweak >= 0 %}+{% endif %}{{ note.tweak }}</td>
|
||||
<td>
|
||||
<div class="btn-group">
|
||||
<a href="{% url 'participation:tournament_harmonize_note' pk=tournament.pk round=round action="add" trigram=participation.team.trigram %}"
|
||||
class="btn btn-sm btn-success">
|
||||
+1
|
||||
</a>
|
||||
<a href="{% url 'participation:tournament_harmonize_note' pk=tournament.pk round=round action="remove" trigram=participation.team.trigram %}"
|
||||
class="btn btn-sm btn-danger">
|
||||
-1
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer text-center">
|
||||
<a class="btn btn-secondary" href="{% url 'participation:tournament_detail' pk=tournament.pk %}">
|
||||
<i class="fas fa-arrow-left-long"></i>
|
||||
{% trans "Back to tournament page" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -6,9 +6,16 @@
|
||||
{% block content %}
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<div id="form-content">
|
||||
<div class="alert alert-warning">
|
||||
{% url 'participation:pool_jury' pk=pool.jury as jury_url %}
|
||||
{% blocktrans trimmed with jury_url=jury_url %}
|
||||
Rows that are full of zeros are ignored.
|
||||
Unknown juries are not considered.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
<div class="alert alert-info">
|
||||
<a class="alert-link" href="{% url "participation:pool_notes_template" pk=pool.pk %}">
|
||||
{% trans "Download empty notation sheet" %}
|
||||
{% trans "Download empty notation sheet" %}
|
||||
</a>
|
||||
</div>
|
||||
{% csrf_token %}
|
||||
|
@ -7,10 +7,10 @@
|
||||
<div id="form-content">
|
||||
<div class="alert alert-info">
|
||||
{% trans "Templates:" %}
|
||||
<a class="alert-link" href="{% static "Fiche_synthèse.pdf" %}"> PDF</a> —
|
||||
<a class="alert-link" href="{% static "Fiche_synthèse.tex" %}"> TEX</a> —
|
||||
<a class="alert-link" href="{% static "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.pdf" %}"> PDF</a> —
|
||||
<a class="alert-link" href="{% static "tfjm/Fiche_synthèse.tex" %}"> TEX</a> —
|
||||
<a class="alert-link" href="{% static "tfjm/Fiche_synthèse.odt" %}"> ODT</a> —
|
||||
<a class="alert-link" href="{% static "tfjm/Fiche_synthèse.docx" %}" title="{% trans "Warning: non-free format" %}"> DOCX</a>
|
||||
</div>
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
|
@ -2,16 +2,17 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.urls import path
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from .views import CreateTeamView, FinalNotationSheetTemplateView, JoinTeamView, MyParticipationDetailView, \
|
||||
MyTeamDetailView, NoteUpdateView, ParticipationDetailView, PassageCreateView, PassageDetailView, \
|
||||
PassageUpdateView, PoolCreateView, PoolDetailView, PoolJuryView, PoolNotesTemplateView, \
|
||||
PoolPresideJuryView, PoolRemoveJuryView, PoolUpdateTeamsView, PoolUpdateView, PoolUploadNotesView, \
|
||||
ScaleNotationSheetTemplateView, SolutionsDownloadView, SolutionUploadView, SynthesisUploadView, \
|
||||
from .views import CreateTeamView, FinalNotationSheetTemplateView, GSheetNotificationsView, JoinTeamView, \
|
||||
MyParticipationDetailView, MyTeamDetailView, NotationSheetsArchiveView, NoteUpdateView, ParticipationDetailView, \
|
||||
PassageDetailView, PassageUpdateView, PoolCreateView, PoolDetailView, PoolJuryView, PoolNotesTemplateView, \
|
||||
PoolPresideJuryView, PoolRemoveJuryView, PoolUpdateView, PoolUploadNotesView, \
|
||||
ScaleNotationSheetTemplateView, SelectTeamFinalView, \
|
||||
SolutionsDownloadView, SolutionUploadView, SynthesisUploadView, \
|
||||
TeamAuthorizationsView, TeamDetailView, TeamLeaveView, TeamListView, TeamUpdateView, \
|
||||
TeamUploadMotivationLetterView, TournamentCreateView, TournamentDetailView, TournamentExportCSVView, \
|
||||
TournamentListView, TournamentPaymentsView, TournamentPublishNotesView, TournamentUpdateView
|
||||
TournamentHarmonizeNoteView, TournamentHarmonizeView, TournamentListView, TournamentPaymentsView, \
|
||||
TournamentPublishNotesView, TournamentUpdateView
|
||||
|
||||
|
||||
app_name = "participation"
|
||||
@ -43,8 +44,18 @@ urlpatterns = [
|
||||
name="tournament_solutions"),
|
||||
path("tournament/<int:tournament_id>/syntheses/", SolutionsDownloadView.as_view(),
|
||||
name="tournament_syntheses"),
|
||||
path("tournament/<int:tournament_id>/notation/sheets/", NotationSheetsArchiveView.as_view(),
|
||||
name="tournament_notation_sheets"),
|
||||
path("tournament/<int:pk>/notation/notifications/", GSheetNotificationsView.as_view(),
|
||||
name="tournament_gsheet_notifications"),
|
||||
path("tournament/<int:pk>/publish-notes/<int:round>/", TournamentPublishNotesView.as_view(),
|
||||
name="tournament_publish_notes"),
|
||||
path("tournament/<int:pk>/harmonize/<int:round>/", TournamentHarmonizeView.as_view(),
|
||||
name="tournament_harmonize"),
|
||||
path("tournament/<int:pk>/harmonize/<int:round>/<str:action>/<str:trigram>/", TournamentHarmonizeNoteView.as_view(),
|
||||
name="tournament_harmonize_note"),
|
||||
path("tournament/<int:pk>/select-final/<int:participation_id>/", SelectTeamFinalView.as_view(),
|
||||
name="select_team_final"),
|
||||
path("pools/create/", PoolCreateView.as_view(), name="pool_create"),
|
||||
path("pools/<int:pk>/", PoolDetailView.as_view(), name="pool_detail"),
|
||||
path("pools/<int:pk>/update/", PoolUpdateView.as_view(), name="pool_update"),
|
||||
@ -52,16 +63,14 @@ urlpatterns = [
|
||||
path("pools/<int:pool_id>/syntheses/", SolutionsDownloadView.as_view(), name="pool_download_syntheses"),
|
||||
path("pools/<int:pk>/notation/scale/", ScaleNotationSheetTemplateView.as_view(), name="pool_scale_note_sheet"),
|
||||
path("pools/<int:pk>/notation/final/", FinalNotationSheetTemplateView.as_view(), name="pool_final_note_sheet"),
|
||||
path("pools/<int:pk>/update-teams/", PoolUpdateTeamsView.as_view(), name="pool_update_teams"),
|
||||
path("pools/<int:pool_id>/notation/sheets/", NotationSheetsArchiveView.as_view(), name="pool_notation_sheets"),
|
||||
path("pools/<int:pk>/jury/", PoolJuryView.as_view(), name="pool_jury"),
|
||||
path("pools/<int:pk>/jury/remove/<int:jury_id>/", PoolRemoveJuryView.as_view(), name="pool_remove_jury"),
|
||||
path("pools/<int:pk>/jury/preside/<int:jury_id>/", PoolPresideJuryView.as_view(), name="pool_preside"),
|
||||
path("pools/<int:pk>/upload-notes/", PoolUploadNotesView.as_view(), name="pool_upload_notes"),
|
||||
path("pools/<int:pk>/upload-notes/template/", PoolNotesTemplateView.as_view(), name="pool_notes_template"),
|
||||
path("pools/passages/add/<int:pk>/", PassageCreateView.as_view(), name="passage_create"),
|
||||
path("pools/passages/<int:pk>/", PassageDetailView.as_view(), name="passage_detail"),
|
||||
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/notes/<int:pk>/", NoteUpdateView.as_view(), name="update_notes"),
|
||||
path("chat/", TemplateView.as_view(template_name="participation/chat.html"), name="chat")
|
||||
]
|
||||
|
@ -1,7 +1,10 @@
|
||||
# Copyright (C) 2020 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import csv
|
||||
from hashlib import sha1
|
||||
from io import BytesIO
|
||||
import os
|
||||
import subprocess
|
||||
@ -23,11 +26,15 @@ from django.template.loader import render_to_string
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.timezone import localtime
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.generic import CreateView, DetailView, FormView, RedirectView, TemplateView, UpdateView, View
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
from django.views.generic.edit import FormMixin, ProcessFormView
|
||||
from django_tables2 import MultiTableMixin, SingleTableMixin, SingleTableView
|
||||
import gspread
|
||||
from magic import Magic
|
||||
from odf.opendocument import OpenDocumentSpreadsheet
|
||||
from odf.style import Style, TableCellProperties, TableColumnProperties, TextProperties
|
||||
@ -39,9 +46,9 @@ from tfjm.lists import get_sympa_client
|
||||
from tfjm.views import AdminMixin, VolunteerMixin
|
||||
|
||||
from .forms import AddJuryForm, JoinTeamForm, MotivationLetterForm, NoteForm, ParticipationForm, PassageForm, \
|
||||
PoolForm, PoolTeamsForm, RequestValidationForm, SolutionForm, SynthesisForm, TeamForm, TournamentForm, \
|
||||
PoolForm, RequestValidationForm, SolutionForm, SynthesisForm, TeamForm, TournamentForm, \
|
||||
UploadNotesForm, ValidateParticipationForm
|
||||
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament
|
||||
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament, Tweak
|
||||
from .tables import NoteTable, ParticipationTable, PassageTable, PoolTable, TeamTable, TournamentTable
|
||||
|
||||
|
||||
@ -252,17 +259,20 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView)
|
||||
payment = Payment.objects.get(registrations=registration, final=False)
|
||||
else:
|
||||
payment = None
|
||||
mail_context = dict(domain=domain, registration=registration, team=self.object, payment=payment,
|
||||
message=form.cleaned_data["message"])
|
||||
mail_plain = render_to_string("participation/mails/team_validated.txt", mail_context)
|
||||
mail_html = render_to_string("participation/mails/team_validated.html", mail_context)
|
||||
mail_context_plain = dict(domain=domain, registration=registration, team=self.object, payment=payment,
|
||||
message=form.cleaned_data["message"])
|
||||
mail_context_html = dict(domain=domain, registration=registration, team=self.object, payment=payment,
|
||||
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)
|
||||
elif "invalidate" in self.request.POST:
|
||||
self.object.participation.valid = None
|
||||
self.object.participation.save()
|
||||
mail_context = dict(team=self.object, message=form.cleaned_data["message"])
|
||||
mail_plain = render_to_string("participation/mails/team_not_validated.txt", mail_context)
|
||||
mail_html = render_to_string("participation/mails/team_not_validated.html", mail_context)
|
||||
mail_context_plain = dict(team=self.object, message=form.cleaned_data["message"])
|
||||
mail_context_html = dict(team=self.object, message=form.cleaned_data["message"].replace('\n', '<br>'))
|
||||
mail_plain = render_to_string("participation/mails/team_not_validated.txt", mail_context_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],
|
||||
html_message=mail_html)
|
||||
else:
|
||||
@ -384,7 +394,7 @@ class TeamAuthorizationsView(LoginRequiredMixin, View):
|
||||
tournament = Tournament.objects.get(pk=kwargs["tournament_id"])
|
||||
|
||||
if user.registration.is_admin or user.registration.is_volunteer \
|
||||
and (user.registration in tournament.organizers
|
||||
and (user.registration in tournament.organizers.all()
|
||||
or (team is not None and team.participation.final
|
||||
and user.registration in Tournament.final_tournament().organizers)):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
@ -393,6 +403,7 @@ class TeamAuthorizationsView(LoginRequiredMixin, View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
if 'team_id' in kwargs:
|
||||
team = Team.objects.get(pk=kwargs["team_id"])
|
||||
tournament = team.participation.tournament
|
||||
teams = [team]
|
||||
filename = _("Authorizations of team {trigram}.zip").format(trigram=team.trigram)
|
||||
else:
|
||||
@ -409,21 +420,25 @@ class TeamAuthorizationsView(LoginRequiredMixin, View):
|
||||
for participant in team.participants.all():
|
||||
user_prefix = f"{team_prefix}{participant.user.first_name} {participant.user.last_name}/"
|
||||
|
||||
if participant.photo_authorization \
|
||||
and participant.photo_authorization.storage.exists(participant.photo_authorization.path):
|
||||
mime_type = magic.from_file("media/" + participant.photo_authorization.name)
|
||||
ext = mime_type.split("/")[1].replace("jpeg", "jpg")
|
||||
zf.write("media/" + participant.photo_authorization.name,
|
||||
user_prefix + _("Photo authorization of {participant}.{ext}")
|
||||
.format(participant=str(participant), ext=ext))
|
||||
if 'team_id' in kwargs or not tournament.final:
|
||||
# Don't include the photo authorization and the parental authorization of the regional tournament
|
||||
# in the final authorizations
|
||||
if participant.photo_authorization \
|
||||
and participant.photo_authorization.storage.exists(participant.photo_authorization.path):
|
||||
mime_type = magic.from_file("media/" + participant.photo_authorization.name)
|
||||
ext = mime_type.split("/")[1].replace("jpeg", "jpg")
|
||||
zf.write("media/" + participant.photo_authorization.name,
|
||||
user_prefix + _("Photo authorization of {participant}.{ext}")
|
||||
.format(participant=str(participant), ext=ext))
|
||||
|
||||
if participant.is_student and participant.parental_authorization \
|
||||
and participant.parental_authorization.storage.exists(participant.parental_authorization.path):
|
||||
mime_type = magic.from_file("media/" + participant.parental_authorization.name)
|
||||
ext = mime_type.split("/")[1].replace("jpeg", "jpg")
|
||||
zf.write("media/" + participant.parental_authorization.name,
|
||||
user_prefix + _("Parental authorization of {participant}.{ext}")
|
||||
.format(participant=str(participant), ext=ext))
|
||||
if participant.is_student and participant.parental_authorization \
|
||||
and participant.parental_authorization.storage.exists(
|
||||
participant.parental_authorization.path):
|
||||
mime_type = magic.from_file("media/" + participant.parental_authorization.name)
|
||||
ext = mime_type.split("/")[1].replace("jpeg", "jpg")
|
||||
zf.write("media/" + participant.parental_authorization.name,
|
||||
user_prefix + _("Parental authorization of {participant}.{ext}")
|
||||
.format(participant=str(participant), ext=ext))
|
||||
|
||||
if participant.is_student and participant.health_sheet \
|
||||
and participant.health_sheet.storage.exists(participant.health_sheet.path):
|
||||
@ -441,6 +456,26 @@ class TeamAuthorizationsView(LoginRequiredMixin, View):
|
||||
user_prefix + _("Vaccine sheet of {participant}.{ext}")
|
||||
.format(participant=str(participant), ext=ext))
|
||||
|
||||
if 'team_id' in kwargs or tournament.final:
|
||||
# Don't include final authorizations in the regional authorizations
|
||||
if participant.photo_authorization_final \
|
||||
and participant.photo_authorization_final.storage.exists(
|
||||
participant.photo_authorization_final.path):
|
||||
mime_type = magic.from_file("media/" + participant.photo_authorization_final.name)
|
||||
ext = mime_type.split("/")[1].replace("jpeg", "jpg")
|
||||
zf.write("media/" + participant.photo_authorization_final.name,
|
||||
user_prefix + _("Photo authorization of {participant} (final).{ext}")
|
||||
.format(participant=str(participant), ext=ext))
|
||||
|
||||
if participant.is_student and participant.parental_authorization_final \
|
||||
and participant.parental_authorization_final.storage.exists(
|
||||
participant.parental_authorization_final.path):
|
||||
mime_type = magic.from_file("media/" + participant.parental_authorization_final.name)
|
||||
ext = mime_type.split("/")[1].replace("jpeg", "jpg")
|
||||
zf.write("media/" + participant.parental_authorization_final.name,
|
||||
user_prefix + _("Parental authorization of {participant} (final).{ext}")
|
||||
.format(participant=str(participant), ext=ext))
|
||||
|
||||
if team.motivation_letter and team.motivation_letter.storage.exists(team.motivation_letter.path):
|
||||
mime_type = magic.from_file("media/" + team.motivation_letter.name)
|
||||
ext = mime_type.split("/")[1].replace("jpeg", "jpg")
|
||||
@ -585,16 +620,22 @@ class TournamentDetailView(MultiTableMixin, DetailView):
|
||||
or (self.request.user.is_authenticated and self.request.user.registration.is_volunteer))
|
||||
if note:
|
||||
notes[participation] = note
|
||||
context["notes"] = sorted(notes.items(), key=lambda x: x[1], reverse=True)
|
||||
sorted_notes = sorted(notes.items(), key=lambda x: x[1], reverse=True)
|
||||
context["notes"] = sorted_notes
|
||||
context["available_notes_1"] = all(pool.results_available for pool in self.object.pools.filter(round=1).all())
|
||||
context["available_notes_2"] = all(pool.results_available for pool in self.object.pools.filter(round=2).all())
|
||||
|
||||
if not self.object.final and notes and context["available_notes_2"] \
|
||||
and not self.request.user.is_anonymous and self.request.user.registration.is_volunteer:
|
||||
context["team_selectable_for_final"] = next(participation for participation, _note in sorted_notes
|
||||
if not participation.final)
|
||||
|
||||
return context
|
||||
|
||||
def get_tables(self):
|
||||
return [
|
||||
ParticipationTable(self.object.participations.all()),
|
||||
PoolTable(self.object.pools.order_by('id').all()),
|
||||
PoolTable(self.object.pools.all()),
|
||||
]
|
||||
|
||||
|
||||
@ -619,8 +660,11 @@ class TournamentPaymentsView(VolunteerMixin, SingleTableMixin, DetailView):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_table_data(self):
|
||||
return Payment.objects.filter(registrations__team__participation__tournament=self.get_object()) \
|
||||
.annotate(team_id=F('registrations__team')).order_by('-valid', 'registrations__team__trigram') \
|
||||
if self.object.final:
|
||||
payments = Payment.objects.filter(final=True)
|
||||
else:
|
||||
payments = Payment.objects.filter(registrations__team__participation__tournament=self.get_object())
|
||||
return payments.annotate(team_id=F('registrations__team')).order_by('-valid', 'registrations__team__trigram') \
|
||||
.distinct().all()
|
||||
|
||||
|
||||
@ -650,7 +694,7 @@ class TournamentExportCSVView(VolunteerMixin, DetailView):
|
||||
if 'all' not in request.GET:
|
||||
participations = participations.filter(valid=True)
|
||||
for participation in participations.order_by('-valid', 'team__trigram').all():
|
||||
for registration in participation.team.participants\
|
||||
for registration in participation.team.participants \
|
||||
.order_by('coachregistration', 'user__last_name').all():
|
||||
writer.writerow({
|
||||
'Tournoi': tournament.name,
|
||||
@ -702,14 +746,125 @@ class TournamentPublishNotesView(VolunteerMixin, SingleObjectMixin, RedirectView
|
||||
raise Http404
|
||||
|
||||
tournament = Tournament.objects.get(pk=kwargs["pk"])
|
||||
tournament.pools.filter(round=kwargs["round"]).update(results_available=True)
|
||||
messages.success(request, _("Notes published!"))
|
||||
tournament.pools.filter(round=kwargs["round"]).update(results_available='hide' not in request.GET)
|
||||
if 'hide' not in request.GET:
|
||||
messages.success(request, _("Notes published!"))
|
||||
else:
|
||||
messages.success(request, _("Notes hidden!"))
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def get_redirect_url(self, *args, **kwargs):
|
||||
return reverse_lazy("participation:tournament_detail", args=(kwargs['pk'],))
|
||||
|
||||
|
||||
class TournamentHarmonizeView(VolunteerMixin, DetailView):
|
||||
"""
|
||||
Harmonize the notes of a tournament.
|
||||
"""
|
||||
model = Tournament
|
||||
template_name = "participation/tournament_harmonize.html"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
return self.handle_no_permission()
|
||||
tournament = self.get_object()
|
||||
reg = request.user.registration
|
||||
if not reg.is_admin and (not reg.is_volunteer or tournament not in reg.organized_tournaments.all()):
|
||||
return self.handle_no_permission()
|
||||
if self.kwargs['round'] not in (1, 2):
|
||||
raise Http404
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
tournament = self.get_object()
|
||||
context['round'] = self.kwargs['round']
|
||||
context['pools'] = tournament.pools.filter(round=context["round"]).all()
|
||||
context['title'] = _("Harmonize notes of {tournament} - Day {round}") \
|
||||
.format(tournament=tournament, round=context["round"])
|
||||
|
||||
notes = dict()
|
||||
for participation in self.object.participations.filter(valid=True).all():
|
||||
note = sum(pool.average(participation) for pool in context['pools'])
|
||||
tweak = sum(tweak.diff for tweak in participation.tweaks.filter(pool__in=context['pools']).all())
|
||||
notes[participation] = {'note': note, 'tweak': tweak}
|
||||
context["notes"] = sorted(notes.items(), key=lambda x: x[1]['note'], reverse=True)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class TournamentHarmonizeNoteView(VolunteerMixin, DetailView):
|
||||
model = Tournament
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
return self.handle_no_permission()
|
||||
tournament = self.get_object()
|
||||
reg = request.user.registration
|
||||
if not reg.is_admin and (not reg.is_volunteer or tournament not in reg.organized_tournaments.all()):
|
||||
return self.handle_no_permission()
|
||||
if self.kwargs['round'] not in (1, 2) or self.kwargs['action'] not in ('add', 'remove') \
|
||||
or self.kwargs['trigram'] not in [p.team.trigram
|
||||
for p in tournament.participations.filter(valid=True).all()]:
|
||||
raise Http404
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
tournament = self.get_object()
|
||||
participation = tournament.participations.filter(valid=True).get(team__trigram=kwargs['trigram'])
|
||||
pool = tournament.pools.get(round=kwargs['round'], participations=participation)
|
||||
tweak_qs = Tweak.objects.filter(participation=participation, pool=pool)
|
||||
old_diff = tweak_qs.first().diff if tweak_qs.exists() else 0
|
||||
new_diff = old_diff + (1 if kwargs['action'] == 'add' else -1)
|
||||
if new_diff == 0:
|
||||
tweak_qs.delete()
|
||||
else:
|
||||
tweak_qs.update_or_create(defaults={'diff': new_diff},
|
||||
create_defaults={'diff': new_diff, 'participation': participation, 'pool': pool})
|
||||
|
||||
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
|
||||
spreadsheet = gc.open_by_key(tournament.notes_sheet_id)
|
||||
worksheet = spreadsheet.worksheet("Classement final")
|
||||
column = 3 if kwargs['round'] == 1 else 5
|
||||
row = worksheet.find(f"{participation.team.name} ({participation.team.trigram})", in_column=1).row
|
||||
worksheet.update_cell(row, column, new_diff)
|
||||
|
||||
return redirect(reverse_lazy("participation:tournament_harmonize", args=(tournament.pk, kwargs['round'],)))
|
||||
|
||||
|
||||
class SelectTeamFinalView(VolunteerMixin, DetailView):
|
||||
model = Tournament
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
return self.handle_no_permission()
|
||||
tournament = self.get_object()
|
||||
reg = request.user.registration
|
||||
if not reg.is_admin and (not reg.is_volunteer or tournament not in reg.organized_tournaments.all()):
|
||||
return self.handle_no_permission()
|
||||
participation_qs = tournament.participations.filter(pk=self.kwargs["participation_id"])
|
||||
if not participation_qs.exists():
|
||||
raise Http404
|
||||
self.participation = participation_qs.get()
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
@transaction.atomic
|
||||
def get(self, request, *args, **kwargs):
|
||||
tournament = self.get_object()
|
||||
self.participation.final = True
|
||||
self.participation.save()
|
||||
for regional_sol in self.participation.solutions.filter(final_solution=False).all():
|
||||
final_sol, _created = Solution.objects.get_or_create(participation=self.participation, final_solution=True,
|
||||
problem=regional_sol.problem)
|
||||
final_sol: Solution
|
||||
with open(regional_sol.file.path, 'rb') as f:
|
||||
final_sol.file.save(regional_sol.file.name, f)
|
||||
for registration in self.participation.team.participants.all():
|
||||
registration.send_email_final_selection()
|
||||
return redirect(reverse_lazy("participation:tournament_detail", args=(tournament.pk,)))
|
||||
|
||||
|
||||
class SolutionUploadView(LoginRequiredMixin, FormView):
|
||||
template_name = "participation/upload_solution.html"
|
||||
form_class = SolutionForm
|
||||
@ -778,13 +933,16 @@ class PoolDetailView(LoginRequiredMixin, DetailView):
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
context["passages"] = PassageTable(self.object.passages.order_by('id').all())
|
||||
context["passages"] = PassageTable(self.object.passages.order_by('position').all())
|
||||
|
||||
if self.object.results_available or self.request.user.registration.is_volunteer:
|
||||
# Hide notes before the end of the turn
|
||||
notes = dict()
|
||||
for participation in self.object.participations.all():
|
||||
note = self.object.average(participation)
|
||||
# For a 5-teams pool, notes are separated in 2 different pool objects, so we fetch them all
|
||||
all_pools = self.object.tournament.pools.filter(round=self.object.round,
|
||||
letter=self.object.letter).all()
|
||||
note = sum(pool.average(participation) for pool in all_pools)
|
||||
if note:
|
||||
notes[participation] = note
|
||||
context["notes"] = sorted(notes.items(), key=lambda x: x[1], reverse=True)
|
||||
@ -804,19 +962,12 @@ class PoolUpdateView(VolunteerMixin, UpdateView):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
return self.handle_no_permission()
|
||||
|
||||
|
||||
class PoolUpdateTeamsView(VolunteerMixin, UpdateView):
|
||||
model = Pool
|
||||
form_class = PoolTeamsForm
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
return self.handle_no_permission()
|
||||
if request.user.registration.is_admin or request.user.registration.is_volunteer \
|
||||
and (self.get_object().tournament in request.user.registration.organized_tournaments.all()
|
||||
or request.user.registration in self.get_object().juries.all()):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
return self.handle_no_permission()
|
||||
def form_valid(self, form):
|
||||
ret = super().form_valid(form)
|
||||
# Update Google Sheets juries lines
|
||||
if os.getenv('GOOGLE_PRIVATE_KEY_ID', None):
|
||||
self.object.update_juries_lines_spreadsheet()
|
||||
return ret
|
||||
|
||||
|
||||
class SolutionsDownloadView(VolunteerMixin, View):
|
||||
@ -885,7 +1036,7 @@ class SolutionsDownloadView(VolunteerMixin, View):
|
||||
|
||||
def prefix(s: Solution | Synthesis) -> str:
|
||||
pool = s.pool if is_solution else s.passage.pool
|
||||
p = f"Poule {pool.get_letter_display()}{pool.round}/"
|
||||
p = f"Poule {pool.short_name}/"
|
||||
if not is_solution:
|
||||
p += f"Passage {s.passage.position}/"
|
||||
return p
|
||||
@ -906,7 +1057,7 @@ class SolutionsDownloadView(VolunteerMixin, View):
|
||||
syntheses = Synthesis.objects.filter(passage__pool=pool).all()
|
||||
filename = _("Solutions for pool {pool} of tournament {tournament}.zip") \
|
||||
if is_solution else _("Syntheses for pool {pool} of tournament {tournament}.zip")
|
||||
filename = filename.format(pool=pool.get_letter_display() + str(pool.round),
|
||||
filename = filename.format(pool=pool.short_name,
|
||||
tournament=pool.tournament.name)
|
||||
|
||||
def prefix(s: Solution | Synthesis) -> str:
|
||||
@ -939,8 +1090,8 @@ class PoolJuryView(VolunteerMixin, FormView, DetailView):
|
||||
|
||||
if request.user.is_authenticated and \
|
||||
(request.user.registration.is_admin or request.user.registration.is_volunteer
|
||||
and (self.object.tournament in request.user.registration.organized_tournaments.all()
|
||||
or request.user.registration == self.object.jury_president)):
|
||||
and (self.object.tournament in request.user.registration.organized_tournaments.all()
|
||||
or request.user.registration == self.object.jury_president)):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
return self.handle_no_permission()
|
||||
@ -948,7 +1099,7 @@ class PoolJuryView(VolunteerMixin, FormView, DetailView):
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['title'] = _("Jury of pool {pool} for {tournament} with teams {teams}") \
|
||||
.format(pool=f"{self.object.get_letter_display()}{self.object.round}",
|
||||
.format(pool=f"{self.object.short_name}",
|
||||
tournament=self.object.tournament.name,
|
||||
teams=", ".join(participation.team.trigram for participation in self.object.participations.all()))
|
||||
return context
|
||||
@ -999,6 +1150,10 @@ class PoolJuryView(VolunteerMixin, FormView, DetailView):
|
||||
self.object.juries.add(reg)
|
||||
self.object.save()
|
||||
|
||||
# Update Google Sheets juries lines
|
||||
if os.getenv('GOOGLE_PRIVATE_KEY_ID', None):
|
||||
self.object.update_juries_lines_spreadsheet()
|
||||
|
||||
# Add notification
|
||||
messages.success(self.request, _("The jury {name} has been successfully added!")
|
||||
.format(name=f"{user.first_name} {user.last_name}"))
|
||||
@ -1022,8 +1177,8 @@ class PoolRemoveJuryView(VolunteerMixin, DetailView):
|
||||
|
||||
if request.user.is_authenticated and \
|
||||
(request.user.registration.is_admin or request.user.registration.is_volunteer
|
||||
and (self.object.tournament in request.user.registration.organized_tournaments.all()
|
||||
or request.user.registration == self.object.jury_president)):
|
||||
and (self.object.tournament in request.user.registration.organized_tournaments.all()
|
||||
or request.user.registration == self.object.jury_president)):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
return self.handle_no_permission()
|
||||
@ -1035,6 +1190,7 @@ class PoolRemoveJuryView(VolunteerMixin, DetailView):
|
||||
jury = pool.juries.get(pk=kwargs['jury_id'])
|
||||
pool.juries.remove(jury)
|
||||
pool.save()
|
||||
Note.objects.filter(jury=jury, passage__pool=pool).delete()
|
||||
messages.success(request, _("The jury {name} has been successfully removed!")
|
||||
.format(name=f"{jury.user.first_name} {jury.user.last_name}"))
|
||||
return redirect(reverse_lazy('participation:pool_jury', args=(pool.pk,)))
|
||||
@ -1048,8 +1204,8 @@ class PoolPresideJuryView(VolunteerMixin, DetailView):
|
||||
|
||||
if request.user.is_authenticated and \
|
||||
(request.user.registration.is_admin or request.user.registration.is_volunteer
|
||||
and (self.object.tournament in request.user.registration.organized_tournaments.all()
|
||||
or request.user.registration == self.object.jury_president)):
|
||||
and (self.object.tournament in request.user.registration.organized_tournaments.all()
|
||||
or request.user.registration == self.object.jury_president)):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
return self.handle_no_permission()
|
||||
@ -1076,8 +1232,8 @@ class PoolUploadNotesView(VolunteerMixin, FormView, DetailView):
|
||||
|
||||
if request.user.is_authenticated and \
|
||||
(request.user.registration.is_admin or request.user.registration.is_volunteer
|
||||
and (self.object.tournament in request.user.registration.organized_tournaments.all()
|
||||
or request.user.registration == self.object.jury_president)):
|
||||
and (self.object.tournament in request.user.registration.organized_tournaments.all()
|
||||
or request.user.registration == self.object.jury_president)):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
return self.handle_no_permission()
|
||||
@ -1095,14 +1251,16 @@ class PoolUploadNotesView(VolunteerMixin, FormView, DetailView):
|
||||
return self.form_invalid(form)
|
||||
|
||||
for vr, notes in parsed_notes.items():
|
||||
# There is an observer note for 4-teams pools
|
||||
notes_count = 7 if pool.passages.count() == 4 else 6
|
||||
notes_count = 6
|
||||
for i, passage in enumerate(pool.passages.all()):
|
||||
note = Note.objects.get_or_create(jury=vr, passage=passage)[0]
|
||||
passage_notes = notes[notes_count * i:notes_count * (i + 1)]
|
||||
note.set_all(*passage_notes)
|
||||
note.set_all(*list(map(int, passage_notes)))
|
||||
note.save()
|
||||
|
||||
if os.getenv('GOOGLE_PRIVATE_KEY_ID', None):
|
||||
pool.update_spreadsheet()
|
||||
|
||||
messages.success(self.request, _("Notes were successfully uploaded."))
|
||||
return super().form_valid(form)
|
||||
|
||||
@ -1121,15 +1279,15 @@ class PoolNotesTemplateView(VolunteerMixin, DetailView):
|
||||
|
||||
if request.user.is_authenticated and \
|
||||
(request.user.registration.is_admin or request.user.registration.is_volunteer
|
||||
and (self.object.tournament in request.user.registration.organized_tournaments.all()
|
||||
or request.user.registration == self.object.jury_president)):
|
||||
and (self.object.tournament in request.user.registration.organized_tournaments.all()
|
||||
or request.user.registration == self.object.jury_president)):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
return self.handle_no_permission()
|
||||
|
||||
def render_to_response(self, context, **response_kwargs): # noqa: C901
|
||||
pool_size = self.object.passages.count()
|
||||
passage_width = 7 if pool_size == 4 else 6
|
||||
passage_width = 6
|
||||
line_length = pool_size * passage_width
|
||||
|
||||
def getcol(number: int) -> str:
|
||||
@ -1301,19 +1459,14 @@ class PoolNotesTemplateView(VolunteerMixin, DetailView):
|
||||
col_style.addElement(TableColumnProperties(columnwidth="2.6cm", breakbefore="auto"))
|
||||
doc.automaticstyles.addElement(col_style)
|
||||
|
||||
obs_col_style = Style(name="co3", family="table-column")
|
||||
obs_col_style.addElement(TableColumnProperties(columnwidth="5.2cm", breakbefore="auto"))
|
||||
doc.automaticstyles.addElement(obs_col_style)
|
||||
|
||||
table = Table(name=f"Poule {self.object.get_letter_display()}{self.object.round}")
|
||||
table = Table(name=f"Poule {self.object.short_name}")
|
||||
doc.spreadsheet.addElement(table)
|
||||
|
||||
table.addElement(TableColumn(stylename=first_col_style))
|
||||
table.addElement(TableColumn(stylename=jury_id_style))
|
||||
|
||||
for i in range(line_length):
|
||||
table.addElement(TableColumn(stylename=obs_col_style if pool_size == 4
|
||||
and i % passage_width == passage_width - 1 else col_style))
|
||||
table.addElement(TableColumn(stylename=col_style))
|
||||
|
||||
# Add line for the problems for different passages
|
||||
header_pb = TableRow()
|
||||
@ -1326,10 +1479,9 @@ class PoolNotesTemplateView(VolunteerMixin, DetailView):
|
||||
for passage in self.object.passages.all():
|
||||
tc = TableCell(valuetype="string", stylename=title_style_topleftright)
|
||||
tc.addElement(P(text=f"Problème {passage.solution_number}"))
|
||||
tc.setAttribute('numbercolumnsspanned', "7" if pool_size == 4 else "6")
|
||||
tc.setAttribute("formula", f"of:=[.B{8 + self.object.juries.count() + passage.position}]")
|
||||
tc.setAttribute('numbercolumnsspanned', "6")
|
||||
header_pb.addElement(tc)
|
||||
header_pb.addElement(CoveredTableCell(numbercolumnsrepeated=6 if pool_size == 4 else 5))
|
||||
header_pb.addElement(CoveredTableCell(numbercolumnsrepeated=5))
|
||||
|
||||
# Add roles on the second line of the table
|
||||
header_role = TableRow()
|
||||
@ -1353,17 +1505,12 @@ class PoolNotesTemplateView(VolunteerMixin, DetailView):
|
||||
header_role.addElement(CoveredTableCell())
|
||||
|
||||
reporter_tc = TableCell(valuetype="string",
|
||||
stylename=title_style_right if pool_size != 4 else title_style)
|
||||
reporter_tc.addElement(P(text="Rapporteur⋅e"))
|
||||
stylename=title_style_right)
|
||||
reporter_tc.addElement(P(text="Rapporteur⋅rice"))
|
||||
reporter_tc.setAttribute('numbercolumnsspanned', "2")
|
||||
header_role.addElement(reporter_tc)
|
||||
header_role.addElement(CoveredTableCell())
|
||||
|
||||
if pool_size == 4:
|
||||
observer_tc = TableCell(valuetype="string", stylename=title_style_right)
|
||||
observer_tc.addElement(P(text="Intervention exceptionnelle"))
|
||||
header_role.addElement(observer_tc)
|
||||
|
||||
# Add maximum notes on the third line
|
||||
header_notes = TableRow()
|
||||
table.addElement(header_notes)
|
||||
@ -1394,17 +1541,10 @@ class PoolNotesTemplateView(VolunteerMixin, DetailView):
|
||||
reporter_w_tc.addElement(P(text="Écrit (/10)"))
|
||||
header_notes.addElement(reporter_w_tc)
|
||||
|
||||
reporter_o_tc = TableCell(valuetype="string",
|
||||
stylename=title_style_botright if pool_size != 4 else title_style_bot)
|
||||
reporter_o_tc = TableCell(valuetype="string", stylename=title_style_botright)
|
||||
reporter_o_tc.addElement(P(text="Oral (/10)"))
|
||||
header_notes.addElement(reporter_o_tc)
|
||||
|
||||
if pool_size == 4:
|
||||
observer_tc = TableCell(valuetype="string",
|
||||
stylename=title_style_botright)
|
||||
observer_tc.addElement(P(text="Oral (± 4)"))
|
||||
header_notes.addElement(observer_tc)
|
||||
|
||||
# Add a notation line for each jury
|
||||
for jury in self.object.juries.all():
|
||||
jury_row = TableRow()
|
||||
@ -1478,16 +1618,10 @@ class PoolNotesTemplateView(VolunteerMixin, DetailView):
|
||||
reporter_w_tc.addElement(P(text="1"))
|
||||
coeff_row.addElement(reporter_w_tc)
|
||||
|
||||
reporter_o_tc = TableCell(valuetype="float", value=1,
|
||||
stylename=style_right if pool_size != 4 else style)
|
||||
reporter_o_tc = TableCell(valuetype="float", value=1, stylename=style_right)
|
||||
reporter_o_tc.addElement(P(text="1"))
|
||||
coeff_row.addElement(reporter_o_tc)
|
||||
|
||||
if pool_size == 4:
|
||||
observer_tc = TableCell(valuetype="float", value=1, stylename=style_right)
|
||||
observer_tc.addElement(P(text="1"))
|
||||
coeff_row.addElement(observer_tc)
|
||||
|
||||
# Add the subtotal on the next line
|
||||
subtotal_row = TableRow()
|
||||
table.addElement(subtotal_row)
|
||||
@ -1519,8 +1653,7 @@ class PoolNotesTemplateView(VolunteerMixin, DetailView):
|
||||
|
||||
rep_w_col = getcol(min_column + passage_width * i + 4)
|
||||
rep_o_col = getcol(min_column + passage_width * i + 5)
|
||||
reporter_tc = TableCell(valuetype="float", value=passage.average_reporter,
|
||||
stylename=style_botright if pool_size != 4 else style_bot)
|
||||
reporter_tc = TableCell(valuetype="float", value=passage.average_reporter, stylename=style_botright)
|
||||
reporter_tc.addElement(P(text=str(passage.average_reporter)))
|
||||
reporter_tc.setAttribute('numbercolumnsspanned', "2")
|
||||
reporter_tc.setAttribute("formula", f"of:=[.{rep_w_col}{max_row + 1}] * [.{rep_w_col}{max_row + 2}]"
|
||||
@ -1528,97 +1661,74 @@ class PoolNotesTemplateView(VolunteerMixin, DetailView):
|
||||
subtotal_row.addElement(reporter_tc)
|
||||
subtotal_row.addElement(CoveredTableCell())
|
||||
|
||||
if pool_size == 4:
|
||||
obs_col = getcol(min_column + passage_width * i + 6)
|
||||
observer_tc = TableCell(valuetype="float", value=passage.average_observer,
|
||||
stylename=style_botright)
|
||||
observer_tc.addElement(P(text=str(passage.average_observer)))
|
||||
observer_tc.setAttribute("formula", f"of:=[.{obs_col}{max_row + 1}] * [.{obs_col}{max_row + 2}]")
|
||||
subtotal_row.addElement(observer_tc)
|
||||
|
||||
table.addElement(TableRow())
|
||||
|
||||
# Compute the total scores in a new table
|
||||
scores_header = TableRow()
|
||||
table.addElement(scores_header)
|
||||
team_tc = TableCell(valuetype="string", stylename=title_style_topbotleft)
|
||||
team_tc.addElement(P(text="Équipe"))
|
||||
team_tc.setAttribute('numbercolumnsspanned', "2")
|
||||
scores_header.addElement(team_tc)
|
||||
problem_tc = TableCell(valuetype="string", stylename=title_style_topbot)
|
||||
problem_tc.addElement(P(text="Problème"))
|
||||
scores_header.addElement(problem_tc)
|
||||
total_tc = TableCell(valuetype="string", stylename=title_style_topbot)
|
||||
total_tc.addElement(P(text="Total"))
|
||||
scores_header.addElement(total_tc)
|
||||
rank_tc = TableCell(valuetype="string", stylename=title_style_topbotright)
|
||||
rank_tc.addElement(P(text="Rang"))
|
||||
scores_header.addElement(rank_tc)
|
||||
|
||||
# For each line of the matrix P, the ith team is defender on the passage number Pi0,
|
||||
# opponent on the passage number Pi1, reporter on the passage number Pi2
|
||||
# and eventually observer on the passage number Pi3.
|
||||
passage_matrix = []
|
||||
match pool_size:
|
||||
case 3:
|
||||
passage_matrix = [
|
||||
[0, 2, 1],
|
||||
[1, 0, 2],
|
||||
[2, 1, 0],
|
||||
]
|
||||
case 4:
|
||||
passage_matrix = [
|
||||
[0, 3, 2, 1],
|
||||
[1, 0, 3, 2],
|
||||
[2, 1, 0, 3],
|
||||
[3, 2, 1, 0],
|
||||
]
|
||||
case 5:
|
||||
passage_matrix = [
|
||||
[0, 2, 3],
|
||||
[1, 4, 2],
|
||||
[2, 0, 4],
|
||||
[3, 1, 0],
|
||||
[4, 3, 1],
|
||||
]
|
||||
|
||||
sorted_participations = sorted(self.object.participations.all(), key=lambda p: -self.object.average(p))
|
||||
for passage in self.object.passages.all():
|
||||
team_row = TableRow()
|
||||
table.addElement(team_row)
|
||||
|
||||
team_tc = TableCell(valuetype="string",
|
||||
stylename=style_botleft if passage.position == pool_size else style_left)
|
||||
team_tc.addElement(P(text=f"{passage.defender.team.name} ({passage.defender.team.trigram})"))
|
||||
if self.object.participations.count() == 5:
|
||||
# 5-teams pools are separated in two different objects.
|
||||
# So, displaying the ranking may don't make any sens. We don't display it for this reason.
|
||||
scores_row = TableRow()
|
||||
table.addElement(scores_row)
|
||||
score_tc = TableCell(valuetype="string")
|
||||
score_tc.addElement(P(text="Le classement d'une poule à 5 n'est pas disponible sur le tableur, "
|
||||
"puisque les notes de l'autre salle sont manquantes.\n"
|
||||
"Merci de vous fier au site, ou bien au Google Sheets."))
|
||||
scores_row.addElement(score_tc)
|
||||
else:
|
||||
# Compute the total scores in a new table
|
||||
scores_header = TableRow()
|
||||
table.addElement(scores_header)
|
||||
team_tc = TableCell(valuetype="string", stylename=title_style_topbotleft)
|
||||
team_tc.addElement(P(text="Équipe"))
|
||||
team_tc.setAttribute('numbercolumnsspanned', "2")
|
||||
team_row.addElement(team_tc)
|
||||
scores_header.addElement(team_tc)
|
||||
problem_tc = TableCell(valuetype="string", stylename=title_style_topbot)
|
||||
problem_tc.addElement(P(text="Problème"))
|
||||
scores_header.addElement(problem_tc)
|
||||
total_tc = TableCell(valuetype="string", stylename=title_style_topbot)
|
||||
total_tc.addElement(P(text="Total"))
|
||||
scores_header.addElement(total_tc)
|
||||
rank_tc = TableCell(valuetype="string", stylename=title_style_topbotright)
|
||||
rank_tc.addElement(P(text="Rang"))
|
||||
scores_header.addElement(rank_tc)
|
||||
|
||||
problem_tc = TableCell(valuetype="string",
|
||||
stylename=style_bot if passage.position == pool_size else style)
|
||||
problem_tc.addElement(P(text=f"Problème {passage.solution_number}"))
|
||||
team_row.addElement(problem_tc)
|
||||
sorted_participations = sorted(self.object.participations.all(), key=lambda p: -self.object.average(p))
|
||||
for passage in self.object.passages.all():
|
||||
team_row = TableRow()
|
||||
table.addElement(team_row)
|
||||
|
||||
passage_line = passage_matrix[passage.position - 1]
|
||||
score_tc = TableCell(valuetype="float", value=self.object.average(passage.defender),
|
||||
stylename=style_bot if passage.position == pool_size else style)
|
||||
score_tc.addElement(P(text=self.object.average(passage.defender)))
|
||||
formula = "of:="
|
||||
formula += getcol(min_column + passage_line[0] * passage_width) + str(max_row + 3) # Defender
|
||||
formula += " + " + getcol(min_column + passage_line[1] * passage_width + 2) + str(max_row + 3) # Opponent
|
||||
formula += " + " + getcol(min_column + passage_line[2] * passage_width + 4) + str(max_row + 3) # Reporter
|
||||
if pool_size == 4:
|
||||
# Observer
|
||||
formula += " + " + getcol(min_column + passage_line[3] * passage_width + 6) + str(max_row + 3)
|
||||
score_tc.setAttribute("formula", formula)
|
||||
team_row.addElement(score_tc)
|
||||
team_tc = TableCell(valuetype="string",
|
||||
stylename=style_botleft if passage.position == pool_size else style_left)
|
||||
team_tc.addElement(P(text=f"{passage.defender.team.name} ({passage.defender.team.trigram})"))
|
||||
team_tc.setAttribute('numbercolumnsspanned', "2")
|
||||
team_row.addElement(team_tc)
|
||||
|
||||
score_col = 'C'
|
||||
rank_tc = TableCell(valuetype="float", value=sorted_participations.index(passage.defender) + 1,
|
||||
stylename=style_botright if passage.position == pool_size else style_right)
|
||||
rank_tc.addElement(P(text=str(sorted_participations.index(passage.defender) + 1)))
|
||||
rank_tc.setAttribute("formula", f"of:=RANK([.{score_col}{max_row + 5 + passage.position}]; "
|
||||
f"[.{score_col}${max_row + 6}]:[.{score_col}${max_row + 5 + pool_size}])")
|
||||
team_row.addElement(rank_tc)
|
||||
problem_tc = TableCell(valuetype="string",
|
||||
stylename=style_bot if passage.position == pool_size else style)
|
||||
problem_tc.addElement(P(text=f"Problème {passage.solution_number}"))
|
||||
problem_tc.setAttribute("formula", f"of:=[.B{3 + passage_width * (passage.position - 1)}]")
|
||||
team_row.addElement(problem_tc)
|
||||
|
||||
defender_pos = passage.position - 1
|
||||
opponent_pos = self.object.passages.get(opponent=passage.defender).position - 1
|
||||
reporter_pos = self.object.passages.get(reporter=passage.defender).position - 1
|
||||
|
||||
score_tc = TableCell(valuetype="float", value=self.object.average(passage.defender),
|
||||
stylename=style_bot if passage.position == pool_size else style)
|
||||
score_tc.addElement(P(text=self.object.average(passage.defender)))
|
||||
formula = "of:="
|
||||
formula += getcol(min_column + defender_pos * passage_width) + str(max_row + 3) # Defender
|
||||
formula += " + " + getcol(min_column + opponent_pos * passage_width + 2) + str(max_row + 3) # Opponent
|
||||
formula += " + " + getcol(min_column + reporter_pos * passage_width + 4) + str(max_row + 3) # Reporter
|
||||
score_tc.setAttribute("formula", formula)
|
||||
team_row.addElement(score_tc)
|
||||
|
||||
score_col = 'C'
|
||||
rank_tc = TableCell(valuetype="float", value=sorted_participations.index(passage.defender) + 1,
|
||||
stylename=style_botright if passage.position == pool_size else style_right)
|
||||
rank_tc.addElement(P(text=str(sorted_participations.index(passage.defender) + 1)))
|
||||
rank_tc.setAttribute("formula", f"of:=RANK([.{score_col}{max_row + 5 + passage.position}]; "
|
||||
f"[.{score_col}${max_row + 6}]:[.{score_col}${max_row + 5 + pool_size}])")
|
||||
team_row.addElement(rank_tc)
|
||||
|
||||
table.addElement(TableRow())
|
||||
|
||||
@ -1643,7 +1753,7 @@ class PoolNotesTemplateView(VolunteerMixin, DetailView):
|
||||
return FileResponse(streaming_content=open("/tmp/notes.ods", "rb"),
|
||||
content_type="application/vnd.oasis.opendocument.spreadsheet",
|
||||
filename=f"Feuille de notes - {self.object.tournament.name} "
|
||||
f"- Poule {self.object.get_letter_display()}{self.object.round}.ods")
|
||||
f"- Poule {self.object.short_name}.ods")
|
||||
|
||||
|
||||
class NotationSheetTemplateView(VolunteerMixin, DetailView):
|
||||
@ -1652,22 +1762,26 @@ class NotationSheetTemplateView(VolunteerMixin, DetailView):
|
||||
"""
|
||||
model = Pool
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
|
||||
if request.user.is_authenticated and \
|
||||
(request.user.registration.is_admin or request.user.registration.is_volunteer
|
||||
and (self.object.tournament in request.user.registration.organized_tournaments.all()
|
||||
or request.user.registration == self.object.jury_president)):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
return self.handle_no_permission()
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
passages = self.object.passages.all()
|
||||
if passages.count() == 5:
|
||||
page = self.request.GET.get('page', '1')
|
||||
if not page.isnumeric() or page not in ['1', '2']:
|
||||
page = '1'
|
||||
passages = passages.filter(id__in=[passages[0].id, passages[2].id, passages[4].id]
|
||||
if page == '1' else [passages[1].id, passages[3].id])
|
||||
context['page'] = page
|
||||
|
||||
context['passages'] = passages
|
||||
context['esp'] = passages.count() * '&'
|
||||
context['is_jury'] = self.request.user.registration in self.object.juries.all() \
|
||||
and 'blank' not in self.request.GET
|
||||
if self.request.user.registration in self.object.juries.all() and 'blank' not in self.request.GET:
|
||||
context['jury'] = self.request.user.registration
|
||||
context['tfjm_number'] = timezone.now().year - 2010
|
||||
return context
|
||||
|
||||
@ -1692,33 +1806,113 @@ class FinalNotationSheetTemplateView(NotationSheetTemplateView):
|
||||
template_name = 'participation/tex/finale.tex'
|
||||
|
||||
|
||||
class PassageCreateView(VolunteerMixin, CreateView):
|
||||
model = Passage
|
||||
form_class = PassageForm
|
||||
class NotationSheetsArchiveView(VolunteerMixin, DetailView):
|
||||
@property
|
||||
def model(self):
|
||||
return Pool if 'pool_id' in self.kwargs else Tournament
|
||||
|
||||
@property
|
||||
def pk_url_kwarg(self):
|
||||
return 'pool_id' if 'pool_id' in self.kwargs else 'tournament_id'
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
return self.handle_no_permission()
|
||||
|
||||
qs = Pool.objects.filter(pk=self.kwargs["pk"])
|
||||
if not qs.exists():
|
||||
raise Http404
|
||||
self.pool = qs.get()
|
||||
reg = request.user.registration
|
||||
|
||||
if request.user.registration.is_admin or request.user.registration.is_volunteer \
|
||||
and (self.pool.tournament in request.user.registration.organized_tournaments.all()
|
||||
or request.user.registration in self.pool.juries.all()):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
if 'pool_id' in kwargs:
|
||||
pool = self.get_object()
|
||||
tournament = pool.tournament
|
||||
if reg.is_admin or reg.is_volunteer \
|
||||
and (tournament in reg.organized_tournaments.all() or reg in pool.juries.all()):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
else:
|
||||
tournament = self.get_object()
|
||||
if reg.is_admin or reg.is_volunteer and tournament in reg.organized_tournaments.all():
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
return self.handle_no_permission()
|
||||
|
||||
def get_form(self, form_class=None):
|
||||
form = super().get_form(form_class)
|
||||
form.instance.pool = self.pool
|
||||
form.fields["defender"].queryset = self.pool.participations.all()
|
||||
form.fields["opponent"].queryset = self.pool.participations.all()
|
||||
form.fields["reporter"].queryset = self.pool.participations.all()
|
||||
return form
|
||||
def get(self, request, *args, **kwargs):
|
||||
if 'pool_id' in kwargs:
|
||||
pool = self.get_object()
|
||||
tournament = pool.tournament
|
||||
pools = [pool]
|
||||
filename = _("Notation sheets of pool {pool} of {tournament}.zip") \
|
||||
.format(pool=pool.short_name, tournament=tournament.name)
|
||||
else:
|
||||
tournament = self.get_object()
|
||||
pools = tournament.pools.all()
|
||||
filename = _("Notation sheets of {tournament}.zip").format(tournament=tournament.name)
|
||||
|
||||
output = BytesIO()
|
||||
with ZipFile(output, "w") as zf:
|
||||
for pool in pools:
|
||||
prefix = f"{pool.short_name}/" if len(pools) > 1 else ""
|
||||
for template_name in ['bareme', 'finale']:
|
||||
juries = list(pool.juries.all()) + [None]
|
||||
|
||||
for jury in juries:
|
||||
if jury is not None and template_name == "bareme":
|
||||
continue
|
||||
|
||||
context = {'jury': jury, 'pool': pool,
|
||||
'tfjm_number': timezone.now().year - 2010}
|
||||
|
||||
passages = pool.passages.all()
|
||||
context['passages'] = passages
|
||||
context['esp'] = passages.count() * '&'
|
||||
|
||||
tex = render_to_string(f"participation/tex/{template_name}.tex",
|
||||
context=context, request=self.request)
|
||||
temp_dir = mkdtemp()
|
||||
with open(os.path.join(temp_dir, "texput.tex"), "w") as f:
|
||||
f.write(tex)
|
||||
|
||||
process = subprocess.Popen(
|
||||
["pdflatex", "-interaction=nonstopmode", f"-output-directory={temp_dir}",
|
||||
os.path.join(temp_dir, "texput.tex"), ])
|
||||
process.wait()
|
||||
|
||||
sheet_name = f"Barème pour la poule {pool.short_name}" if template_name == "bareme" \
|
||||
else (f"Feuille de notation pour la poule {pool.short_name}"
|
||||
f" - {str(jury) if jury else 'Vierge'}")
|
||||
|
||||
zf.write(os.path.join(temp_dir, "texput.pdf"),
|
||||
f"{prefix}{sheet_name}.pdf")
|
||||
|
||||
response = HttpResponse(content_type="application/zip")
|
||||
response["Content-Disposition"] = f"attachment; filename=\"{filename}\""
|
||||
response.write(output.getvalue())
|
||||
return response
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
class GSheetNotificationsView(View):
|
||||
async def post(self, request, *args, **kwargs):
|
||||
if not await Tournament.objects.filter(pk=kwargs['pk']).aexists():
|
||||
return HttpResponse(status=404)
|
||||
|
||||
tournament = await Tournament.objects.prefetch_related('participation_set', 'pools').aget(pk=kwargs['pk'])
|
||||
now = localtime(timezone.now())
|
||||
expected_channel_id = sha1(f"{tournament.name}-{now.date()}-{request.site.domain}".encode()) \
|
||||
.hexdigest()
|
||||
|
||||
if request.headers['X-Goog-Channel-ID'] != expected_channel_id:
|
||||
raise ValueError(f"Invalid channel ID: {request.headers['X-Goog-Channel-ID']}")
|
||||
|
||||
if request.headers['X-Goog-Resource-State'] != 'update' \
|
||||
or 'content' not in request.headers['X-Goog-Changed'].split(','):
|
||||
return HttpResponse(status=204)
|
||||
|
||||
# Run the parsing in dedicated executors since it takes time
|
||||
executor = ThreadPoolExecutor()
|
||||
async for pool in tournament.pools.prefetch_related('participations', 'passages__notes', 'juries').all():
|
||||
asyncio.get_event_loop().run_in_executor(executor, pool.parse_spreadsheet)
|
||||
asyncio.get_event_loop().run_in_executor(executor, tournament.parse_tweaks_spreadsheets)
|
||||
|
||||
return HttpResponse(status=204)
|
||||
|
||||
|
||||
class PassageDetailView(LoginRequiredMixin, DetailView):
|
||||
@ -1734,27 +1928,34 @@ class PassageDetailView(LoginRequiredMixin, DetailView):
|
||||
or reg in passage.pool.juries.all()
|
||||
or reg.pools_presided.filter(tournament=passage.pool.tournament).exists()) \
|
||||
or reg.participates and reg.team \
|
||||
and reg.team.participation in [passage.defender, passage.opponent, passage.reporter, passage.observer]:
|
||||
and reg.team.participation in [passage.defender, passage.opponent, passage.reporter]:
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
return self.handle_no_permission()
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
reg = self.request.user.registration
|
||||
if reg in self.object.pool.juries.all():
|
||||
context["my_note"] = Note.objects.get_or_create(passage=self.object, jury=self.request.user.registration)[0]
|
||||
passage = self.object
|
||||
if reg in passage.pool.juries.all():
|
||||
context["my_note"] = Note.objects.get_or_create(passage=passage, jury=self.request.user.registration)[0]
|
||||
if reg.is_volunteer:
|
||||
notes = self.object.notes.all()
|
||||
notes = passage.notes.all()
|
||||
if not reg.is_admin \
|
||||
or (reg != self.object.pool.jury_president
|
||||
and reg not in self.object.pool.tournament.organizers.all()):
|
||||
notes = [note for note in notes if note.has_any_note()]
|
||||
and (reg != passage.pool.jury_president
|
||||
or reg not in passage.pool.tournament.organizers.all()):
|
||||
notes = [note for note in notes if note.has_any_note() or note.jury == reg]
|
||||
context["notes"] = NoteTable(notes)
|
||||
|
||||
# Only display the observer column for 4-teams pools
|
||||
context['notes']._sequence.remove('observer_oral')
|
||||
if 'notes' in context and not self.request.user.registration.is_admin:
|
||||
context['notes']._sequence.remove('update')
|
||||
|
||||
context['notes'].columns['defender_writing'].column.verbose_name += f" ({passage.defender.team.trigram})"
|
||||
context['notes'].columns['defender_oral'].column.verbose_name += f" ({passage.defender.team.trigram})"
|
||||
context['notes'].columns['opponent_writing'].column.verbose_name += f" ({passage.opponent.team.trigram})"
|
||||
context['notes'].columns['opponent_oral'].column.verbose_name += f" ({passage.opponent.team.trigram})"
|
||||
context['notes'].columns['reporter_writing'].column.verbose_name += f" ({passage.reporter.team.trigram})"
|
||||
context['notes'].columns['reporter_oral'].column.verbose_name += f" ({passage.reporter.team.trigram})"
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@ -1842,7 +2043,15 @@ class NoteUpdateView(VolunteerMixin, UpdateView):
|
||||
|
||||
def get_form(self, form_class=None):
|
||||
form = super().get_form(form_class)
|
||||
if not self.object.passage.observer:
|
||||
# Set the note of the observer only for 4-teams pools
|
||||
del form.fields['observer_oral']
|
||||
form.fields['defender_writing'].label += f" ({self.object.passage.defender.team.trigram})"
|
||||
form.fields['defender_oral'].label += f" ({self.object.passage.defender.team.trigram})"
|
||||
form.fields['opponent_writing'].label += f" ({self.object.passage.opponent.team.trigram})"
|
||||
form.fields['opponent_oral'].label += f" ({self.object.passage.opponent.team.trigram})"
|
||||
form.fields['reporter_writing'].label += f" ({self.object.passage.reporter.team.trigram})"
|
||||
form.fields['reporter_oral'].label += f" ({self.object.passage.reporter.team.trigram})"
|
||||
return form
|
||||
|
||||
def form_valid(self, form):
|
||||
ret = super().form_valid(form)
|
||||
self.object.update_spreadsheet()
|
||||
return ret
|
||||
|
@ -129,7 +129,7 @@ class VolunteerRegistrationAdmin(PolymorphicChildModelAdmin):
|
||||
|
||||
@admin.register(Payment)
|
||||
class PaymentAdmin(ModelAdmin):
|
||||
list_display = ('concerned_people', 'tournament', 'team', 'grouped', 'type', 'amount', 'valid', )
|
||||
list_display = ('concerned_people', 'tournament', 'team', 'grouped', 'type', 'amount', 'final', 'valid', )
|
||||
search_fields = ('registrations__user__last_name', 'registrations__user__first_name', 'registrations__user__email',
|
||||
'registrations__team__name', 'registrations__team__participation__team__trigram',)
|
||||
list_filter = ('registrations__team__participation__valid', 'type',
|
||||
|
@ -133,6 +133,28 @@ class PhotoAuthorizationForm(forms.ModelForm):
|
||||
fields = ('photo_authorization',)
|
||||
|
||||
|
||||
class PhotoAuthorizationFinalForm(forms.ModelForm):
|
||||
"""
|
||||
Form to send a photo authorization.
|
||||
"""
|
||||
def clean_photo_authorization_final(self):
|
||||
if "photo_authorization_final" in self.files:
|
||||
file = self.files["photo_authorization_final"]
|
||||
if file.size > 2e6:
|
||||
raise ValidationError(_("The uploaded file size must be under 2 Mo."))
|
||||
if file.content_type not in ["application/pdf", "image/png", "image/jpeg"]:
|
||||
raise ValidationError(_("The uploaded file must be a PDF, PNG of JPEG file."))
|
||||
return self.cleaned_data["photo_authorization_final"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["photo_authorization_final"].widget = FileInput()
|
||||
|
||||
class Meta:
|
||||
model = ParticipantRegistration
|
||||
fields = ('photo_authorization_final',)
|
||||
|
||||
|
||||
class HealthSheetForm(forms.ModelForm):
|
||||
"""
|
||||
Form to send a health sheet.
|
||||
@ -199,6 +221,28 @@ class ParentalAuthorizationForm(forms.ModelForm):
|
||||
fields = ('parental_authorization',)
|
||||
|
||||
|
||||
class ParentalAuthorizationFinalForm(forms.ModelForm):
|
||||
"""
|
||||
Form to send a parental authorization.
|
||||
"""
|
||||
def clean_parental_authorization(self):
|
||||
if "parental_authorization_final" in self.files:
|
||||
file = self.files["parental_authorization_final"]
|
||||
if file.size > 2e6:
|
||||
raise ValidationError(_("The uploaded file size must be under 2 Mo."))
|
||||
if file.content_type not in ["application/pdf", "image/png", "image/jpeg"]:
|
||||
raise ValidationError(_("The uploaded file must be a PDF, PNG of JPEG file."))
|
||||
return self.cleaned_data["parental_authorization"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["parental_authorization_final"].widget = FileInput()
|
||||
|
||||
class Meta:
|
||||
model = StudentRegistration
|
||||
fields = ('parental_authorization_final',)
|
||||
|
||||
|
||||
class CoachRegistrationForm(forms.ModelForm):
|
||||
"""
|
||||
A coach can tell its professional activity.
|
||||
|
@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-07 08:34
|
||||
|
||||
import registration.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("registration", "0012_payment_token_alter_payment_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="participantregistration",
|
||||
name="photo_authorization_final",
|
||||
field=models.FileField(
|
||||
blank=True,
|
||||
default="",
|
||||
upload_to=registration.models.get_random_photo_filename,
|
||||
verbose_name="photo authorization (final)",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="studentregistration",
|
||||
name="parental_authorization_final",
|
||||
field=models.FileField(
|
||||
blank=True,
|
||||
default="",
|
||||
upload_to=registration.models.get_random_parental_filename,
|
||||
verbose_name="parental authorization (final)",
|
||||
),
|
||||
),
|
||||
]
|
@ -14,6 +14,7 @@ from django.utils.crypto import get_random_string
|
||||
from django.utils.encoding import force_bytes
|
||||
from django.utils.http import urlsafe_base64_encode
|
||||
from django.utils.text import format_lazy
|
||||
from django.utils.timezone import localtime
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
from polymorphic.models import PolymorphicModel
|
||||
@ -209,17 +210,39 @@ class ParticipantRegistration(Registration):
|
||||
default="",
|
||||
)
|
||||
|
||||
photo_authorization_final = models.FileField(
|
||||
verbose_name=_("photo authorization (final)"),
|
||||
upload_to=get_random_photo_filename,
|
||||
blank=True,
|
||||
default="",
|
||||
)
|
||||
|
||||
@property
|
||||
def under_18(self):
|
||||
if isinstance(self, CoachRegistration):
|
||||
return False # In normal case
|
||||
important_date = timezone.now().date()
|
||||
important_date = localtime(timezone.now()).date()
|
||||
if self.team and self.team.participation.tournament:
|
||||
important_date = self.team.participation.tournament.date_start
|
||||
if self.team.participation.final:
|
||||
from participation.models import Tournament
|
||||
important_date = Tournament.final_tournament().date_start
|
||||
return (important_date - self.birth_date).days < 18 * 365.24
|
||||
birth_date = self.birth_date
|
||||
if birth_date.month == 2 and birth_date.day == 29:
|
||||
# If the birth date is the 29th of February, we consider it as the 1st of March
|
||||
birth_date = birth_date.replace(month=3, day=1)
|
||||
over_18_on = birth_date.replace(year=birth_date.year + 18)
|
||||
return important_date < over_18_on
|
||||
|
||||
@property
|
||||
def under_18_final(self):
|
||||
if isinstance(self, CoachRegistration):
|
||||
return False # In normal case
|
||||
from participation.models import Tournament
|
||||
important_date = Tournament.final_tournament().date_start
|
||||
birth_date = self.birth_date
|
||||
if birth_date.month == 2 and birth_date.day == 29:
|
||||
# If the birth date is the 29th of February, we consider it as the 1st of March
|
||||
birth_date = birth_date.replace(month=3, day=1)
|
||||
over_18_on = birth_date.replace(year=birth_date.year + 18)
|
||||
return important_date < over_18_on
|
||||
|
||||
@property
|
||||
def type(self): # pragma: no cover
|
||||
@ -257,10 +280,49 @@ class ParticipantRegistration(Registration):
|
||||
'content': content,
|
||||
})
|
||||
|
||||
if self.team.participation.final:
|
||||
if not self.photo_authorization_final:
|
||||
text = _("You have not uploaded your photo authorization for the final tournament. "
|
||||
"You can do it by clicking on <a href=\"{photo_url}\">this link</a>.")
|
||||
photo_url = reverse_lazy("registration:upload_user_photo_authorization_final", args=(self.id,))
|
||||
content = format_lazy(text, photo_url=photo_url)
|
||||
informations.append({
|
||||
'title': _("Photo authorization"),
|
||||
'type': "danger",
|
||||
'priority': 5,
|
||||
'content': content,
|
||||
})
|
||||
|
||||
informations.extend(self.team.important_informations())
|
||||
|
||||
return informations
|
||||
|
||||
def send_email_final_selection(self):
|
||||
"""
|
||||
The team is selected for final.
|
||||
"""
|
||||
translation.activate('fr')
|
||||
subject = "[TFJM²] " + str(_("Team selected for the final tournament"))
|
||||
site = Site.objects.first()
|
||||
from participation.models import Tournament
|
||||
tournament = Tournament.final_tournament()
|
||||
payment = self.payments.filter(final=True).first() if self.is_student else None
|
||||
message = loader.render_to_string('registration/mails/final_selection.txt',
|
||||
{
|
||||
'user': self.user,
|
||||
'domain': site.domain,
|
||||
'tournament': tournament,
|
||||
'payment': payment,
|
||||
})
|
||||
html = loader.render_to_string('registration/mails/final_selection.html',
|
||||
{
|
||||
'user': self.user,
|
||||
'domain': site.domain,
|
||||
'tournament': tournament,
|
||||
'payment': payment,
|
||||
})
|
||||
self.user.email_user(subject, message, html_message=html)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("participant registration")
|
||||
verbose_name_plural = _("participant registrations")
|
||||
@ -313,6 +375,13 @@ class StudentRegistration(ParticipantRegistration):
|
||||
default="",
|
||||
)
|
||||
|
||||
parental_authorization_final = models.FileField(
|
||||
verbose_name=_("parental authorization (final)"),
|
||||
upload_to=get_random_parental_filename,
|
||||
blank=True,
|
||||
default="",
|
||||
)
|
||||
|
||||
health_sheet = models.FileField(
|
||||
verbose_name=_("health sheet"),
|
||||
upload_to=get_random_health_filename,
|
||||
@ -397,6 +466,20 @@ class StudentRegistration(ParticipantRegistration):
|
||||
'content': content,
|
||||
})
|
||||
|
||||
if self.team.participation.final:
|
||||
if self.under_18_final and not self.parental_authorization_final:
|
||||
text = _("You have not uploaded your parental authorization for the final tournament. "
|
||||
"You can do it by clicking on <a href=\"{parental_url}\">this link</a>.")
|
||||
parental_url = reverse_lazy("registration:upload_user_parental_authorization_final",
|
||||
args=(self.id,))
|
||||
content = format_lazy(text, parental_url=parental_url)
|
||||
informations.append({
|
||||
'title': _("Parental authorization"),
|
||||
'type': "danger",
|
||||
'priority': 5,
|
||||
'content': content,
|
||||
})
|
||||
|
||||
return informations
|
||||
|
||||
class Meta:
|
||||
@ -471,7 +554,7 @@ class VolunteerRegistration(Registration):
|
||||
text = _("Registrations for tournament {tournament} are closing on {date:%Y-%m-%d %H:%M}. "
|
||||
"There are for now {validated_teams} validated teams (+ {pending_teams} pending) "
|
||||
"on {max_teams} expected.")
|
||||
content = format_lazy(text, tournament=tournament.name, date=tournament.inscription_limit,
|
||||
content = format_lazy(text, tournament=tournament.name, date=localtime(tournament.inscription_limit),
|
||||
validated_teams=tournament.participations.filter(valid=True).count(),
|
||||
pending_teams=tournament.participations.filter(valid=False).count(),
|
||||
max_teams=tournament.max_teams)
|
||||
@ -513,6 +596,61 @@ class VolunteerRegistration(Registration):
|
||||
'content': content,
|
||||
})
|
||||
|
||||
if timezone.now() > tournament.solution_limit and timezone.now() < tournament.solutions_draw:
|
||||
text = _("<p>The draw of the solutions for the tournament {tournament} is planned on the "
|
||||
"{date:%Y-%m-%d %H:%M}. You can join it on <a href='{url}'>this link</a>.</p>")
|
||||
url = reverse_lazy("draw:index")
|
||||
content = format_lazy(text, tournament=tournament.name,
|
||||
date=localtime(tournament.solutions_draw),
|
||||
url=url)
|
||||
informations.append({
|
||||
'title': _("Draw of solutions"),
|
||||
'type': "info",
|
||||
'priority': 1,
|
||||
'content': content,
|
||||
})
|
||||
|
||||
for tournament in self.interesting_tournaments:
|
||||
pools = tournament.pools.filter(juries=self).order_by('round').all()
|
||||
for pool in pools:
|
||||
if pool.round == 1 and timezone.now().date() <= tournament.date_start:
|
||||
text = _("<p>You are in the jury of the pool {pool} for the tournament of {tournament}. "
|
||||
"You can find the pool page <a href='{pool_url}'>here</a>.</p>")
|
||||
pool_url = reverse_lazy("participation:pool_detail", args=(pool.id,))
|
||||
content = format_lazy(text, pool=pool.short_name, tournament=tournament.name, pool_url=pool_url)
|
||||
informations.append({
|
||||
'title': _("First round"),
|
||||
'type': "info",
|
||||
'priority': 1,
|
||||
'content': content,
|
||||
})
|
||||
elif pool.round == 2 and timezone.now().date() <= tournament.date_end:
|
||||
text = _("<p>You are in the jury of the pool {pool} for the tournament of {tournament}. "
|
||||
"You can find the pool page <a href='{pool_url}'>here</a>.</p>")
|
||||
pool_url = reverse_lazy("participation:pool_detail", args=(pool.id,))
|
||||
content = format_lazy(text, pool=pool.short_name, tournament=tournament.name, pool_url=pool_url)
|
||||
informations.append({
|
||||
'title': _("Second round"),
|
||||
'type': "info",
|
||||
'priority': 2,
|
||||
'content': content,
|
||||
})
|
||||
|
||||
for note in self.notes.filter(passage__pool=pool).all():
|
||||
if not note.has_any_note():
|
||||
text = _("<p>You don't have given any note as a jury for the passage {passage} "
|
||||
"in the pool {pool} of {tournament}. "
|
||||
"You can set your notes <a href='{passage_url}'>here</a>.</p>")
|
||||
passage_url = reverse_lazy("participation:passage_detail", args=(note.passage.id,))
|
||||
content = format_lazy(text, passage=note.passage.position, pool=pool.short_name,
|
||||
tournament=tournament.name, passage_url=passage_url)
|
||||
informations.append({
|
||||
'title': _("Note"),
|
||||
'type': "warning",
|
||||
'priority': 3 + note.passage.position,
|
||||
'content': content,
|
||||
})
|
||||
|
||||
return informations
|
||||
|
||||
class Meta:
|
||||
|
@ -0,0 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
<p>
|
||||
Bonjour {{ user.registration }},
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Félicitations ! Votre équipe {{ user.registration.team.name }} ({{ user.registration.team.trigram }})
|
||||
est sélectionnée pour le tournoi final du TFJM² !
|
||||
</p>
|
||||
|
||||
<p>
|
||||
La finale aura lieu du {{ tournament.date_start|date:"d/m/Y" }} au {{ tournament.date_end|date:"d/m/Y" }}
|
||||
à : {{ tournament.place }}.
|
||||
Les organisateurices de la finale vous recontacteront pour plus de détails.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
D'ores-et-déjà, vous pouvez soumettre votre autorisation de droit à l'image spécifique à la finale sur votre espace personnel :
|
||||
<a href="https://{{ domain }}{% url 'registration:user_detail' pk=user.pk %}">
|
||||
https://{{ domain }}{% url 'registration:user_detail' pk=user.pk %}
|
||||
</a>.
|
||||
{% if user.registration.is_student and user.registration.under_18_final %}
|
||||
Vous pouvez également transmettre puisque vous êtes mineur⋅e votre autorisation parentale spécifique pour la finale sur la même page.
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{% if tournament.price > 0 %}
|
||||
{% if user.registration.is_student %}
|
||||
{% if payment.type == "scholarship" %}
|
||||
Votre statut de boursièr⋅e déjà enregistré vous exempte à nouveau des frais de participation de la finale.
|
||||
{% else %}
|
||||
Vous devez régler les frais de participation à la finale de {{ tournament.price }} €.
|
||||
Rendez-vous pour cela sur la page du paiement :
|
||||
<a href="https://{{ domain }}{% url 'registration:update_payment' pk=payment.pk %}">
|
||||
https://{{ domain }}{% url 'registration:update_payment' pk=payment.pk %}
|
||||
</a>.
|
||||
{% endif %}
|
||||
{% else %}
|
||||
En tant qu'encadrant⋅e, vous n'avez toujours rien à payer, mais veillez bien à ce que les membres de votre équipe
|
||||
règlent les frais de participation à la finale de {{ tournament.price }} €.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Conformément au règlement du TFJM², vous pouvez soumettre de nouvelles versions de vos solutions,
|
||||
pour améliorer vos explications, corriger des erreurs mineures ou la mise en page, ou supprimer
|
||||
des éléments faux, mais il vous est en revanche interdit d'ajouter des résultats ou des preuves
|
||||
ou de corriger des erreurs majeures.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Pour mettre à jour vos solutions, rendez-vous sur la page de votre équipe :
|
||||
<a href="https://{{ domain }}{% url 'participation:participation_detail' pk=user.registration.team.participation.pk %}">
|
||||
https://{{ domain }}{% url 'participation:participation_detail' pk=user.registration.team.participation.pk %}
|
||||
</a>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Cordialement,
|
||||
</p>
|
||||
|
||||
--
|
||||
<p>
|
||||
L'équipe du TFJM²
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,38 @@
|
||||
Bonjour {{ user.registration }},
|
||||
|
||||
Félicitations ! Votre équipe {{ user.registration.team.name }} ({{ user.registration.team.trigram }}) est sélectionnée pour le tournoi final du TFJM² !
|
||||
|
||||
La finale aura lieu du {{ tournament.date_start|date:"d/m/Y" }} au {{ tournament.date_end|date:"d/m/Y" }} à : {{ tournament.place }}.
|
||||
Les organisateurices de la finale vous recontacteront pour plus de détails.
|
||||
|
||||
D'ores-et-déjà, vous pouvez soumettre votre autorisation de droit à l'image spécifique à la finale sur votre espace personnel :
|
||||
https://{{ domain }}{% url 'registration:user_detail' pk=user.pk %}
|
||||
{% if user.registration.is_student and user.registration.under_18_final %}
|
||||
Vous pouvez également transmettre puisque vous êtes mineur⋅e votre autorisation parentale spécifique pour la finale sur la même page.
|
||||
{% endif %}
|
||||
{% if tournament.price > 0 %}
|
||||
{% if user.registration.is_student %}
|
||||
{% if payment.type == "scholarship" %}
|
||||
Votre statut de boursièr⋅e déjà enregistré vous exempte à nouveau des frais de participation de la finale.
|
||||
{% else %}
|
||||
Vous devez régler les frais de participation à la finale de {{ tournament.price }} €.
|
||||
Rendez-vous pour cela sur la page du paiement :
|
||||
https://{{ domain }}{% url 'registration:update_payment' pk=payment.pk %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
En tant qu'encadrant⋅e, vous n'avez toujours rien à payer, mais veillez bien à ce que les membres de votre équipe
|
||||
règlent les frais de participation à la finale de {{ tournament.price }} €.
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
Conformément au règlement du TFJM², vous pouvez soumettre de nouvelles versions de vos solutions,
|
||||
pour améliorer vos explications, corriger des erreurs mineures ou la mise en page, ou supprimer
|
||||
des éléments faux, mais il vous est en revanche interdit d'ajouter des résultats ou des preuves
|
||||
ou de corriger des erreurs majeures.
|
||||
|
||||
Pour mettre à jour vos solutions, rendez-vous sur la page de votre équipe :
|
||||
https://{{ domain }}{% url 'participation:participation_detail' pk=user.registration.team.participation.pk %}
|
||||
|
||||
Cordialement,
|
||||
|
||||
--
|
||||
L'équipe du TFJM²
|
@ -9,7 +9,7 @@
|
||||
<div id="form-content">
|
||||
<div class="alert alert-info">
|
||||
{% 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>
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
|
@ -9,7 +9,7 @@
|
||||
<div id="form-content">
|
||||
<div class="alert alert-info">
|
||||
{% trans "Authorization template:" %}
|
||||
<a class="alert-link" href="{% url "registration:parental_authorization_template" %}?registration_id={{ object.pk }}&tournament_id={{ object.team.participation.tournament.pk }}">{% trans "Download" %}</a>
|
||||
<a class="alert-link" href="{% url "registration:parental_authorization_template" %}?registration_id={{ object.pk }}&tournament_id={{ tournament.pk }}">{% trans "Download" %}</a>
|
||||
</div>
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
|
@ -9,8 +9,8 @@
|
||||
<div id="form-content">
|
||||
<div class="alert alert-info">
|
||||
{% trans "Authorization templates:" %}
|
||||
<a class="alert-link" href="{% url "registration:photo_authorization_adult_template" %}?registration_id={{ object.pk }}&tournament_id={{ object.team.participation.tournament.pk }}">{% trans "Adult" %}</a> —
|
||||
<a class="alert-link" href="{% url "registration:photo_authorization_child_template" %}?registration_id={{ object.pk }}&tournament_id={{ object.team.participation.tournament.pk }}">{% trans "Child" %}</a>
|
||||
<a class="alert-link" href="{% url "registration:photo_authorization_adult_template" %}?registration_id={{ object.pk }}&tournament_id={{ tournament.pk }}">{% trans "Adult" %}</a> —
|
||||
<a class="alert-link" href="{% url "registration:photo_authorization_child_template" %}?registration_id={{ object.pk }}&tournament_id={{ tournament.pk }}">{% trans "Child" %}</a>
|
||||
</div>
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
|
@ -11,18 +11,18 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{% 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 %}
|
||||
<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">
|
||||
<a href="{% url 'password_change' %}" class="btn btn-sm btn-secondary">
|
||||
<i class="fas fa-edit"></i> {% trans "Change password" %}
|
||||
@ -31,7 +31,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% 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 %}
|
||||
<dd class="col-sm-6">
|
||||
<a href="{% if user_object.registration.team %}{% url "participation:team_detail" pk=user_object.registration.team.pk %}{% else %}#{% endif %}">
|
||||
@ -40,30 +40,30 @@
|
||||
</dd>
|
||||
|
||||
{% 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>
|
||||
{% 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{% 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>
|
||||
{% endif %}
|
||||
|
||||
{% 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>
|
||||
{% 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">
|
||||
{% if user_object.registration.photo_authorization %}
|
||||
<a href="{{ user_object.registration.photo_authorization.url }}">{% trans "Download" %}</a>
|
||||
@ -72,11 +72,21 @@
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadPhotoAuthorizationModal">{% trans "Replace" %}</button>
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
{% if user_object.registration.team.participation.final %}
|
||||
<dt class="col-sm-6 text-sm-end">{% trans "Photo authorization (final):" %}</dt>
|
||||
<dd class="col-sm-6">
|
||||
{% if user_object.registration.photo_authorization_final %}
|
||||
<a href="{{ user_object.registration.photo_authorization_final.url }}">{% trans "Download" %}</a>
|
||||
{% endif %}
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadPhotoAuthorizationFinalModal">{% trans "Replace" %}</button>
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% 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 %}
|
||||
<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">
|
||||
{% if user_object.registration.health_sheet %}
|
||||
<a href="{{ user_object.registration.health_sheet.url }}">{% trans "Download" %}</a>
|
||||
@ -86,7 +96,7 @@
|
||||
{% endif %}
|
||||
</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">
|
||||
{% if user_object.registration.vaccine_sheet %}
|
||||
<a href="{{ user_object.registration.vaccine_sheet.url }}">{% trans "Download" %}</a>
|
||||
@ -96,7 +106,7 @@
|
||||
{% endif %}
|
||||
</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">
|
||||
{% if user_object.registration.parental_authorization %}
|
||||
<a href="{{ user_object.registration.parental_authorization.url }}">{% trans "Download" %}</a>
|
||||
@ -105,49 +115,64 @@
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadParentalAuthorizationModal">{% trans "Replace" %}</button>
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
{% if user_object.registration.team.participation.final %}
|
||||
<dt class="col-sm-6 text-sm-end">{% trans "Parental authorization (final):" %}</dt>
|
||||
<dd class="col-sm-6">
|
||||
{% if user_object.registration.parental_authorization_final %}
|
||||
<a href="{{ user_object.registration.parental_authorization_final.url }}">{% trans "Download" %}</a>
|
||||
{% endif %}
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadParentalAuthorizationFinalModal">{% trans "Replace" %}</button>
|
||||
</dd>
|
||||
{% 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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 %}
|
||||
<dd class="col-sm-6"><a href="mailto:{{ email }}">{{ email }}</a></dd>
|
||||
{% endwith %}
|
||||
{% 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>
|
||||
|
||||
<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>
|
||||
|
||||
{% 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>
|
||||
|
||||
<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>
|
||||
{% 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>
|
||||
</dl>
|
||||
|
||||
{% if user_object.registration.participates and user_object.registration.team.participation.valid %}
|
||||
<hr>
|
||||
{% for payment in user_object.registration.payments.all %}
|
||||
<hr>
|
||||
|
||||
<dl class="row">
|
||||
<dt class="col-sm-6 text-end">{% trans "Payment information:" %}</dt>
|
||||
<dt class="col-sm-6 text-sm-end">
|
||||
{% if payment.final %}
|
||||
{% trans "Payment information (final):" %}
|
||||
{% else %}
|
||||
{% trans "Payment information:" %}
|
||||
{% endif %}
|
||||
</dt>
|
||||
<dd class="col-sm-6">
|
||||
{% trans "yes,no,pending" as yesnodefault %}
|
||||
{% with info=payment.additional_information %}
|
||||
@ -197,25 +222,36 @@
|
||||
{% url "registration:upload_user_photo_authorization" pk=user_object.registration.pk as modal_action %}
|
||||
{% include "base_modal.html" with modal_id="uploadPhotoAuthorization" modal_enctype="multipart/form-data" %}
|
||||
|
||||
{% trans "Upload health sheet" as modal_title %}
|
||||
{% trans "Upload" as modal_button %}
|
||||
{% url "registration:upload_user_health_sheet" pk=user_object.registration.pk as modal_action %}
|
||||
{% include "base_modal.html" with modal_id="uploadHealthSheet" modal_enctype="multipart/form-data" %}
|
||||
{% if user_object.registration.under_18 %}
|
||||
{% trans "Upload health sheet" as modal_title %}
|
||||
{% trans "Upload" as modal_button %}
|
||||
{% url "registration:upload_user_health_sheet" pk=user_object.registration.pk as modal_action %}
|
||||
{% include "base_modal.html" with modal_id="uploadHealthSheet" modal_enctype="multipart/form-data" %}
|
||||
|
||||
{% trans "Upload vaccine sheet" as modal_title %}
|
||||
{% trans "Upload" as modal_button %}
|
||||
{% url "registration:upload_user_vaccine_sheet" pk=user_object.registration.pk as modal_action %}
|
||||
{% include "base_modal.html" with modal_id="uploadVaccineSheet" modal_enctype="multipart/form-data" %}
|
||||
{% trans "Upload vaccine sheet" as modal_title %}
|
||||
{% trans "Upload" as modal_button %}
|
||||
{% url "registration:upload_user_vaccine_sheet" pk=user_object.registration.pk as modal_action %}
|
||||
{% include "base_modal.html" with modal_id="uploadVaccineSheet" modal_enctype="multipart/form-data" %}
|
||||
|
||||
{% trans "Upload parental authorization" as modal_title %}
|
||||
{% trans "Upload" as modal_button %}
|
||||
{% url "registration:upload_user_parental_authorization" pk=user_object.registration.pk as modal_action %}
|
||||
{% include "base_modal.html" with modal_id="uploadParentalAuthorization" modal_enctype="multipart/form-data" %}
|
||||
{% trans "Upload parental authorization" as modal_title %}
|
||||
{% trans "Upload" as modal_button %}
|
||||
{% url "registration:upload_user_parental_authorization" pk=user_object.registration.pk as modal_action %}
|
||||
{% include "base_modal.html" with modal_id="uploadParentalAuthorization" modal_enctype="multipart/form-data" %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% trans "Upload parental authorization" as modal_title %}
|
||||
{% if user_object.registration.team.participation.final %}
|
||||
{% trans "Upload photo authorization (final)" as modal_title %}
|
||||
{% trans "Upload" as modal_button %}
|
||||
{% url "registration:upload_user_parental_authorization" pk=user_object.registration.pk as modal_action %}
|
||||
{% include "base_modal.html" with modal_id="uploadParentalAuthorization" modal_enctype="multipart/form-data" %}
|
||||
{% url "registration:upload_user_photo_authorization_final" pk=user_object.registration.pk as modal_action %}
|
||||
{% include "base_modal.html" with modal_id="uploadPhotoAuthorizationFinal" modal_enctype="multipart/form-data" %}
|
||||
|
||||
{% if user_object.registration.under_18_final %}
|
||||
{% trans "Upload parental authorization (final)" as modal_title %}
|
||||
{% trans "Upload" as modal_button %}
|
||||
{% url "registration:upload_user_parental_authorization_final" pk=user_object.registration.pk as modal_action %}
|
||||
{% include "base_modal.html" with modal_id="uploadParentalAuthorizationFinal" modal_enctype="multipart/form-data" %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@ -224,9 +260,18 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
{% if user_object.registration.team and not user_object.registration.team.participation.valid %}
|
||||
initModal("uploadPhotoAuthorization", "{% url "registration:upload_user_photo_authorization" pk=user_object.registration.pk %}")
|
||||
initModal("uploadHealthSheet", "{% url "registration:upload_user_health_sheet" pk=user_object.registration.pk %}")
|
||||
initModal("uploadVaccineSheet", "{% url "registration:upload_user_vaccine_sheet" pk=user_object.registration.pk %}")
|
||||
initModal("uploadParentalAuthorization", "{% url "registration:upload_user_parental_authorization" pk=user_object.registration.pk %}")
|
||||
{% if user_object.registration.under_18 %}
|
||||
initModal("uploadHealthSheet", "{% url "registration:upload_user_health_sheet" pk=user_object.registration.pk %}")
|
||||
initModal("uploadVaccineSheet", "{% url "registration:upload_user_vaccine_sheet" pk=user_object.registration.pk %}")
|
||||
initModal("uploadParentalAuthorization", "{% url "registration:upload_user_parental_authorization" pk=user_object.registration.pk %}")
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if user_object.registration.team.participation.final %}
|
||||
initModal("uploadPhotoAuthorizationFinal", "{% url "registration:upload_user_photo_authorization_final" pk=user_object.registration.pk %}")
|
||||
{% if user_object.registration.under_18_final %}
|
||||
initModal("uploadParentalAuthorizationFinal", "{% url "registration:upload_user_parental_authorization_final" pk=user_object.registration.pk %}")
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
|
@ -24,6 +24,8 @@ urlpatterns = [
|
||||
path("user/<int:pk>/update/", UserUpdateView.as_view(), name="update_user"),
|
||||
path("user/<int:pk>/upload-photo-authorization/", UserUploadPhotoAuthorizationView.as_view(),
|
||||
name="upload_user_photo_authorization"),
|
||||
path("user/<int:pk>/upload-photo-authorization/final/", UserUploadPhotoAuthorizationView.as_view(),
|
||||
name="upload_user_photo_authorization_final"),
|
||||
path("parental-authorization-template/", ParentalAuthorizationTemplateView.as_view(),
|
||||
name="parental_authorization_template"),
|
||||
path("photo-authorization-template/adult/", AdultPhotoAuthorizationTemplateView.as_view(),
|
||||
@ -37,6 +39,8 @@ urlpatterns = [
|
||||
name="upload_user_vaccine_sheet"),
|
||||
path("user/<int:pk>/upload-parental-authorization/", UserUploadParentalAuthorizationView.as_view(),
|
||||
name="upload_user_parental_authorization"),
|
||||
path("user/<int:pk>/upload-parental-authorization/final/", UserUploadParentalAuthorizationView.as_view(),
|
||||
name="upload_user_parental_authorization_final"),
|
||||
path("update-payment/<int:pk>/", PaymentUpdateView.as_view(), name="update_payment"),
|
||||
path("update-payment/<int:pk>/toggle-group-mode/", PaymentUpdateGroupView.as_view(),
|
||||
name="update_payment_group_mode"),
|
||||
|
@ -1,5 +1,6 @@
|
||||
# Copyright (C) 2020 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
@ -29,8 +30,9 @@ from participation.models import Passage, Solution, Synthesis, Tournament
|
||||
from tfjm.tokens import email_validation_token
|
||||
from tfjm.views import UserMixin, UserRegistrationMixin, VolunteerMixin
|
||||
|
||||
from .forms import AddOrganizerForm, CoachRegistrationForm, HealthSheetForm, ParentalAuthorizationForm, \
|
||||
PaymentAdminForm, PaymentForm, PhotoAuthorizationForm, SignupForm, StudentRegistrationForm, UserForm, \
|
||||
from .forms import AddOrganizerForm, CoachRegistrationForm, HealthSheetForm, \
|
||||
ParentalAuthorizationFinalForm, ParentalAuthorizationForm, PaymentAdminForm, PaymentForm, \
|
||||
PhotoAuthorizationFinalForm, PhotoAuthorizationForm, SignupForm, StudentRegistrationForm, UserForm, \
|
||||
VaccineSheetForm, VolunteerRegistrationForm
|
||||
from .models import ParticipantRegistration, Payment, Registration, StudentRegistration
|
||||
from .tables import RegistrationTable
|
||||
@ -311,15 +313,27 @@ class UserUploadPhotoAuthorizationView(UserRegistrationMixin, UpdateView):
|
||||
A participant can send its photo authorization.
|
||||
"""
|
||||
model = ParticipantRegistration
|
||||
form_class = PhotoAuthorizationForm
|
||||
template_name = "registration/upload_photo_authorization.html"
|
||||
extra_context = dict(title=_("Upload photo authorization"))
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
if self.object.team:
|
||||
tournament = self.object.team.participation.tournament \
|
||||
if 'final' not in self.request.path else Tournament.final_tournament()
|
||||
context["tournament"] = tournament
|
||||
return context
|
||||
|
||||
def get_form_class(self):
|
||||
return PhotoAuthorizationForm if 'final' not in self.request.path else PhotoAuthorizationFinalForm
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
old_instance = ParticipantRegistration.objects.get(pk=self.object.pk)
|
||||
if old_instance.photo_authorization:
|
||||
old_instance.photo_authorization.delete()
|
||||
old_instance: ParticipantRegistration = ParticipantRegistration.objects.get(pk=self.object.pk)
|
||||
old_field = old_instance.photo_authorization \
|
||||
if 'final' not in self.request.path else old_instance.photo_authorization_final
|
||||
if old_field:
|
||||
old_field.delete()
|
||||
old_instance.save()
|
||||
return super().form_valid(form)
|
||||
|
||||
@ -374,15 +388,27 @@ class UserUploadParentalAuthorizationView(UserRegistrationMixin, UpdateView):
|
||||
A participant can send its parental authorization.
|
||||
"""
|
||||
model = StudentRegistration
|
||||
form_class = ParentalAuthorizationForm
|
||||
template_name = "registration/upload_parental_authorization.html"
|
||||
extra_context = dict(title=_("Upload parental authorization"))
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
if self.object.team:
|
||||
tournament = self.object.team.participation.tournament \
|
||||
if 'final' not in self.request.path else Tournament.final_tournament()
|
||||
context["tournament"] = tournament
|
||||
return context
|
||||
|
||||
def get_form_class(self):
|
||||
return ParentalAuthorizationForm if 'final' not in self.request.path else ParentalAuthorizationFinalForm
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
old_instance = StudentRegistration.objects.get(pk=self.object.pk)
|
||||
if old_instance.parental_authorization:
|
||||
old_instance.parental_authorization.delete()
|
||||
old_instance: StudentRegistration = StudentRegistration.objects.get(pk=self.object.pk)
|
||||
old_field = old_instance.parental_authorization \
|
||||
if 'final' not in self.request.path else old_instance.parental_authorization_final
|
||||
if old_field:
|
||||
old_field.delete()
|
||||
old_instance.save()
|
||||
return super().form_valid(form)
|
||||
|
||||
@ -666,7 +692,8 @@ class PhotoAuthorizationView(LoginRequiredMixin, View):
|
||||
path = f"media/authorization/photo/{filename}"
|
||||
if not os.path.exists(path):
|
||||
raise Http404
|
||||
student = ParticipantRegistration.objects.get(photo_authorization__endswith=filename)
|
||||
student = ParticipantRegistration.objects.get(Q(photo_authorization__endswith=filename)
|
||||
| Q(photo_authorization_final__endswith=filename))
|
||||
user = request.user
|
||||
if not (student.user == user or user.registration.is_admin or user.registration.is_volunteer and student.team
|
||||
and student.team.participation.tournament in user.registration.organized_tournaments.all()):
|
||||
@ -738,7 +765,8 @@ class ParentalAuthorizationView(LoginRequiredMixin, View):
|
||||
path = f"media/authorization/parental/{filename}"
|
||||
if not os.path.exists(path):
|
||||
raise Http404
|
||||
student = StudentRegistration.objects.get(parental_authorization__endswith=filename)
|
||||
student = StudentRegistration.objects.get(Q(parental_authorization__endswith=filename)
|
||||
| Q(parental_authorization_final__endswith=filename))
|
||||
user = request.user
|
||||
if not (student.user == user or user.registration.is_admin or user.registration.is_volunteer and student.team
|
||||
and student.team.participation.tournament in user.registration.organized_tournaments.all()):
|
||||
@ -800,7 +828,7 @@ class SolutionView(LoginRequiredMixin, View):
|
||||
or (user.registration.is_volunteer
|
||||
and user.registration in solution.tournament.organizers.all())
|
||||
or (user.registration.is_volunteer
|
||||
and user.registration.presided_pools.filter(tournament=solution.tournament).exists())
|
||||
and user.registration.pools_presided.filter(tournament=solution.tournament).exists())
|
||||
or user.registration.is_volunteer
|
||||
and Passage.objects.filter(Q(pool__juries=user.registration)
|
||||
| Q(pool__tournament__in=user.registration.organized_tournaments.all()),
|
||||
@ -836,7 +864,7 @@ class SynthesisView(LoginRequiredMixin, View):
|
||||
if not (user.registration.is_admin or user.registration.is_volunteer
|
||||
and (user.registration in synthesis.passage.pool.juries.all()
|
||||
or user.registration in synthesis.passage.pool.tournament.organizers.all()
|
||||
or user.registration.presided_pools.filter(tournament=synthesis.passage.pool.tournament).exists())
|
||||
or user.registration.pools_presided.filter(tournament=synthesis.passage.pool.tournament).exists())
|
||||
or user.registration.participates and user.registration.team == synthesis.participation.team):
|
||||
raise PermissionDenied
|
||||
# Guess mime type of the file
|
||||
|
@ -1,20 +1,23 @@
|
||||
channels[daphne]~=4.0.0
|
||||
channels-redis~=4.2.0
|
||||
crispy-bootstrap5~=2023.10
|
||||
Django>=5.0,<6.0
|
||||
Django>=5.0.3,<6.0
|
||||
django-crispy-forms~=2.1
|
||||
django-extensions~=3.2.3
|
||||
django-filter~=23.5
|
||||
elasticsearch~=7.17.9
|
||||
git+https://github.com/django-haystack/django-haystack.git#v3.3b1
|
||||
git+https://github.com/django-haystack/django-haystack.git#v3.3b2
|
||||
django-mailer~=2.3.1
|
||||
django-phonenumber-field~=7.3.0
|
||||
django-pipeline~=3.1.0
|
||||
django-polymorphic~=3.1.0
|
||||
django-tables2~=2.7.0
|
||||
djangorestframework~=3.14.0
|
||||
django-rest-polymorphic~=0.1.10
|
||||
elasticsearch~=7.17.9
|
||||
gspread~=6.1.0
|
||||
gunicorn~=21.2.0
|
||||
odfpy~=1.4.1
|
||||
pandas~=2.2.1
|
||||
phonenumbers~=8.13.27
|
||||
psycopg2-binary~=2.9.9
|
||||
pypdf~=3.17.4
|
||||
|
@ -8,12 +8,18 @@
|
||||
*/2 * * * * cd /code && python manage.py update_index &> /dev/null
|
||||
|
||||
# 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
|
||||
*/6 * * * * cd /code && python manage.py check_hello_asso &> /dev/null
|
||||
# Send reminders for payments
|
||||
30 6 * * 1 cd /code && python manage.py remind_payments &> /dev/null
|
||||
|
||||
# Check notation sheets every 15 minutes from 08:00 to 23:00 on fridays to mondays in april and may
|
||||
# */15 8-23 * 4-5 5,6,7,1 cd /code && python manage.py parse_notation_sheets -v 0
|
||||
|
||||
# Update Google Drive notifications daily
|
||||
0 0 * * * cd /code && python manage.py renew_gdrive_notifications &> /dev/null
|
||||
|
||||
# Clean temporary files
|
||||
30 * * * * rm -rf /tmp/*
|
||||
|
@ -22,13 +22,13 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tfjm.settings')
|
||||
django_asgi_app = get_asgi_application()
|
||||
|
||||
# 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(
|
||||
{
|
||||
"http": django_asgi_app,
|
||||
"websocket": AllowedHostsOriginValidator(
|
||||
AuthMiddlewareStack(URLRouter(draw.routing.websocket_urlpatterns))
|
||||
AuthMiddlewareStack(URLRouter(tfjm.routing.websocket_urlpatterns))
|
||||
),
|
||||
}
|
||||
)
|
||||
|
19
tfjm/permissions.py
Normal file
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
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.
|
||||
|
||||
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
|
||||
https://docs.djangoproject.com/en/3.0/ref/settings/
|
||||
https://docs.djangoproject.com/en/5.0/ref/settings/
|
||||
"""
|
||||
|
||||
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")]
|
||||
|
||||
# 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!
|
||||
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'CHANGE_ME_IN_ENV_SETTINGS')
|
||||
@ -63,11 +63,13 @@ INSTALLED_APPS = [
|
||||
'haystack',
|
||||
'logs',
|
||||
'phonenumber_field',
|
||||
'pipeline',
|
||||
'polymorphic',
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
|
||||
'api',
|
||||
'chat',
|
||||
'draw',
|
||||
'registration',
|
||||
'participation',
|
||||
@ -94,6 +96,8 @@ MIDDLEWARE = [
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
'django.contrib.sites.middleware.CurrentSiteMiddleware',
|
||||
'django.middleware.gzip.GZipMiddleware',
|
||||
'pipeline.middleware.MinifyHTMLMiddleware',
|
||||
'tfjm.middlewares.SessionMiddleware',
|
||||
'tfjm.middlewares.FetchMiddleware',
|
||||
]
|
||||
@ -101,6 +105,7 @@ MIDDLEWARE = [
|
||||
ROOT_URLCONF = 'tfjm.urls'
|
||||
|
||||
LOGIN_REDIRECT_URL = "index"
|
||||
LOGOUT_REDIRECT_URL = "login"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
@ -124,7 +129,7 @@ ASGI_APPLICATION = 'tfjm.asgi.application'
|
||||
WSGI_APPLICATION = 'tfjm.wsgi.application'
|
||||
|
||||
# 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 = [
|
||||
{
|
||||
@ -159,7 +164,7 @@ REST_FRAMEWORK = {
|
||||
}
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/3.0/topics/i18n/
|
||||
# https://docs.djangoproject.com/en/5.0/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en'
|
||||
|
||||
@ -179,7 +184,7 @@ USE_TZ = True
|
||||
LOCALE_PATHS = [os.path.join(BASE_DIR, "locale")]
|
||||
|
||||
# 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/'
|
||||
|
||||
@ -189,6 +194,70 @@ STATICFILES_DIRS = [
|
||||
|
||||
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_ROOT = os.path.join(BASE_DIR, "media")
|
||||
@ -246,6 +315,23 @@ HELLOASSO_CLIENT_ID = os.getenv('HELLOASSO_CLIENT_ID', 'CHANGE_ME_IN_ENV_SETTING
|
||||
HELLOASSO_CLIENT_SECRET = os.getenv('HELLOASSO_CLIENT_SECRET', 'CHANGE_ME_IN_ENV_SETTINGS')
|
||||
HELLOASSO_TEST_ENDPOINT = False # Enable custom test endpoint, for unit tests
|
||||
|
||||
GOOGLE_SERVICE_CLIENT = {
|
||||
"type": "service_account",
|
||||
"project_id": os.getenv("GOOGLE_PROJECT_ID", "plateforme-tfjm"),
|
||||
"private_key_id": os.getenv("GOOGLE_PRIVATE_KEY_ID", "CHANGE_ME_IN_ENV_SETTINGS"),
|
||||
"private_key": os.getenv("GOOGLE_PRIVATE_KEY", "CHANGE_ME_IN_ENV_SETTINGS").replace("\\n", "\n"),
|
||||
"client_email": os.getenv("GOOGLE_CLIENT_EMAIL", "CHANGE_ME_IN_ENV_SETTINGS"),
|
||||
"client_id": os.getenv("GOOGLE_CLIENT_ID", "CHANGE_ME_IN_ENV_SETTINGS"),
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": os.getenv("GOOGLE_CLIENT_X509_CERT_URL", "CHANGE_ME_IN_ENV_SETTINGS"),
|
||||
"universe_domain": "googleapis.com"
|
||||
}
|
||||
|
||||
# The ID of the Google Drive folder where to store the notation sheets
|
||||
NOTES_DRIVE_FOLDER_ID = os.getenv("NOTES_DRIVE_FOLDER_ID", "CHANGE_ME_IN_ENV_SETTINGS")
|
||||
|
||||
# Custom parameters
|
||||
PROBLEMS = [
|
||||
"Triominos",
|
||||
|
@ -7,7 +7,7 @@ import os
|
||||
DEBUG = False
|
||||
|
||||
# Mandatory !
|
||||
ALLOWED_HOSTS = ['inscription.tfjm.org', 'plateforme.tfjm.org']
|
||||
ALLOWED_HOSTS = ['inscription.tfjm.org', 'inscriptions.tfjm.org', 'plateforme.tfjm.org']
|
||||
|
||||
# Emails
|
||||
EMAIL_BACKEND = 'mailer.backend.DbBackend'
|
||||
@ -27,7 +27,7 @@ SESSION_COOKIE_SECURE = False
|
||||
CSRF_COOKIE_SECURE = False
|
||||
CSRF_COOKIE_HTTPONLY = False
|
||||
X_FRAME_OPTIONS = 'DENY'
|
||||
SESSION_COOKIE_AGE = 60 * 60 * 3
|
||||
SESSION_COOKIE_AGE = 60 * 60 * 24 * 7 * 2 # 2 weeks
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
165
tfjm/static/fontawesome/LICENSE.txt
Normal file
165
tfjm/static/fontawesome/LICENSE.txt
Normal file
@ -0,0 +1,165 @@
|
||||
Fonticons, Inc. (https://fontawesome.com)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Font Awesome Free License
|
||||
|
||||
Font Awesome Free is free, open source, and GPL friendly. You can use it for
|
||||
commercial projects, open source projects, or really almost whatever you want.
|
||||
Full Font Awesome Free license: https://fontawesome.com/license/free.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
# Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/)
|
||||
|
||||
The Font Awesome Free download is licensed under a Creative Commons
|
||||
Attribution 4.0 International License and applies to all icons packaged
|
||||
as SVG and JS file types.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
# Fonts: SIL OFL 1.1 License
|
||||
|
||||
In the Font Awesome Free download, the SIL OFL license applies to all icons
|
||||
packaged as web and desktop font files.
|
||||
|
||||
Copyright (c) 2023 Fonticons, Inc. (https://fontawesome.com)
|
||||
with Reserved Font Name: "Font Awesome".
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
SIL OPEN FONT LICENSE
|
||||
Version 1.1 - 26 February 2007
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting — in part or in whole — any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
# Code: MIT License (https://opensource.org/licenses/MIT)
|
||||
|
||||
In the Font Awesome Free download, the MIT license applies to all non-font and
|
||||
non-icon files.
|
||||
|
||||
Copyright 2023 Fonticons, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without limitation the rights to use, copy,
|
||||
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
|
||||
and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
# Attribution
|
||||
|
||||
Attribution is required by MIT, SIL OFL, and CC BY licenses. Downloaded Font
|
||||
Awesome Free files already contain embedded comments with sufficient
|
||||
attribution, so you shouldn't need to do anything additional when using these
|
||||
files normally.
|
||||
|
||||
We've kept attribution comments terse, so we ask that you do not actively work
|
||||
to remove them from files, especially code. They're a great way for folks to
|
||||
learn about Font Awesome.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
# Brand Icons
|
||||
|
||||
All brand icons are trademarks of their respective owners. The use of these
|
||||
trademarks does not indicate endorsement of the trademark holder by Font
|
||||
Awesome, nor vice versa. **Please do not use brand logos for any purpose except
|
||||
to represent the company, product, or service to which they refer.**
|
8003
tfjm/static/fontawesome/css/all.css
vendored
Normal file
8003
tfjm/static/fontawesome/css/all.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
9
tfjm/static/fontawesome/css/all.min.css
vendored
Normal file
9
tfjm/static/fontawesome/css/all.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1573
tfjm/static/fontawesome/css/brands.css
vendored
Normal file
1573
tfjm/static/fontawesome/css/brands.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
6
tfjm/static/fontawesome/css/brands.min.css
vendored
Normal file
6
tfjm/static/fontawesome/css/brands.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
6369
tfjm/static/fontawesome/css/fontawesome.css
vendored
Normal file
6369
tfjm/static/fontawesome/css/fontawesome.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
9
tfjm/static/fontawesome/css/fontawesome.min.css
vendored
Normal file
9
tfjm/static/fontawesome/css/fontawesome.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
19
tfjm/static/fontawesome/css/regular.css
vendored
Normal file
19
tfjm/static/fontawesome/css/regular.css
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
/*!
|
||||
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
* Copyright 2023 Fonticons, Inc.
|
||||
*/
|
||||
:root, :host {
|
||||
--fa-style-family-classic: 'Font Awesome 6 Free';
|
||||
--fa-font-regular: normal 400 1em/1 'Font Awesome 6 Free'; }
|
||||
|
||||
@font-face {
|
||||
font-family: 'Font Awesome 6 Free';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: block;
|
||||
src: url("../webfonts/fa-regular-400.woff2") format("woff2"), url("../webfonts/fa-regular-400.ttf") format("truetype"); }
|
||||
|
||||
.far,
|
||||
.fa-regular {
|
||||
font-weight: 400; }
|
6
tfjm/static/fontawesome/css/regular.min.css
vendored
Normal file
6
tfjm/static/fontawesome/css/regular.min.css
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/*!
|
||||
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
* Copyright 2023 Fonticons, Inc.
|
||||
*/
|
||||
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-weight:400}
|
19
tfjm/static/fontawesome/css/solid.css
vendored
Normal file
19
tfjm/static/fontawesome/css/solid.css
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
/*!
|
||||
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
* Copyright 2023 Fonticons, Inc.
|
||||
*/
|
||||
:root, :host {
|
||||
--fa-style-family-classic: 'Font Awesome 6 Free';
|
||||
--fa-font-solid: normal 900 1em/1 'Font Awesome 6 Free'; }
|
||||
|
||||
@font-face {
|
||||
font-family: 'Font Awesome 6 Free';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: block;
|
||||
src: url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.ttf") format("truetype"); }
|
||||
|
||||
.fas,
|
||||
.fa-solid {
|
||||
font-weight: 900; }
|
6
tfjm/static/fontawesome/css/solid.min.css
vendored
Normal file
6
tfjm/static/fontawesome/css/solid.min.css
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/*!
|
||||
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
* Copyright 2023 Fonticons, Inc.
|
||||
*/
|
||||
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900}
|
640
tfjm/static/fontawesome/css/svg-with-js.css
vendored
Normal file
640
tfjm/static/fontawesome/css/svg-with-js.css
vendored
Normal file
@ -0,0 +1,640 @@
|
||||
/*!
|
||||
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
* Copyright 2023 Fonticons, Inc.
|
||||
*/
|
||||
:root, :host {
|
||||
--fa-font-solid: normal 900 1em/1 'Font Awesome 6 Solid';
|
||||
--fa-font-regular: normal 400 1em/1 'Font Awesome 6 Regular';
|
||||
--fa-font-light: normal 300 1em/1 'Font Awesome 6 Light';
|
||||
--fa-font-thin: normal 100 1em/1 'Font Awesome 6 Thin';
|
||||
--fa-font-duotone: normal 900 1em/1 'Font Awesome 6 Duotone';
|
||||
--fa-font-sharp-solid: normal 900 1em/1 'Font Awesome 6 Sharp';
|
||||
--fa-font-sharp-regular: normal 400 1em/1 'Font Awesome 6 Sharp';
|
||||
--fa-font-sharp-light: normal 300 1em/1 'Font Awesome 6 Sharp';
|
||||
--fa-font-sharp-thin: normal 100 1em/1 'Font Awesome 6 Sharp';
|
||||
--fa-font-brands: normal 400 1em/1 'Font Awesome 6 Brands'; }
|
||||
|
||||
svg:not(:root).svg-inline--fa, svg:not(:host).svg-inline--fa {
|
||||
overflow: visible;
|
||||
box-sizing: content-box; }
|
||||
|
||||
.svg-inline--fa {
|
||||
display: var(--fa-display, inline-block);
|
||||
height: 1em;
|
||||
overflow: visible;
|
||||
vertical-align: -.125em; }
|
||||
.svg-inline--fa.fa-2xs {
|
||||
vertical-align: 0.1em; }
|
||||
.svg-inline--fa.fa-xs {
|
||||
vertical-align: 0em; }
|
||||
.svg-inline--fa.fa-sm {
|
||||
vertical-align: -0.07143em; }
|
||||
.svg-inline--fa.fa-lg {
|
||||
vertical-align: -0.2em; }
|
||||
.svg-inline--fa.fa-xl {
|
||||
vertical-align: -0.25em; }
|
||||
.svg-inline--fa.fa-2xl {
|
||||
vertical-align: -0.3125em; }
|
||||
.svg-inline--fa.fa-pull-left {
|
||||
margin-right: var(--fa-pull-margin, 0.3em);
|
||||
width: auto; }
|
||||
.svg-inline--fa.fa-pull-right {
|
||||
margin-left: var(--fa-pull-margin, 0.3em);
|
||||
width: auto; }
|
||||
.svg-inline--fa.fa-li {
|
||||
width: var(--fa-li-width, 2em);
|
||||
top: 0.25em; }
|
||||
.svg-inline--fa.fa-fw {
|
||||
width: var(--fa-fw-width, 1.25em); }
|
||||
|
||||
.fa-layers svg.svg-inline--fa {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0; }
|
||||
|
||||
.fa-layers-text, .fa-layers-counter {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
text-align: center; }
|
||||
|
||||
.fa-layers {
|
||||
display: inline-block;
|
||||
height: 1em;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
vertical-align: -.125em;
|
||||
width: 1em; }
|
||||
.fa-layers svg.svg-inline--fa {
|
||||
-webkit-transform-origin: center center;
|
||||
transform-origin: center center; }
|
||||
|
||||
.fa-layers-text {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
-webkit-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
-webkit-transform-origin: center center;
|
||||
transform-origin: center center; }
|
||||
|
||||
.fa-layers-counter {
|
||||
background-color: var(--fa-counter-background-color, #ff253a);
|
||||
border-radius: var(--fa-counter-border-radius, 1em);
|
||||
box-sizing: border-box;
|
||||
color: var(--fa-inverse, #fff);
|
||||
line-height: var(--fa-counter-line-height, 1);
|
||||
max-width: var(--fa-counter-max-width, 5em);
|
||||
min-width: var(--fa-counter-min-width, 1.5em);
|
||||
overflow: hidden;
|
||||
padding: var(--fa-counter-padding, 0.25em 0.5em);
|
||||
right: var(--fa-right, 0);
|
||||
text-overflow: ellipsis;
|
||||
top: var(--fa-top, 0);
|
||||
-webkit-transform: scale(var(--fa-counter-scale, 0.25));
|
||||
transform: scale(var(--fa-counter-scale, 0.25));
|
||||
-webkit-transform-origin: top right;
|
||||
transform-origin: top right; }
|
||||
|
||||
.fa-layers-bottom-right {
|
||||
bottom: var(--fa-bottom, 0);
|
||||
right: var(--fa-right, 0);
|
||||
top: auto;
|
||||
-webkit-transform: scale(var(--fa-layers-scale, 0.25));
|
||||
transform: scale(var(--fa-layers-scale, 0.25));
|
||||
-webkit-transform-origin: bottom right;
|
||||
transform-origin: bottom right; }
|
||||
|
||||
.fa-layers-bottom-left {
|
||||
bottom: var(--fa-bottom, 0);
|
||||
left: var(--fa-left, 0);
|
||||
right: auto;
|
||||
top: auto;
|
||||
-webkit-transform: scale(var(--fa-layers-scale, 0.25));
|
||||
transform: scale(var(--fa-layers-scale, 0.25));
|
||||
-webkit-transform-origin: bottom left;
|
||||
transform-origin: bottom left; }
|
||||
|
||||
.fa-layers-top-right {
|
||||
top: var(--fa-top, 0);
|
||||
right: var(--fa-right, 0);
|
||||
-webkit-transform: scale(var(--fa-layers-scale, 0.25));
|
||||
transform: scale(var(--fa-layers-scale, 0.25));
|
||||
-webkit-transform-origin: top right;
|
||||
transform-origin: top right; }
|
||||
|
||||
.fa-layers-top-left {
|
||||
left: var(--fa-left, 0);
|
||||
right: auto;
|
||||
top: var(--fa-top, 0);
|
||||
-webkit-transform: scale(var(--fa-layers-scale, 0.25));
|
||||
transform: scale(var(--fa-layers-scale, 0.25));
|
||||
-webkit-transform-origin: top left;
|
||||
transform-origin: top left; }
|
||||
|
||||
.fa-1x {
|
||||
font-size: 1em; }
|
||||
|
||||
.fa-2x {
|
||||
font-size: 2em; }
|
||||
|
||||
.fa-3x {
|
||||
font-size: 3em; }
|
||||
|
||||
.fa-4x {
|
||||
font-size: 4em; }
|
||||
|
||||
.fa-5x {
|
||||
font-size: 5em; }
|
||||
|
||||
.fa-6x {
|
||||
font-size: 6em; }
|
||||
|
||||
.fa-7x {
|
||||
font-size: 7em; }
|
||||
|
||||
.fa-8x {
|
||||
font-size: 8em; }
|
||||
|
||||
.fa-9x {
|
||||
font-size: 9em; }
|
||||
|
||||
.fa-10x {
|
||||
font-size: 10em; }
|
||||
|
||||
.fa-2xs {
|
||||
font-size: 0.625em;
|
||||
line-height: 0.1em;
|
||||
vertical-align: 0.225em; }
|
||||
|
||||
.fa-xs {
|
||||
font-size: 0.75em;
|
||||
line-height: 0.08333em;
|
||||
vertical-align: 0.125em; }
|
||||
|
||||
.fa-sm {
|
||||
font-size: 0.875em;
|
||||
line-height: 0.07143em;
|
||||
vertical-align: 0.05357em; }
|
||||
|
||||
.fa-lg {
|
||||
font-size: 1.25em;
|
||||
line-height: 0.05em;
|
||||
vertical-align: -0.075em; }
|
||||
|
||||
.fa-xl {
|
||||
font-size: 1.5em;
|
||||
line-height: 0.04167em;
|
||||
vertical-align: -0.125em; }
|
||||
|
||||
.fa-2xl {
|
||||
font-size: 2em;
|
||||
line-height: 0.03125em;
|
||||
vertical-align: -0.1875em; }
|
||||
|
||||
.fa-fw {
|
||||
text-align: center;
|
||||
width: 1.25em; }
|
||||
|
||||
.fa-ul {
|
||||
list-style-type: none;
|
||||
margin-left: var(--fa-li-margin, 2.5em);
|
||||
padding-left: 0; }
|
||||
.fa-ul > li {
|
||||
position: relative; }
|
||||
|
||||
.fa-li {
|
||||
left: calc(var(--fa-li-width, 2em) * -1);
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
width: var(--fa-li-width, 2em);
|
||||
line-height: inherit; }
|
||||
|
||||
.fa-border {
|
||||
border-color: var(--fa-border-color, #eee);
|
||||
border-radius: var(--fa-border-radius, 0.1em);
|
||||
border-style: var(--fa-border-style, solid);
|
||||
border-width: var(--fa-border-width, 0.08em);
|
||||
padding: var(--fa-border-padding, 0.2em 0.25em 0.15em); }
|
||||
|
||||
.fa-pull-left {
|
||||
float: left;
|
||||
margin-right: var(--fa-pull-margin, 0.3em); }
|
||||
|
||||
.fa-pull-right {
|
||||
float: right;
|
||||
margin-left: var(--fa-pull-margin, 0.3em); }
|
||||
|
||||
.fa-beat {
|
||||
-webkit-animation-name: fa-beat;
|
||||
animation-name: fa-beat;
|
||||
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||
animation-delay: var(--fa-animation-delay, 0s);
|
||||
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||
animation-direction: var(--fa-animation-direction, normal);
|
||||
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||
animation-duration: var(--fa-animation-duration, 1s);
|
||||
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
-webkit-animation-timing-function: var(--fa-animation-timing, ease-in-out);
|
||||
animation-timing-function: var(--fa-animation-timing, ease-in-out); }
|
||||
|
||||
.fa-bounce {
|
||||
-webkit-animation-name: fa-bounce;
|
||||
animation-name: fa-bounce;
|
||||
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||
animation-delay: var(--fa-animation-delay, 0s);
|
||||
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||
animation-direction: var(--fa-animation-direction, normal);
|
||||
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||
animation-duration: var(--fa-animation-duration, 1s);
|
||||
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
-webkit-animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.28, 0.84, 0.42, 1));
|
||||
animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.28, 0.84, 0.42, 1)); }
|
||||
|
||||
.fa-fade {
|
||||
-webkit-animation-name: fa-fade;
|
||||
animation-name: fa-fade;
|
||||
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||
animation-delay: var(--fa-animation-delay, 0s);
|
||||
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||
animation-direction: var(--fa-animation-direction, normal);
|
||||
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||
animation-duration: var(--fa-animation-duration, 1s);
|
||||
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
-webkit-animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1));
|
||||
animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1)); }
|
||||
|
||||
.fa-beat-fade {
|
||||
-webkit-animation-name: fa-beat-fade;
|
||||
animation-name: fa-beat-fade;
|
||||
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||
animation-delay: var(--fa-animation-delay, 0s);
|
||||
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||
animation-direction: var(--fa-animation-direction, normal);
|
||||
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||
animation-duration: var(--fa-animation-duration, 1s);
|
||||
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
-webkit-animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1));
|
||||
animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1)); }
|
||||
|
||||
.fa-flip {
|
||||
-webkit-animation-name: fa-flip;
|
||||
animation-name: fa-flip;
|
||||
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||
animation-delay: var(--fa-animation-delay, 0s);
|
||||
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||
animation-direction: var(--fa-animation-direction, normal);
|
||||
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||
animation-duration: var(--fa-animation-duration, 1s);
|
||||
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
-webkit-animation-timing-function: var(--fa-animation-timing, ease-in-out);
|
||||
animation-timing-function: var(--fa-animation-timing, ease-in-out); }
|
||||
|
||||
.fa-shake {
|
||||
-webkit-animation-name: fa-shake;
|
||||
animation-name: fa-shake;
|
||||
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||
animation-delay: var(--fa-animation-delay, 0s);
|
||||
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||
animation-direction: var(--fa-animation-direction, normal);
|
||||
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||
animation-duration: var(--fa-animation-duration, 1s);
|
||||
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
-webkit-animation-timing-function: var(--fa-animation-timing, linear);
|
||||
animation-timing-function: var(--fa-animation-timing, linear); }
|
||||
|
||||
.fa-spin {
|
||||
-webkit-animation-name: fa-spin;
|
||||
animation-name: fa-spin;
|
||||
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||
animation-delay: var(--fa-animation-delay, 0s);
|
||||
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||
animation-direction: var(--fa-animation-direction, normal);
|
||||
-webkit-animation-duration: var(--fa-animation-duration, 2s);
|
||||
animation-duration: var(--fa-animation-duration, 2s);
|
||||
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
-webkit-animation-timing-function: var(--fa-animation-timing, linear);
|
||||
animation-timing-function: var(--fa-animation-timing, linear); }
|
||||
|
||||
.fa-spin-reverse {
|
||||
--fa-animation-direction: reverse; }
|
||||
|
||||
.fa-pulse,
|
||||
.fa-spin-pulse {
|
||||
-webkit-animation-name: fa-spin;
|
||||
animation-name: fa-spin;
|
||||
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||
animation-direction: var(--fa-animation-direction, normal);
|
||||
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||
animation-duration: var(--fa-animation-duration, 1s);
|
||||
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
-webkit-animation-timing-function: var(--fa-animation-timing, steps(8));
|
||||
animation-timing-function: var(--fa-animation-timing, steps(8)); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fa-beat,
|
||||
.fa-bounce,
|
||||
.fa-fade,
|
||||
.fa-beat-fade,
|
||||
.fa-flip,
|
||||
.fa-pulse,
|
||||
.fa-shake,
|
||||
.fa-spin,
|
||||
.fa-spin-pulse {
|
||||
-webkit-animation-delay: -1ms;
|
||||
animation-delay: -1ms;
|
||||
-webkit-animation-duration: 1ms;
|
||||
animation-duration: 1ms;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
-webkit-transition-delay: 0s;
|
||||
transition-delay: 0s;
|
||||
-webkit-transition-duration: 0s;
|
||||
transition-duration: 0s; } }
|
||||
|
||||
@-webkit-keyframes fa-beat {
|
||||
0%, 90% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1); }
|
||||
45% {
|
||||
-webkit-transform: scale(var(--fa-beat-scale, 1.25));
|
||||
transform: scale(var(--fa-beat-scale, 1.25)); } }
|
||||
|
||||
@keyframes fa-beat {
|
||||
0%, 90% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1); }
|
||||
45% {
|
||||
-webkit-transform: scale(var(--fa-beat-scale, 1.25));
|
||||
transform: scale(var(--fa-beat-scale, 1.25)); } }
|
||||
|
||||
@-webkit-keyframes fa-bounce {
|
||||
0% {
|
||||
-webkit-transform: scale(1, 1) translateY(0);
|
||||
transform: scale(1, 1) translateY(0); }
|
||||
10% {
|
||||
-webkit-transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0);
|
||||
transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0); }
|
||||
30% {
|
||||
-webkit-transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em));
|
||||
transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em)); }
|
||||
50% {
|
||||
-webkit-transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0);
|
||||
transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0); }
|
||||
57% {
|
||||
-webkit-transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em));
|
||||
transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em)); }
|
||||
64% {
|
||||
-webkit-transform: scale(1, 1) translateY(0);
|
||||
transform: scale(1, 1) translateY(0); }
|
||||
100% {
|
||||
-webkit-transform: scale(1, 1) translateY(0);
|
||||
transform: scale(1, 1) translateY(0); } }
|
||||
|
||||
@keyframes fa-bounce {
|
||||
0% {
|
||||
-webkit-transform: scale(1, 1) translateY(0);
|
||||
transform: scale(1, 1) translateY(0); }
|
||||
10% {
|
||||
-webkit-transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0);
|
||||
transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0); }
|
||||
30% {
|
||||
-webkit-transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em));
|
||||
transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em)); }
|
||||
50% {
|
||||
-webkit-transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0);
|
||||
transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0); }
|
||||
57% {
|
||||
-webkit-transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em));
|
||||
transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em)); }
|
||||
64% {
|
||||
-webkit-transform: scale(1, 1) translateY(0);
|
||||
transform: scale(1, 1) translateY(0); }
|
||||
100% {
|
||||
-webkit-transform: scale(1, 1) translateY(0);
|
||||
transform: scale(1, 1) translateY(0); } }
|
||||
|
||||
@-webkit-keyframes fa-fade {
|
||||
50% {
|
||||
opacity: var(--fa-fade-opacity, 0.4); } }
|
||||
|
||||
@keyframes fa-fade {
|
||||
50% {
|
||||
opacity: var(--fa-fade-opacity, 0.4); } }
|
||||
|
||||
@-webkit-keyframes fa-beat-fade {
|
||||
0%, 100% {
|
||||
opacity: var(--fa-beat-fade-opacity, 0.4);
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1); }
|
||||
50% {
|
||||
opacity: 1;
|
||||
-webkit-transform: scale(var(--fa-beat-fade-scale, 1.125));
|
||||
transform: scale(var(--fa-beat-fade-scale, 1.125)); } }
|
||||
|
||||
@keyframes fa-beat-fade {
|
||||
0%, 100% {
|
||||
opacity: var(--fa-beat-fade-opacity, 0.4);
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1); }
|
||||
50% {
|
||||
opacity: 1;
|
||||
-webkit-transform: scale(var(--fa-beat-fade-scale, 1.125));
|
||||
transform: scale(var(--fa-beat-fade-scale, 1.125)); } }
|
||||
|
||||
@-webkit-keyframes fa-flip {
|
||||
50% {
|
||||
-webkit-transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg));
|
||||
transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg)); } }
|
||||
|
||||
@keyframes fa-flip {
|
||||
50% {
|
||||
-webkit-transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg));
|
||||
transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg)); } }
|
||||
|
||||
@-webkit-keyframes fa-shake {
|
||||
0% {
|
||||
-webkit-transform: rotate(-15deg);
|
||||
transform: rotate(-15deg); }
|
||||
4% {
|
||||
-webkit-transform: rotate(15deg);
|
||||
transform: rotate(15deg); }
|
||||
8%, 24% {
|
||||
-webkit-transform: rotate(-18deg);
|
||||
transform: rotate(-18deg); }
|
||||
12%, 28% {
|
||||
-webkit-transform: rotate(18deg);
|
||||
transform: rotate(18deg); }
|
||||
16% {
|
||||
-webkit-transform: rotate(-22deg);
|
||||
transform: rotate(-22deg); }
|
||||
20% {
|
||||
-webkit-transform: rotate(22deg);
|
||||
transform: rotate(22deg); }
|
||||
32% {
|
||||
-webkit-transform: rotate(-12deg);
|
||||
transform: rotate(-12deg); }
|
||||
36% {
|
||||
-webkit-transform: rotate(12deg);
|
||||
transform: rotate(12deg); }
|
||||
40%, 100% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
transform: rotate(0deg); } }
|
||||
|
||||
@keyframes fa-shake {
|
||||
0% {
|
||||
-webkit-transform: rotate(-15deg);
|
||||
transform: rotate(-15deg); }
|
||||
4% {
|
||||
-webkit-transform: rotate(15deg);
|
||||
transform: rotate(15deg); }
|
||||
8%, 24% {
|
||||
-webkit-transform: rotate(-18deg);
|
||||
transform: rotate(-18deg); }
|
||||
12%, 28% {
|
||||
-webkit-transform: rotate(18deg);
|
||||
transform: rotate(18deg); }
|
||||
16% {
|
||||
-webkit-transform: rotate(-22deg);
|
||||
transform: rotate(-22deg); }
|
||||
20% {
|
||||
-webkit-transform: rotate(22deg);
|
||||
transform: rotate(22deg); }
|
||||
32% {
|
||||
-webkit-transform: rotate(-12deg);
|
||||
transform: rotate(-12deg); }
|
||||
36% {
|
||||
-webkit-transform: rotate(12deg);
|
||||
transform: rotate(12deg); }
|
||||
40%, 100% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
transform: rotate(0deg); } }
|
||||
|
||||
@-webkit-keyframes fa-spin {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
transform: rotate(0deg); }
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
transform: rotate(360deg); } }
|
||||
|
||||
@keyframes fa-spin {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
transform: rotate(0deg); }
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
transform: rotate(360deg); } }
|
||||
|
||||
.fa-rotate-90 {
|
||||
-webkit-transform: rotate(90deg);
|
||||
transform: rotate(90deg); }
|
||||
|
||||
.fa-rotate-180 {
|
||||
-webkit-transform: rotate(180deg);
|
||||
transform: rotate(180deg); }
|
||||
|
||||
.fa-rotate-270 {
|
||||
-webkit-transform: rotate(270deg);
|
||||
transform: rotate(270deg); }
|
||||
|
||||
.fa-flip-horizontal {
|
||||
-webkit-transform: scale(-1, 1);
|
||||
transform: scale(-1, 1); }
|
||||
|
||||
.fa-flip-vertical {
|
||||
-webkit-transform: scale(1, -1);
|
||||
transform: scale(1, -1); }
|
||||
|
||||
.fa-flip-both,
|
||||
.fa-flip-horizontal.fa-flip-vertical {
|
||||
-webkit-transform: scale(-1, -1);
|
||||
transform: scale(-1, -1); }
|
||||
|
||||
.fa-rotate-by {
|
||||
-webkit-transform: rotate(var(--fa-rotate-angle, none));
|
||||
transform: rotate(var(--fa-rotate-angle, none)); }
|
||||
|
||||
.fa-stack {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
height: 2em;
|
||||
position: relative;
|
||||
width: 2.5em; }
|
||||
|
||||
.fa-stack-1x,
|
||||
.fa-stack-2x {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: var(--fa-stack-z-index, auto); }
|
||||
|
||||
.svg-inline--fa.fa-stack-1x {
|
||||
height: 1em;
|
||||
width: 1.25em; }
|
||||
|
||||
.svg-inline--fa.fa-stack-2x {
|
||||
height: 2em;
|
||||
width: 2.5em; }
|
||||
|
||||
.fa-inverse {
|
||||
color: var(--fa-inverse, #fff); }
|
||||
|
||||
.sr-only,
|
||||
.fa-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0; }
|
||||
|
||||
.sr-only-focusable:not(:focus),
|
||||
.fa-sr-only-focusable:not(:focus) {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0; }
|
||||
|
||||
.svg-inline--fa .fa-primary {
|
||||
fill: var(--fa-primary-color, currentColor);
|
||||
opacity: var(--fa-primary-opacity, 1); }
|
||||
|
||||
.svg-inline--fa .fa-secondary {
|
||||
fill: var(--fa-secondary-color, currentColor);
|
||||
opacity: var(--fa-secondary-opacity, 0.4); }
|
||||
|
||||
.svg-inline--fa.fa-swap-opacity .fa-primary {
|
||||
opacity: var(--fa-secondary-opacity, 0.4); }
|
||||
|
||||
.svg-inline--fa.fa-swap-opacity .fa-secondary {
|
||||
opacity: var(--fa-primary-opacity, 1); }
|
||||
|
||||
.svg-inline--fa mask .fa-primary,
|
||||
.svg-inline--fa mask .fa-secondary {
|
||||
fill: black; }
|
||||
|
||||
.fad.fa-inverse,
|
||||
.fa-duotone.fa-inverse {
|
||||
color: var(--fa-inverse, #fff); }
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user