1
0
mirror of https://gitlab.com/animath/si/plateforme.git synced 2025-06-23 00:38:25 +02:00

Compare commits

...

93 Commits

Author SHA1 Message Date
5e2add90a8 Minify CSS and JavaScript files
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-02 19:47:35 +02:00
635606eb13 Add inscriptions.tfjm.org as valid DNS
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-05-29 23:35:44 +02:00
b828631106 Add french comments on chat application
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-05-26 22:08:34 +02:00
8216e0943f Don't display final selection in the final tournament page
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-05-20 16:06:40 +02:00
1138885fb4 Fix TFJM sympa lists every day instead of every two minutes
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-05-19 21:18:58 +02:00
a43dc9c12a Fix total score in tfjm.org export for 5-teams pools
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-05-19 21:09:34 +02:00
70050827d8 Better bold lines in tfjm.org export
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-05-19 21:02:44 +02:00
f687deed14 Fix bold lines in tfjm.org export
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-05-19 20:55:47 +02:00
7a0341e7cf Display mention on tfjm.org page
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-05-19 20:40:35 +02:00
0129e32643 Messages in team validation mails now contains line breaks
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-30 20:29:52 +02:00
64a2ea007e Add basic Markdown rules for the chat
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-30 20:20:10 +02:00
531eecf4b8 Make consistent the right alignment and the column structure
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 19:51:52 +02:00
bd416318ac Fix unread messages count
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:35 +02:00
90bec6bf5e Remove debug code
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:34 +02:00
ed5944e044 Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:34 +02:00
a41c17576f Store last visited channel in local storage
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:34 +02:00
80456f4da8 Add sort by unread messages option
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:34 +02:00
1a641cb2d7 Store what messages are read
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:34 +02:00
8f3929875f Improve context menus
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:34 +02:00
f26f102650 Automatically create appropriated channels when tournaments/pools/participations are updated
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:34 +02:00
1e5d0ebcfc Editing and deleting is working
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:33 +02:00
0cab21f344 Users can only edit & delete their own messages (except for admin users)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:33 +02:00
a771710094 Add popovers to edit and delete messages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:33 +02:00
3b3dcff28b Only give the focus to a private channel if it wasn't previously created
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:33 +02:00
d6aa5eb0cc Manage private chats
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:33 +02:00
c6b9a84def Reset retry delay to 1 second when a connection has succeeded
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:33 +02:00
675f19492c Extend session cookie age from 3 hours to 2 weeks
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:32 +02:00
a5c210e9b6 Add script to create channels per tournament, pools and teams. Put channels in categories
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:32 +02:00
784002c085 Open channels list by swiping
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:32 +02:00
e77cc558de Add specific login and logout pages for chat
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:32 +02:00
7bb0f78f34 Improve mobile chat
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:32 +02:00
bfd1a76a2d Notifications use the PNG logo
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:32 +02:00
b86dfe7351 Automatically scroll to bottom
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:32 +02:00
d36e97fa2e Chat is restricted to authenticated users
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:31 +02:00
181bb86e49 Simplify chat views
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:31 +02:00
a121d1042b Add feature to install chat on the home screen
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:31 +02:00
2d706b2b81 Add fullscreen mode for chat
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:31 +02:00
ca91842c2d Fill channel selector using JavaScript
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:31 +02:00
d617dd77c1 Properly sort messages and add fetch previous messages ability
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:31 +02:00
d59bb75dce Fetching last messages is working
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:30 +02:00
4a78e80399 Send messages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:30 +02:00
f3a4a99b78 Setup chat UI
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:30 +02:00
46fc5f39c8 Allow to impersonate user on draw interface
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:30 +02:00
b464e7df1d Manage channels permissions
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:30 +02:00
7498677bbd Permissions are strings, not integers
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:30 +02:00
ea8007aa07 Initialize chat interface
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:29 +02:00
d9bb0a0860 Prepare models for new chat feature
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:29 +02:00
a594b268ea Fix permission to download all authorizations of a tournament
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-25 12:42:37 +02:00
0bc5ef0a7f Add debug feature for problem draw, useful for final tournament
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-22 23:36:52 +02:00
943276ef71 Round is an integer
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-21 07:46:20 +02:00
13c815c62c Allow to parse empty mentions
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-20 21:43:37 +02:00
35e3be8af3 Fix one translation activation before parsing notes
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-20 21:38:33 +02:00
720de380d1 Tweaks are done in the pool of the first room
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-20 21:37:37 +02:00
ecf80f8b81 Use french translation when submitting notes to Google Sheets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-20 16:16:50 +02:00
3ca0148934 Update information about draw with the 2024 changes
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-19 19:02:11 +02:00
58608ea5ff Add red background if the defender has at least one penalty
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-19 18:51:13 +02:00
68da61a33b Fix script that generates data for second teams when there are 5 teams in the pool
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-19 18:38:19 +02:00
86e978faf2 Don't display ranking in notation ODS when there are 5 teams
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-19 18:30:59 +02:00
0845d0bfb6 Since a notation sheet has at most 4 passages, reduce the number of columns to 26
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-18 15:41:27 +02:00
f457a2355e Display scores of all teams in a 5-teams pool
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-18 15:22:59 +02:00
bacdd5cfcf Replace pool name by its short name in severous views
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-18 15:12:45 +02:00
3e24e10780 Fix information display for participants in 5-teams pools
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-18 15:07:15 +02:00
adc4634f3e Better pool view for 5-teams pools
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-18 15:05:10 +02:00
266afaf5c9 Split 5-teams pols in two pools for each room
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-18 14:53:58 +02:00
059cae75c5 Fix notation sheets when we change the order of pools
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-17 22:07:47 +02:00
91a1837c99 Fix 5-teams pools passages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-17 21:56:46 +02:00
b24201c529 Rapporteure => Rapportrice
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-17 00:58:56 +02:00
53302db56a Display mentions only after the reveal of the notes of the second round
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-17 00:43:42 +02:00
49fda3df49 Add mentions
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-17 00:38:18 +02:00
3a0a98a331 Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-17 00:02:48 +02:00
21c4d5d7f5 Exchange first and last teams if there is only one pool (event if there are only 3 or 4 teams)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-17 00:02:02 +02:00
338a19ec32 Remove observer status
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-16 23:59:18 +02:00
5bfcaab831 Fix scale for reporter
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>

Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-16 13:21:42 +02:00
49e5d97ec9 Generate spreadsheet with all teams at the second place
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-14 09:17:34 +02:00
0e185f5046 Add trigrams in column headers in Google Sheets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-13 20:15:07 +02:00
ab7cdd56cc Update scale in passage detail view
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-13 20:08:47 +02:00
7edd43f626 Rapporteure => Rapportrice
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-13 12:48:13 +02:00
aca23eaf8b Fix under 18 calculus
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-09 17:45:41 +02:00
a02697a3a7 Use local time for channel ids
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-08 00:03:10 +02:00
d3d72e090c Fix tournament detail view for anonymous users
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 18:31:59 +02:00
6c76f1e633 Fix final selection
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 18:30:06 +02:00
4a094002f0 Fix under_18 calculus for students that are born on the February 29th
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 16:42:05 +02:00
3045857897 There is no fixture to load
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 13:46:03 +02:00
7a0b93b151 Send email after team final selection
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 13:39:44 +02:00
7073f64aa6 Duplicate solutions from regional tournament to final tournament after selection
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 12:54:16 +02:00
b4fc976197 Display informations about the final tournament in the sidebar
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 12:38:41 +02:00
7a004596ca Only display final selection after publishing results
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 12:09:31 +02:00
1493df0078 Implement final selection
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 11:41:14 +02:00
7732a737bb Use local date for GDrive channel ids
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 09:39:17 +02:00
b942baea17 Support ODS and CSV formats to read notes from a spreadsheet
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 09:34:52 +02:00
188b83ce2d Fix tournament prefetch related in GSheet notifications
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 00:21:20 +02:00
29d9432ca2 Order passages by position rather than id
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 23:34:06 +02:00
0181a1392d Guess the CSV delimiter when uploading a notation sheet
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 23:08:35 +02:00
99 changed files with 5203 additions and 1294 deletions

View File

@ -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
View File

@ -0,0 +1,2 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later

28
chat/admin.py Normal file
View 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
View 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
View 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']})

View File

View File

View 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,
),
)

View 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",),
},
),
]

View File

@ -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",
),
),
]

View 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",
),
),
]

View File

@ -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",
),
),
]

View File

@ -0,0 +1,2 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later

365
chat/models.py Normal file
View 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
View 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,
),
)

View 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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
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
})

View 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 %}

View 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'utilisateurrice connectée afin de pouvoir effectuer des tests plus tard. #}
const IS_ADMIN = {{ request.user.registration.is_admin|yesno:"true,false" }}
</script>

View 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>

View 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
View File

@ -0,0 +1,2 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later

18
chat/urls.py Normal file
View 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'),
]

View File

@ -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"

View File

@ -9,6 +9,7 @@ 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 _
@ -44,6 +45,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
We accept only if this is a user of a team of the associated tournament, or a volunteer
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']
@ -456,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()
@ -502,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()
@ -636,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
@ -936,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&nbsp;% sur le coefficient de l'oral de la défense."
self.tournament.draw.last_message = msg
await self.tournament.draw.asave()
@ -1018,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()

View 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",
},
),
]

View File

@ -148,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':
@ -292,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]:
@ -361,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:
@ -378,28 +389,32 @@ 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):
@ -517,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}"),
@ -530,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',)

View File

@ -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()),
]

View File

@ -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)

View File

@ -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 %}

View File

@ -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>

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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
@ -241,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',
@ -304,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
@ -329,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):
@ -360,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', )

View File

@ -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>")

View 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)

View File

@ -45,7 +45,7 @@ class Command(BaseCommand):
self.style.WARNING(f"No spreadsheet found for {tournament}. Please create it first"))
continue
channel_id = sha1(f"{tournament.name}-{timezone.now().date()}-{site.domain}".encode()).hexdigest()
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}"

View File

@ -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",
),
]

View File

@ -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)"
),
),
]

View File

@ -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",
),
),
]

View File

@ -10,7 +10,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator, RegexVa
from django.db import models
from django.db.models import Index
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils import timezone, translation
from django.utils.crypto import get_random_string
from django.utils.text import format_lazy
from django.utils.timezone import localtime
@ -430,7 +430,9 @@ class Tournament(models.Model):
self.notes_sheet_id = spreadsheet.id
self.save()
def update_ranking_spreadsheet(self):
def update_ranking_spreadsheet(self): # noqa: C901
translation.activate('fr')
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
spreadsheet = gc.open_by_key(self.notes_sheet_id)
worksheets = spreadsheet.worksheets()
@ -444,27 +446,35 @@ class Tournament(models.Model):
header = [["Équipe", "Score jour 1", "Harmonisation 1", "Score jour 2", "Harmonisation 2", "Total", "Rang"]]
lines = []
participations = self.participations.filter(pools__round=1, pools__tournament=self).all()
participations = self.participations.filter(pools__round=1, pools__tournament=self).distinct().all()
for i, participation in enumerate(participations):
line = [f"{participation.team.name} ({participation.team.trigram})"]
lines.append(line)
pool1 = self.pools.get(round=1, participations=participation)
passage1 = pool1.passages.get(defender=participation)
passage1 = Passage.objects.get(pool__tournament=self, pool__round=1, defender=participation)
pool1 = passage1.pool
if pool1.participations.count() != 5:
position1 = passage1.position
else:
position1 = (passage1.position - 1) * 2 + pool1.room
tweak1_qs = Tweak.objects.filter(pool=pool1, participation=participation)
tweak1 = tweak1_qs.get() if tweak1_qs.exists() else None
line.append(f"=SIERREUR('Poule {pool1.short_name}'!$D{pool1.juries.count() + 10 + passage1.position}; 0)")
line.append(f"=SIERREUR('Poule {pool1.short_name}'!$D{pool1.juries.count() + 10 + position1}; 0)")
line.append(tweak1.diff if tweak1 else 0)
if self.pools.filter(round=2, participations=participation).exists():
pool2 = self.pools.get(round=2, participations=participation)
passage2 = pool2.passages.get(defender=participation)
if Passage.objects.filter(pool__tournament=self, pool__round=2, defender=participation).exists():
passage2 = Passage.objects.get(pool__tournament=self, pool__round=2, defender=participation)
pool2 = passage2.pool
if pool2.participations.count() != 5:
position2 = passage2.position
else:
position2 = (passage2.position - 1) * 2 + pool2.room
tweak2_qs = Tweak.objects.filter(pool=pool2, participation=participation)
tweak2 = tweak2_qs.get() if tweak2_qs.exists() else None
line.append(
f"=SIERREUR('Poule {pool2.short_name}'!$D{pool2.juries.count() + 10 + passage2.position}; 0)")
f"=SIERREUR('Poule {pool2.short_name}'!$D{pool2.juries.count() + 10 + position2}; 0)")
line.append(tweak2.diff if tweak2 else 0)
else:
# User has no second pool yet
@ -474,21 +484,32 @@ class Tournament(models.Model):
line.append(f"=$B{i + 2} + $C{i + 2} + $D{i + 2} + E{i + 2}")
line.append(f"=RANG($F{i + 2}; $F$2:$F${participations.count() + 1})")
final_ranking = [["", "", ""], ["", "", ""], ["Équipe", "Score", "Rang"],
final_ranking = [["", "", "", ""], ["", "", "", ""], ["Équipe", "Score", "Rang", "Mention"],
[f"=SORT($A$2:$A${participations.count() + 1}; "
f"$F$2:$F${participations.count() + 1}; FALSE)",
f"=SORT($F$2:$F${participations.count() + 1}; "
f"$F$2:$F${participations.count() + 1}; FALSE)",
f"=SORT($G$2:$G${participations.count() + 1}; "
f"$F$2:$F${participations.count() + 1}; FALSE)", ]]
final_ranking += [["", "", ""] for _i in range(participations.count() - 1)]
notes = dict()
for participation in self.participations.filter(valid=True).all():
note = sum(pool.average(participation) for pool in self.pools.filter(participations=participation).all())
if note:
notes[participation] = note
sorted_notes = sorted(notes.items(), key=lambda x: x[1], reverse=True)
for i, (participation, _note) in enumerate(sorted_notes):
final_ranking[i + 3].append(participation.mention if not self.final else participation.mention_final)
data = header + lines + final_ranking
worksheet.update(data, f"A1:G{participations.count() + 5}", raw=False)
worksheet.update(data, f"A1:G{2 * participations.count() + 4}", raw=False)
format_requests = []
# Set the width of the columns
column_widths = [("A", 300), ("B", 120), ("C", 120), ("D", 120), ("E", 120), ("F", 120), ("G", 120)]
column_widths = [("A", 300), ("B", 150), ("C", 150), ("D", 150), ("E", 150), ("F", 150), ("G", 150)]
for column, width in column_widths:
grid_range = a1_range_to_grid_range(column, worksheet.id)
format_requests.append({
@ -507,9 +528,9 @@ class Tournament(models.Model):
})
# Set borders
border_ranges = [("A1:AF", "0000"),
border_ranges = [("A1:Z", "0000"),
(f"A1:G{participations.count() + 1}", "1111"),
(f"A{participations.count() + 4}:C{2 * participations.count() + 4}", "1111")]
(f"A{participations.count() + 4}:D{2 * participations.count() + 4}", "1111")]
sides_names = ['top', 'bottom', 'left', 'right']
styles = ["NONE", "SOLID", "SOLID_MEDIUM", "SOLID_THICK", "DOUBLE"]
for border_range, sides in border_ranges:
@ -530,8 +551,8 @@ class Tournament(models.Model):
})
# Make titles bold
bold_ranges = [("A1:AF", False), ("A1:G1", True),
(f"A{participations.count() + 4}:C{participations.count() + 4}", True)]
bold_ranges = [("A1:Z", False), ("A1:G1", True),
(f"A{participations.count() + 4}:D{participations.count() + 4}", True)]
for bold_range, bold in bold_ranges:
format_requests.append({
"repeatCell": {
@ -542,14 +563,14 @@ class Tournament(models.Model):
})
# Set background color for headers and footers
bg_colors = [("A1:AF", (1, 1, 1)),
bg_colors = [("A1:Z", (1, 1, 1)),
("A1:G1", (0.8, 0.8, 0.8)),
(f"A2:B{participations.count() + 1}", (0.9, 0.9, 0.9)),
(f"C2:C{participations.count() + 1}", (1, 1, 1)),
(f"D2:D{participations.count() + 1}", (0.9, 0.9, 0.9)),
(f"E2:E{participations.count() + 1}", (1, 1, 1)),
(f"F2:G{participations.count() + 1}", (0.9, 0.9, 0.9)),
(f"A{participations.count() + 4}:C{participations.count() + 4}", (0.8, 0.8, 0.8)),
(f"A{participations.count() + 4}:D{participations.count() + 4}", (0.8, 0.8, 0.8)),
(f"A{participations.count() + 5}:C{2 * participations.count() + 4}", (0.9, 0.9, 0.9)),]
for bg_range, bg_color in bg_colors:
r, g, b = bg_color
@ -625,7 +646,7 @@ class Tournament(models.Model):
for line in data:
trigram = line[0][-4:-1]
participation = self.participations.get(team__trigram=trigram)
pool1 = self.pools.get(round=1, participations=participation)
pool1 = self.pools.get(round=1, participations=participation, room=1)
tweak1_qs = Tweak.objects.filter(pool=pool1, participation=participation)
tweak1_nb = int(line[2])
if not tweak1_nb:
@ -636,7 +657,7 @@ class Tournament(models.Model):
'participation': participation})
if self.pools.filter(round=2, participations=participation).exists():
pool2 = self.pools.get(round=2, participations=participation)
pool2 = self.pools.get(round=2, participations=participation, room=1)
tweak2_qs = Tweak.objects.filter(pool=pool2, participation=participation)
tweak2_nb = int(line[4])
if not tweak2_nb:
@ -646,6 +667,22 @@ class Tournament(models.Model):
create_defaults={'diff': tweak2_nb, 'pool': pool2,
'participation': participation})
nb_participations = self.participations.filter(valid=True).count()
mentions = worksheet.get_values(f"A{score_cell.row + 1}:D{score_cell.row + nb_participations}")
notes = dict()
for participation in self.participations.filter(valid=True).all():
note = sum(pool.average(participation) for pool in self.pools.filter(participations=participation).all())
if note:
notes[participation] = note
sorted_notes = sorted(notes.items(), key=lambda x: x[1], reverse=True)
for i, (participation, _note) in enumerate(sorted_notes):
mention = mentions[i][3] if len(mentions[i]) >= 4 else ""
if not self.final:
participation.mention = mention
else:
participation.mention_final = mention
participation.save()
def get_absolute_url(self):
return reverse_lazy("participation:tournament_detail", args=(self.pk,))
@ -693,6 +730,20 @@ class Participation(models.Model):
help_text=_("The team is selected for the final tournament."),
)
mention = models.CharField(
verbose_name=_("mention"),
max_length=255,
blank=True,
default="",
)
mention_final = models.CharField(
verbose_name=_("mention (final)"),
max_length=255,
blank=True,
default="",
)
def get_absolute_url(self):
return reverse_lazy("participation:participation_detail", args=(self.pk,))
@ -718,38 +769,62 @@ class Participation(models.Model):
'content': content,
})
if timezone.now() <= self.tournament.solution_limit + timedelta(hours=2):
text = _("<p>The solutions for the tournament of {tournament} are due on the {date:%Y-%m-%d %H:%M}.</p>"
"<p>You have currently sent <strong>{nb_solutions}</strong> solutions. "
"We suggest to send at least <strong>{min_solutions}</strong> different solutions.</p>"
"<p>You can upload your solutions on <a href='{url}'>your participation page</a>.</p>")
url = reverse_lazy("participation:participation_detail", args=(self.pk,))
content = format_lazy(text, tournament=self.tournament.name, date=localtime(self.tournament.solution_limit),
nb_solutions=self.solutions.count(), min_solutions=len(settings.PROBLEMS) - 3,
url=url)
informations.append({
'title': _("Solutions due"),
'type': "info",
'priority': 1,
'content': content,
})
elif timezone.now() <= self.tournament.solutions_draw + timedelta(hours=2):
if self.tournament:
informations.extend(self.informations_for_tournament(self.tournament))
if self.final:
informations.extend(self.informations_for_tournament(Tournament.final_tournament()))
return informations
def informations_for_tournament(self, tournament) -> list[dict]:
informations = []
if timezone.now() <= tournament.solution_limit + timedelta(hours=2):
if not tournament.final:
text = _("<p>The solutions for the tournament of {tournament} are due on the {date:%Y-%m-%d %H:%M}.</p>"
"<p>You have currently sent <strong>{nb_solutions}</strong> solutions. "
"We suggest to send at least <strong>{min_solutions}</strong> different solutions.</p>"
"<p>You can upload your solutions on <a href='{url}'>your participation page</a>.</p>")
url = reverse_lazy("participation:participation_detail", args=(self.pk,))
content = format_lazy(text, tournament=tournament.name, date=localtime(tournament.solution_limit),
nb_solutions=self.solutions.filter(final_solution=False).count(),
min_solutions=len(settings.PROBLEMS) - 3,
url=url)
informations.append({
'title': _("Solutions due"),
'type': "info",
'priority': 1,
'content': content,
})
else:
text = _("<p>The solutions for the tournament of {tournament} are due on the {date:%Y-%m-%d %H:%M}.</p>"
"<p>Remember that you can only fix minor changes to your solutions "
"without adding new parts.</p>"
"<p>You can upload your solutions on <a href='{url}'>your participation page</a>.</p>")
url = reverse_lazy("participation:participation_detail", args=(self.pk,))
content = format_lazy(text, tournament=tournament.name, date=localtime(tournament.solution_limit),
url=url)
informations.append({
'title': _("Solutions due"),
'type': "info",
'priority': 1,
'content': content,
})
elif timezone.now() <= tournament.solutions_draw + timedelta(hours=2):
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=self.tournament.name,
date=localtime(self.tournament.solutions_draw), url=url)
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,
})
elif timezone.now() <= self.tournament.syntheses_first_phase_limit + timedelta(hours=2):
pool = self.pools.get(round=1, tournament=self.tournament)
defender_passage = pool.passages.get(defender=self)
opponent_passage = pool.passages.get(opponent=self)
reporter_passage = pool.passages.get(reporter=self)
elif timezone.now() <= tournament.syntheses_first_phase_limit + timedelta(hours=2):
defender_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=1, defender=self)
opponent_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=1, opponent=self)
reporter_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=1, reporter=self)
defender_text = _("<p>The solutions draw is ended. You can check the result on "
"<a href='{draw_url}'>this page</a>.</p>"
@ -790,11 +865,10 @@ class Participation(models.Model):
'priority': 1,
'content': content,
})
elif timezone.now() <= self.tournament.syntheses_second_phase_limit + timedelta(hours=2):
pool = self.pools.get(round=2, tournament=self.tournament)
defender_passage = pool.passages.get(defender=self)
opponent_passage = pool.passages.get(opponent=self)
reporter_passage = pool.passages.get(reporter=self)
elif timezone.now() <= tournament.syntheses_second_phase_limit + timedelta(hours=2):
defender_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=2, defender=self)
opponent_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=2, opponent=self)
reporter_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=2, reporter=self)
defender_text = _("<p>For the second round, you will defend "
"<a href='{solution_url}'>your solution of the problem {problem}</a>.</p>")
@ -833,11 +907,11 @@ class Participation(models.Model):
'priority': 1,
'content': content,
})
elif not self.final:
elif not self.final or tournament.final:
text = _("<p>The tournament {tournament} is ended. You can check the results on the "
"<a href='{url}'>tournament page</a>.</p>")
url = reverse_lazy("participation:tournament_detail", args=(self.tournament.pk,))
content = format_lazy(text, tournament=self.tournament.name, url=url)
url = reverse_lazy("participation:tournament_detail", args=(tournament.pk,))
content = format_lazy(text, tournament=tournament.name, url=url)
informations.append({
'title': _("Tournament ended"),
'type': "info",
@ -870,13 +944,23 @@ class Pool(models.Model):
)
letter = models.PositiveSmallIntegerField(
verbose_name=_('letter'),
choices=[
(1, 'A'),
(2, 'B'),
(3, 'C'),
(4, 'D'),
],
verbose_name=_('letter'),
)
room = models.PositiveSmallIntegerField(
verbose_name=_("room"),
choices=[
(1, _("Room 1")),
(2, _("Room 2")),
],
default=1,
help_text=_("For 5-teams pools only"),
)
participations = models.ManyToManyField(
@ -917,7 +1001,10 @@ class Pool(models.Model):
@property
def short_name(self):
return f"{self.get_letter_display()}{self.round}"
short_name = f"{self.get_letter_display()}{self.round}"
if self.participations.count() == 5:
short_name += f"{self.get_room_display()}"
return short_name
@property
def solutions(self):
@ -940,6 +1027,8 @@ class Pool(models.Model):
return super().validate_constraints()
def update_spreadsheet(self): # noqa: C901
translation.activate('fr')
# Create tournament sheet if it does not exist
self.tournament.create_spreadsheet()
@ -947,24 +1036,24 @@ class Pool(models.Model):
spreadsheet = gc.open_by_key(self.tournament.notes_sheet_id)
worksheets = spreadsheet.worksheets()
if f"Poule {self.short_name}" not in [ws.title for ws in worksheets]:
worksheet = spreadsheet.add_worksheet(f"Poule {self.short_name}", 100, 32)
worksheet = spreadsheet.add_worksheet(f"Poule {self.short_name}", 100, 26)
else:
worksheet = spreadsheet.worksheet(f"Poule {self.short_name}")
if any(ws.title == "Sheet1" for ws in worksheets):
spreadsheet.del_worksheet(spreadsheet.worksheet("Sheet1"))
pool_size = self.participations.count()
passage_width = 7 if pool_size == 4 else 6
passage_width = 6
passages = self.passages.all()
header = [
sum(([f"Problème {passage.solution_number}"] + (passage_width - 1) * [""]
for passage in passages), start=["Problème", ""]),
sum((["Défenseur⋅se", "", "Opposant⋅e", "", "Rapporteur⋅rice", ""]
+ (["Observateur⋅rice"] if pool_size == 4 else [])
for _passage in passages), start=["Rôle", ""]),
sum(([f"Défenseur⋅se ({passage.defender.team.trigram})", "",
f"Opposant⋅e ({passage.opponent.team.trigram})", "",
f"Rapporteur⋅rice ({passage.reporter.team.trigram})", ""]
for passage in passages), start=["Rôle", ""]),
sum((["Écrit (/20)", "Oral (/20)", "Écrit (/10)", "Oral (/10)", "Écrit (/10)", "Oral (/10)"]
+ (["Oral (± 4)"] if pool_size == 4 else [])
for _passage in passages), start=["Juré⋅e", ""]),
]
@ -975,8 +1064,6 @@ class Pool(models.Model):
note = passage.notes.filter(jury=jury).first()
line.extend([note.defender_writing, note.defender_oral, note.opponent_writing, note.opponent_oral,
note.reporter_writing, note.reporter_oral])
if pool_size == 4:
line.append(note.observer_oral)
notes.append(line)
notes.append([]) # Add empty line to ensure pretty design
@ -989,10 +1076,10 @@ class Pool(models.Model):
return getcol((number - 1) // 26) + chr(65 + (number - 1) % 26)
average = ["Moyenne", ""]
coeffs = sum(([1, 1.6 - 0.4 * passage.defender_penalties, 0.9, 2, 0.9, 1]
+ ([1] if pool_size == 4 else []) for passage in passages), start=["Coefficient", ""])
coeffs = sum(([1, 1.6 - 0.4 * passage.defender_penalties, 0.9, 2, 0.9, 1] for passage in passages),
start=["Coefficient", ""])
subtotal = ["Sous-total", ""]
footer = [average, coeffs, subtotal, 32 * [""]]
footer = [average, coeffs, subtotal, 26 * [""]]
min_row = 5
max_row = min_row + self.juries.count()
@ -1018,55 +1105,47 @@ class Pool(models.Model):
subtotal.extend([f"={rep_w_col}{max_row + 1} * {rep_w_col}{max_row + 2}"
f" + {rep_o_col}{max_row + 1} * {rep_o_col}{max_row + 2}", ""])
if pool_size == 4:
obs_col = getcol(min_column + passage_width * i + 6)
subtotal.append(f"={obs_col}{max_row + 1} * {obs_col}{max_row + 2}")
ranking = [
["Équipe", "", "Problème", "Total", "Rang"],
]
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],
]
for passage in passages:
all_passages = Passage.objects.filter(pool__tournament=self.tournament,
pool__round=self.round,
pool__letter=self.letter).order_by('position', 'pool__room')
for i, passage in enumerate(all_passages):
participation = passage.defender
passage_line = passage_matrix[passage.position - 1]
defender_passage = Passage.objects.get(defender=participation,
pool__tournament=self.tournament, pool__round=self.round)
defender_row = 5 + defender_passage.pool.juries.count()
defender_col = defender_passage.position - 1
opponent_passage = Passage.objects.get(opponent=participation,
pool__tournament=self.tournament, pool__round=self.round)
opponent_row = 5 + opponent_passage.pool.juries.count()
opponent_col = opponent_passage.position - 1
reporter_passage = Passage.objects.get(reporter=participation,
pool__tournament=self.tournament, pool__round=self.round)
reporter_row = 5 + reporter_passage.pool.juries.count()
reporter_col = reporter_passage.position - 1
formula = "="
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)
formula += (f"'Poule {defender_passage.pool.short_name}'"
f"!{getcol(min_column + defender_col * passage_width)}{defender_row + 3}") # Defender
formula += (f" + 'Poule {opponent_passage.pool.short_name}'"
f"!{getcol(min_column + opponent_col * passage_width + 2)}{opponent_row + 3}") # Opponent
formula += (f" + 'Poule {reporter_passage.pool.short_name}'"
f"!{getcol(min_column + reporter_col * passage_width + 4)}{reporter_row + 3}") # Reporter
ranking.append([f"{participation.team.name} ({participation.team.trigram})", "",
f"=${getcol(3 + (passage.position - 1) * passage_width)}$1", formula,
f"=RANG(D{max_row + 5 + passage.position}; "
f"='Poule {defender_passage.pool.short_name}'"
f"!${getcol(3 + defender_col * passage_width)}$1",
formula,
f"=RANG(D{max_row + 6 + i}; "
f"D${max_row + 6}:D${max_row + 5 + pool_size})"])
all_values = header + notes + footer + ranking
worksheet.batch_clear([f"A1:AF{max_row + 5 + pool_size}"])
worksheet.update("A1:AF", all_values, raw=False)
worksheet.batch_clear([f"A1:Z{max_row + 5 + pool_size}"])
worksheet.update("A1:Z", all_values, raw=False)
format_requests = []
@ -1092,13 +1171,13 @@ class Pool(models.Model):
for i in range(pool_size + 1):
merge_cells.append(f"A{max_row + 5 + i}:B{max_row + 5 + i}")
format_requests.append({"unmergeCells": {"range": a1_range_to_grid_range("A1:AF", worksheet.id)}})
format_requests.append({"unmergeCells": {"range": a1_range_to_grid_range("A1:Z", worksheet.id)}})
for name in merge_cells:
grid_range = a1_range_to_grid_range(name, worksheet.id)
format_requests.append({"mergeCells": {"mergeType": MergeType.merge_all, "range": grid_range}})
# Make titles bold
bold_ranges = [("A1:AF", False), ("A1:AF3", True),
bold_ranges = [("A1:Z", False), ("A1:Z3", True),
(f"A{max_row + 1}:B{max_row + 3}", True), (f"A{max_row + 5}:E{max_row + 5}", True)]
for bold_range, bold in bold_ranges:
format_requests.append({
@ -1110,13 +1189,16 @@ class Pool(models.Model):
})
# Set background color for headers and footers
bg_colors = [("A1:AF", (1, 1, 1)),
(f"A1:{getcol(2 + pool_size * passage_width)}3", (0.8, 0.8, 0.8)),
bg_colors = [("A1:Z", (1, 1, 1)),
(f"A1:{getcol(2 + passages.count() * passage_width)}3", (0.8, 0.8, 0.8)),
(f"A{min_row - 1}:B{max_row}", (0.95, 0.95, 0.95)),
(f"A{max_row + 1}:B{max_row + 3}", (0.8, 0.8, 0.8)),
(f"C{max_row + 1}:{getcol(2 + pool_size * passage_width)}{max_row + 3}", (0.9, 0.9, 0.9)),
(f"C{max_row + 1}:{getcol(2 + passages.count() * passage_width)}{max_row + 3}", (0.9, 0.9, 0.9)),
(f"A{max_row + 5}:E{max_row + 5}", (0.8, 0.8, 0.8)),
(f"A{max_row + 6}:E{max_row + 5 + pool_size}", (0.9, 0.9, 0.9)),]
# Display penalties in red
bg_colors += [(f"{getcol(2 + (passage.position - 1) * passage_width + 2)}{max_row + 2}", (1.0, 0.7, 0.7))
for passage in self.passages.filter(defender_penalties__gte=1).all()]
for bg_range, bg_color in bg_colors:
r, g, b = bg_color
format_requests.append({
@ -1146,8 +1228,6 @@ class Pool(models.Model):
for passage in passages:
column_widths.append((f"{getcol(3 + passage_width * (passage.position - 1))}"
f":{getcol(8 + passage_width * (passage.position - 1))}", 75))
if pool_size == 4:
column_widths.append((getcol(9 + passage_width * (passage.position - 1)), 120))
for column, width in column_widths:
grid_range = a1_range_to_grid_range(column, worksheet.id)
format_requests.append({
@ -1198,12 +1278,12 @@ class Pool(models.Model):
})
# Define borders
border_ranges = [("A1:AF", "0000"),
(f"A1:{getcol(2 + pool_size * passage_width)}{max_row + 3}", "1111"),
border_ranges = [("A1:Z", "0000"),
(f"A1:{getcol(2 + passages.count() * passage_width)}{max_row + 3}", "1111"),
(f"A{max_row + 5}:E{max_row + pool_size + 5}", "1111"),
(f"A1:B{max_row + 3}", "1113"),
(f"C1:{getcol(2 + (pool_size - 1) * passage_width)}1", "1113")]
for i in range(pool_size - 1):
(f"C1:{getcol(2 + (passages.count() - 1) * passage_width)}1", "1113")]
for i in range(passages.count() - 1):
border_ranges.append((f"{getcol(1 + (i + 1) * passage_width)}2"
f":{getcol(2 + (i + 1) * passage_width)}2", "1113"))
border_ranges.append((f"{getcol(2 + (i + 1) * passage_width)}3"
@ -1230,11 +1310,11 @@ class Pool(models.Model):
})
# Add range conditions
for i in range(pool_size):
for i in range(passages.count()):
for j in range(passage_width):
column = getcol(min_column + i * passage_width + j)
min_note = 0 if j < 6 else -4
max_note = 20 if j < 2 else 10 if j < 6 else 4
min_note = 0
max_note = 20 if j < 2 else 10
format_requests.append({
"setDataValidation": {
"range": a1_range_to_grid_range(f"{column}{min_row - 1}:{column}{max_row}", worksheet.id),
@ -1252,9 +1332,9 @@ class Pool(models.Model):
})
# Set number format, display only one decimal
number_format_ranges = [f"C{max_row + 1}:{getcol(2 + passage_width * pool_size)}{max_row + 1}",
f"C{max_row + 3}:{getcol(2 + passage_width * pool_size)}{max_row + 3}",
f"D{max_row + 6}:D{max_row + 5 + pool_size}",]
number_format_ranges = [f"C{max_row + 1}:{getcol(2 + passage_width * passages.count())}{max_row + 1}",
f"C{max_row + 3}:{getcol(2 + passage_width * passages.count())}{max_row + 3}",
f"D{max_row + 6}:D{max_row + 5 + passages.count()}",]
for number_format_range in number_format_ranges:
format_requests.append({
"repeatCell": {
@ -1273,9 +1353,9 @@ class Pool(models.Model):
})
# Protect the header, the juries list, the footer and the ranking
protected_ranges = ["A1:AF4",
protected_ranges = ["A1:Z4",
f"A{min_row}:B{max_row}",
f"A{max_row}:AF{max_row + 5 + pool_size}"]
f"A{max_row}:Z{max_row + 5 + pool_size}"]
for protected_range in protected_ranges:
format_requests.append({
"addProtectedRange": {
@ -1292,6 +1372,8 @@ class Pool(models.Model):
worksheet.client.batch_update(spreadsheet.id, body)
def update_juries_lines_spreadsheet(self):
translation.activate('fr')
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
spreadsheet = gc.open_by_key(self.tournament.notes_sheet_id)
worksheet = spreadsheet.worksheet(f"Poule {self.short_name}")
@ -1311,6 +1393,8 @@ class Pool(models.Model):
max_row += 1
def parse_spreadsheet(self):
translation.activate('fr')
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
self.tournament.create_spreadsheet()
spreadsheet = gc.open_by_key(self.tournament.notes_sheet_id)
@ -1319,11 +1403,11 @@ class Pool(models.Model):
average_cell = worksheet.find("Moyenne")
min_row = 5
max_row = average_cell.row - 2
data = worksheet.get_values(f"A{min_row}:AF{max_row}")
data = worksheet.get_values(f"A{min_row}:Z{max_row}")
if not data or not data[0]:
return
passage_width = 7 if self.participations.count() == 4 else 6
passage_width = 6
for line in data:
jury_name = line[0]
jury_id = line[1]
@ -1341,15 +1425,15 @@ class Pool(models.Model):
note.save()
def __str__(self):
return _("Pool of day {round} for tournament {tournament} with teams {teams}")\
.format(round=self.round,
return _("Pool {code} for tournament {tournament} with teams {teams}")\
.format(code=self.short_name,
tournament=str(self.tournament),
teams=", ".join(participation.team.trigram for participation in self.participations.all()))
class Meta:
verbose_name = _("pool")
verbose_name_plural = _("pools")
ordering = ('round', 'letter',)
ordering = ('round', 'letter', 'room',)
class Passage(models.Model):
@ -1395,16 +1479,6 @@ class Passage(models.Model):
related_name="+",
)
observer = models.ForeignKey(
Participation,
on_delete=models.PROTECT,
null=True,
blank=True,
default=None,
verbose_name=_("observer"),
related_name="+",
)
defender_penalties = models.PositiveSmallIntegerField(
verbose_name=_("penalties"),
default=0,
@ -1459,10 +1533,6 @@ class Passage(models.Model):
def average_reporter(self) -> float:
return 0.9 * self.average_reporter_writing + self.average_reporter_oral
@property
def average_observer(self) -> float:
return self.avg(note.observer_oral for note in self.notes.all())
@property
def averages(self):
yield self.average_defender_writing
@ -1471,13 +1541,10 @@ class Passage(models.Model):
yield self.average_opponent_oral
yield self.average_reporter_writing
yield self.average_reporter_oral
if self.observer:
yield self.average_observer
def average(self, participation):
return self.average_defender if participation == self.defender else self.average_opponent \
if participation == self.opponent else self.average_reporter if participation == self.reporter \
else self.average_observer if participation == self.observer else 0
if participation == self.opponent else self.average_reporter if participation == self.reporter else 0
def get_absolute_url(self):
return reverse_lazy("participation:passage_detail", args=(self.pk,))
@ -1492,9 +1559,6 @@ class Passage(models.Model):
if self.reporter not in self.pool.participations.all():
raise ValidationError(_("Team {trigram} is not registered in the pool.")
.format(trigram=self.reporter.team.trigram))
if self.observer and self.observer not in self.pool.participations.all():
raise ValidationError(_("Team {trigram} is not registered in the pool.")
.format(trigram=self.observer.team.trigram))
return super().clean()
def __str__(self):
@ -1536,7 +1600,7 @@ class Tweak(models.Model):
def get_solution_filename(instance, filename):
return f"solutions/{instance.participation.team.trigram}_{instance.problem}" \
+ ("final" if instance.final_solution else "")
+ ("_final" if instance.final_solution else "")
def get_synthesis_filename(instance, filename):
@ -1678,12 +1742,6 @@ class Note(models.Model):
default=0,
)
observer_oral = models.SmallIntegerField(
verbose_name=_("observer note"),
choices=zip(range(-4, 5), range(-4, 5)),
default=0,
)
def get_all(self):
yield self.defender_writing
yield self.defender_oral
@ -1691,23 +1749,22 @@ class Note(models.Model):
yield self.opponent_oral
yield self.reporter_writing
yield self.reporter_oral
if self.passage.observer:
yield self.observer_oral
def set_all(self, defender_writing: int, defender_oral: int, opponent_writing: int, opponent_oral: int,
reporter_writing: int, reporter_oral: int, observer_oral: int = 0):
reporter_writing: int, reporter_oral: int):
self.defender_writing = defender_writing
self.defender_oral = defender_oral
self.opponent_writing = opponent_writing
self.opponent_oral = opponent_oral
self.reporter_writing = reporter_writing
self.reporter_oral = reporter_oral
self.observer_oral = observer_oral
def update_spreadsheet(self):
if not self.has_any_note():
return
translation.activate('fr')
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
passage = Passage.objects.prefetch_related('pool__tournament', 'pool__participations').get(pk=self.passage.pk)
spreadsheet_id = passage.pool.tournament.notes_sheet_id
@ -1717,7 +1774,7 @@ class Note(models.Model):
if not jury_id_cell:
raise ValueError("The jury ID cell was not found in the spreadsheet.")
jury_row = jury_id_cell.row
passage_width = 7 if passage.pool.participations.count() == 4 else 6
passage_width = 6
def getcol(number: int) -> str:
if number == 0:

View File

@ -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:

View File

@ -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',)

View File

@ -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 %}

View File

@ -34,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>
@ -88,13 +83,13 @@
{% trans "Average points for the defender oral" %}
({{ passage.defender.team.trigram }}) :
</dt>
<dd class="col-sm-4">{{ passage.average_defender_oral|floatformat }}/16</dd>
<dd class="col-sm-4">{{ passage.average_defender_oral|floatformat }}/20</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 }}/9</dd>
<dd class="col-sm-4">{{ passage.average_opponent_writing|floatformat }}/10</dd>
<dt class="col-sm-8">
{% trans "Average points for the opponent oral" %}
@ -106,21 +101,13 @@
{% trans "Average points for the reporter writing" %}
({{ passage.reporter.team.trigram }}) :
</dt>
<dd class="col-sm-4">{{ passage.average_reporter_writing|floatformat }}/9</dd>
<dd class="col-sm-4">{{ passage.average_reporter_writing|floatformat }}/10</dd>
<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" %}
({{ passage.observer.team.trigram }}) :
</dt>
<dd class="col-sm-4">{{ passage.average_observer|floatformat }}/4</dd>
{% endif %}
</dl>
<hr>
@ -143,14 +130,6 @@
({{ 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" %}
({{ passage.observer.team.trigram }}) :
</dt>
<dd class="col-sm-4">{{ passage.average_observer|floatformat }}/4</dd>
{% endif %}
</dl>
</div>
</div>

View File

@ -25,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">
@ -82,22 +87,12 @@
<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" %}{% if pool.passages.count == 5 %} — {% trans "Room" %} 1{% endif %}
{% trans "Download the scale sheet" %}
</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
</a>
{% endif %}
<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" %}{% if pool.passages.count == 5 %} — {% trans "Room" %} 1{% endif %}
{% trans "Download the final notation sheet" %}
</a>
{% if pool.passages.count == 5 %}
<a class="btn btn-sm btn-info" href="{% url 'participation:pool_final_note_sheet' pk=pool.pk %}?page=2">
{% trans "Room" %} 2
</a>
{% endif %}
<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" %}
@ -133,7 +128,7 @@
<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 CSV file" %}
{% 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>

View File

@ -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 %} :

View File

@ -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|}{{\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|}{{\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}

View File

@ -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,27 +56,11 @@ 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}

View File

@ -9,53 +9,53 @@
</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>
@ -103,7 +103,32 @@
<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>

View File

@ -9,7 +9,6 @@
<div class="alert alert-warning">
{% url 'participation:pool_jury' pk=pool.jury as jury_url %}
{% blocktrans trimmed with jury_url=jury_url %}
Remember to export your spreadsheet as a CSV file before uploading it here.
Rows that are full of zeros are ignored.
Unknown juries are not considered.
{% endblocktrans %}

View File

@ -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 }}

View File

@ -2,13 +2,13 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path
from django.views.generic import TemplateView
from .views import CreateTeamView, FinalNotationSheetTemplateView, GSheetNotificationsView, JoinTeamView, \
MyParticipationDetailView, MyTeamDetailView, NotationSheetsArchiveView, NoteUpdateView, ParticipationDetailView, \
PassageDetailView, PassageUpdateView, PoolCreateView, PoolDetailView, PoolJuryView, PoolNotesTemplateView, \
PoolPresideJuryView, PoolRemoveJuryView, PoolUpdateView, PoolUploadNotesView, \
ScaleNotationSheetTemplateView, SolutionsDownloadView, SolutionUploadView, SynthesisUploadView, \
ScaleNotationSheetTemplateView, SelectTeamFinalView, \
SolutionsDownloadView, SolutionUploadView, SynthesisUploadView, \
TeamAuthorizationsView, TeamDetailView, TeamLeaveView, TeamListView, TeamUpdateView, \
TeamUploadMotivationLetterView, TournamentCreateView, TournamentDetailView, TournamentExportCSVView, \
TournamentHarmonizeNoteView, TournamentHarmonizeView, TournamentListView, TournamentPaymentsView, \
@ -54,6 +54,8 @@ urlpatterns = [
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"),
@ -71,5 +73,4 @@ urlpatterns = [
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")
]

View File

@ -27,6 +27,7 @@ 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
@ -258,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:
@ -390,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)
@ -399,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:
@ -415,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):
@ -447,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")
@ -591,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()),
]
@ -625,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()
@ -788,13 +826,45 @@ class TournamentHarmonizeNoteView(VolunteerMixin, DetailView):
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
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
@ -863,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)
@ -963,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
@ -984,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:
@ -1026,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
@ -1178,8 +1251,7 @@ 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)]
@ -1215,7 +1287,7 @@ class PoolNotesTemplateView(VolunteerMixin, DetailView):
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:
@ -1387,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()
@ -1412,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()
@ -1439,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)
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)
@ -1480,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()
@ -1564,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)
@ -1605,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}]"
@ -1614,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())
@ -1729,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):
@ -1753,13 +1777,6 @@ class NotationSheetTemplateView(VolunteerMixin, DetailView):
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() * '&'
@ -1834,44 +1851,36 @@ class NotationSheetsArchiveView(VolunteerMixin, DetailView):
for pool in pools:
prefix = f"{pool.short_name}/" if len(pools) > 1 else ""
for template_name in ['bareme', 'finale']:
pages = [1] if pool.participations.count() < 5 else [1, 2]
for page in pages:
juries = list(pool.juries.all()) + [None]
juries = list(pool.juries.all()) + [None]
for jury in juries:
if jury is not None and template_name == "bareme":
continue
for jury in juries:
if jury is not None and template_name == "bareme":
continue
context = {'jury': jury, 'page': page, 'pool': pool,
'tfjm_number': timezone.now().year - 2010}
context = {'jury': jury, 'pool': pool,
'tfjm_number': timezone.now().year - 2010}
passages = pool.passages.all()
if passages.count() == 5:
passages = passages.filter(
id__in=([passages[0].id, passages[2].id, passages[4].id]
if page == '1' else [passages[1].id, passages[3].id]))
passages = pool.passages.all()
context['passages'] = passages
context['esp'] = passages.count() * '&'
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)
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()
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'}")
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'}")
sheet_name += " - page 2" if page == 2 else ""
zf.write(os.path.join(temp_dir, "texput.pdf"),
f"{prefix}{sheet_name}.pdf")
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}\""
@ -1885,8 +1894,9 @@ class GSheetNotificationsView(View):
if not await Tournament.objects.filter(pk=kwargs['pk']).aexists():
return HttpResponse(status=404)
tournament = await Tournament.objects.prefetch_related('participations', 'pools').aget(pk=kwargs['pk'])
expected_channel_id = sha1(f"{tournament.name}-{timezone.now().date()}-{request.site.domain}".encode()) \
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:
@ -1918,7 +1928,7 @@ 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()
@ -1936,9 +1946,6 @@ class PassageDetailView(LoginRequiredMixin, DetailView):
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
if passage.pool.participations.count() != 4:
context['notes']._sequence.remove('observer_oral')
if 'notes' in context and not self.request.user.registration.is_admin:
context['notes']._sequence.remove('update')
@ -1948,8 +1955,6 @@ class PassageDetailView(LoginRequiredMixin, DetailView):
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})"
if self.object.observer:
context['notes'].columns['observer_oral'].column.verbose_name += f" ({passage.observer.team.trigram})"
return context
@ -2044,11 +2049,6 @@ class NoteUpdateView(VolunteerMixin, UpdateView):
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})"
if self.object.passage.observer:
form.fields['observer_oral'].label += f" ({self.object.passage.observer.team.trigram})"
else:
# Set the note of the observer only for 4-teams pools
del form.fields['observer_oral']
return form
def form_valid(self, form):

View File

@ -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',

View File

@ -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.

View File

@ -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)",
),
),
]

View File

@ -210,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
@ -258,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")
@ -314,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,
@ -398,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:

View File

@ -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>

View File

@ -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²

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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>

View File

@ -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"),

View File

@ -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()):

View File

@ -5,20 +5,19 @@ 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
google-api-python-client~=2.124.0
google-auth-httplib2~=0.2.0
google-auth-oauthlib~=1.2.0
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

View File

@ -8,7 +8,7 @@
*/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

View File

@ -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
View 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
View 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()),
]

View File

@ -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")

View File

@ -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": {

View File

Before

Width:  |  Height:  |  Size: 428 KiB

After

Width:  |  Height:  |  Size: 428 KiB

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 32 32"
version="1.1"
id="svg27"
sodipodi:docname="logo.svg"
width="32"
height="32"
inkscape:version="0.92.2 2405546, 2018-03-11">
<style>
path {
fill: black;
}
</style>
<metadata
id="metadata31">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs15">
<path
id="b"
d="m 2.58,-3.347 c 0.409,0 1.405,0.02 1.485,1.135 0.01,0.12 0.02,0.25 0.18,0.25 0.168,0 0.168,-0.14 0.168,-0.32 v -2.7 c 0,-0.159 0,-0.318 -0.169,-0.318 -0.13,0 -0.17,0.1 -0.18,0.21 -0.059,1.155 -0.756,1.354 -1.484,1.384 v -2.102 c 0,-0.668 0.19,-0.668 0.429,-0.668 h 0.468 c 1.275,0 1.923,0.688 1.983,1.375 0.01,0.08 0.02,0.23 0.179,0.23 0.17,0 0.17,-0.16 0.17,-0.33 v -1.295 c 0,-0.308 -0.02,-0.328 -0.33,-0.328 h -5 c -0.18,0 -0.34,0 -0.34,0.179 0,0.17 0.19,0.17 0.27,0.17 0.567,0 0.607,0.079 0.607,0.567 v 4.991 c 0,0.469 -0.03,0.568 -0.558,0.568 -0.15,0 -0.319,0 -0.319,0.17 C 0.14,0 0.3,0 0.48,0 h 2.878 c 0.18,0 0.33,0 0.33,-0.18 0,-0.169 -0.17,-0.169 -0.3,-0.169 -0.767,0 -0.807,-0.07 -0.807,-0.597 v -2.401 z m 2.88,-3.129 v 0.469 A 2.557,2.557 0 0 0 4.922,-6.476 Z M 4.065,-3.158 A 1.51,1.51 0 0 0 3.537,-3.547 c 0.189,-0.09 0.388,-0.249 0.528,-0.418 z m -2.7,-2.77 c 0,-0.12 0,-0.368 -0.08,-0.548 h 1.056 c -0.11,0.23 -0.11,0.558 -0.11,0.648 v 4.901 c 0,0.15 0,0.389 0.1,0.578 H 1.285 c 0.08,-0.179 0.08,-0.428 0.08,-0.548 v -5.03 z" />
<path
id="c"
d="m 1.564,-6.824 c -0.18,0 -0.339,0 -0.339,0.179 0,0.17 0.18,0.17 0.29,0.17 0.687,0 0.727,0.069 0.727,0.577 v 5.59 c 0,0.169 0,0.358 -0.17,0.527 -0.08,0.07 -0.239,0.18 -0.478,0.18 -0.07,0 -0.369,0 -0.369,-0.11 0,-0.08 0.04,-0.12 0.09,-0.17 A 0.704,0.704 0 0 0 0.777,-1.057 0.704,0.704 0 0 0 0.06,-0.359 c 0,0.629 0.637,1.106 1.604,1.106 1.106,0 2.042,-0.387 2.192,-1.614 0.01,-0.09 0.01,-0.647 0.01,-0.966 v -4.184 c 0,-0.449 0.139,-0.449 0.707,-0.459 0.09,0 0.17,-0.08 0.17,-0.17 0,-0.178 -0.15,-0.178 -0.33,-0.178 z M 0.867,0.239 C 0.767,0.19 0.408,0.02 0.408,-0.349 c 0,-0.259 0.22,-0.358 0.37,-0.358 0.168,0 0.368,0.12 0.368,0.348 0,0.15 -0.08,0.24 -0.12,0.27 -0.04,0.04 -0.13,0.139 -0.16,0.328 z M 2.59,-5.918 c 0,-0.11 0,-0.378 -0.09,-0.558 h 1.097 c -0.08,0.18 -0.08,0.369 -0.08,0.708 v 4.015 c 0,0.298 0,0.797 -0.01,0.896 C 3.427,-0.349 3.198,0.11 2.44,0.31 2.59,0.08 2.59,-0.109 2.59,-0.288 v -5.629 z" />
<path
id="d"
d="M 4.643,-2.092 2.74,-6.625 c -0.08,-0.2 -0.09,-0.2 -0.359,-0.2 H 0.528 c -0.18,0 -0.329,0 -0.329,0.18 0,0.17 0.18,0.17 0.23,0.17 0.119,0 0.388,0.02 0.607,0.099 v 5.32 c 0,0.21 0,0.648 -0.677,0.707 -0.19,0.02 -0.19,0.16 -0.19,0.17 C 0.17,0 0.33,0 0.51,0 h 1.543 c 0.18,0 0.33,0 0.33,-0.18 0,-0.089 -0.08,-0.159 -0.16,-0.169 -0.767,-0.06 -0.767,-0.478 -0.767,-0.707 v -4.961 l 0.01,-0.01 2.429,5.817 c 0.08,0.18 0.15,0.209 0.21,0.209 0.12,0 0.149,-0.08 0.199,-0.2 l 2.44,-5.827 0.01,0.01 v 4.961 c 0,0.21 0,0.648 -0.677,0.707 -0.19,0.02 -0.19,0.16 -0.19,0.17 0,0.179 0.16,0.179 0.34,0.179 h 2.66 c 0.179,0 0.328,0 0.328,-0.18 C 9.215,-0.27 9.135,-0.34 9.056,-0.35 8.289,-0.41 8.289,-0.828 8.289,-1.057 v -4.712 c 0,-0.21 0,-0.648 0.677,-0.708 0.1,-0.01 0.19,-0.06 0.19,-0.17 0,-0.178 -0.15,-0.178 -0.33,-0.178 H 6.905 c -0.259,0 -0.279,0 -0.369,0.209 z m -0.3,0.18 c 0.08,0.169 0.09,0.178 0.21,0.218 L 4.115,-0.638 H 4.095 L 1.823,-6.058 C 1.773,-6.187 1.693,-6.356 1.554,-6.476 h 0.867 l 1.923,4.563 z M 1.336,-0.35 h -0.17 c 0.02,-0.03 0.04,-0.06 0.06,-0.08 0.01,-0.01 0.01,-0.02 0.02,-0.03 z M 7.104,-6.477 H 8.16 c -0.219,0.25 -0.219,0.508 -0.219,0.688 v 4.752 c 0,0.18 0,0.438 0.23,0.687 H 6.883 c 0.22,-0.249 0.22,-0.508 0.22,-0.687 v -5.44 z" />
<path
id="a"
d="m 4.135,-6.466 c 1.305,0.07 1.793,0.917 1.833,1.385 0.01,0.15 0.02,0.299 0.179,0.299 0.18,0 0.18,-0.17 0.18,-0.359 v -1.325 c 0,-0.348 -0.04,-0.358 -0.34,-0.358 H 0.658 c -0.308,0 -0.328,0.02 -0.328,0.318 V -5.1 c 0,0.16 0,0.319 0.17,0.319 0.17,0 0.178,-0.18 0.178,-0.2 0.04,-0.826 0.788,-1.424 1.834,-1.484 v 5.54 c 0,0.498 -0.04,0.577 -0.668,0.577 -0.12,0 -0.299,0 -0.299,0.17 0,0.179 0.16,0.179 0.339,0.179 h 2.89 C 4.95,0 5.1,0 5.1,-0.18 c 0,-0.169 -0.17,-0.169 -0.28,-0.169 -0.647,0 -0.686,-0.07 -0.686,-0.578 v -5.539 z m -3.458,-0.01 h 0.598 c -0.249,0.15 -0.458,0.349 -0.598,0.518 z m 5.3,0 v 0.528 A 2.606,2.606 0 0 0 5.37,-6.476 H 5.978 Z M 2.77,-0.349 c 0.09,-0.179 0.09,-0.428 0.09,-0.558 v -5.569 h 0.926 v 5.57 c 0,0.129 0,0.378 0.09,0.557 H 2.77 Z" />
<path
id="e"
d="M 3.522,-1.27 H 3.285 c -0.021,0.154 -0.091,0.566 -0.182,0.635 -0.055,0.042 -0.592,0.042 -0.69,0.042 H 1.13 c 0.732,-0.648 0.976,-0.844 1.395,-1.171 0.516,-0.412 0.997,-0.844 0.997,-1.507 0,-0.844 -0.74,-1.36 -1.632,-1.36 -0.865,0 -1.45,0.607 -1.45,1.249 0,0.355 0.3,0.39 0.369,0.39 0.167,0 0.37,-0.118 0.37,-0.37 0,-0.125 -0.05,-0.369 -0.412,-0.369 0.216,-0.495 0.69,-0.649 1.018,-0.649 0.698,0 1.06,0.544 1.06,1.11 0,0.606 -0.432,1.087 -0.655,1.338 l -1.68,1.66 C 0.44,-0.209 0.44,-0.195 0.44,0 h 2.873 z" />
</defs>
<rect width="100%" height="100%"
rx="10px" ry="10px" stroke-linejoin="round"
style="fill: white;" />
<use
x="0.5"
y="19.5"
xlink:href="#a"
id="use17"
width="100%"
height="100%" />
<use
x="7.5"
y="19.5"
xlink:href="#b"
id="use19"
width="100%"
height="100%" />
<use
x="13"
y="19.5"
xlink:href="#c"
id="use21"
width="100%"
height="100%" />
<use
x="18"
y="19.5"
xlink:href="#d"
id="use23"
width="100%"
height="100%" />
<use
x="27"
y="14"
xlink:href="#e"
id="use25"
width="100%"
height="100%" />
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@ -1,4 +1,6 @@
{% load i18n static %}
{% load i18n %}
{% load pipeline %}
{% load static %}
<!DOCTYPE html>
{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
@ -16,19 +18,12 @@
<meta name="theme-color" content="#ffffff">
{# Bootstrap CSS #}
<link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'fontawesome/css/all.css' %}">
<link rel="stylesheet" href="{% static 'fontawesome/css/v4-shims.css' %}">
<link rel="stylesheet" href="{% static 'bootstrap-select/css/bootstrap-select.min.css' %}">
{% stylesheet 'bootstrap_fontawesome' %}
{# Bootstrap JavaScript #}
<script src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script>
{% javascript 'bootstrap' %}
{# bootstrap-select for beautiful selects and JQuery dependency #}
<script src="{% static 'jquery/jquery.min.js' %}"></script>
<script src="{% static 'bootstrap-select/js/bootstrap-select.min.js' %}"></script>
<script src="{% static 'bootstrap-select/js/defaults-fr_FR.min.js' %}"></script>
{% javascript 'bootstrap_select' %}
{# Si un formulaire requiert des données supplémentaires (notamment JS), les données sont chargées #}
{% if form.media %}
@ -40,18 +35,18 @@
<body class="d-flex w-100 h-100 flex-column">
{% include "navbar.html" %}
<div id="body-wrapper" class="row w-100 my-3">
<div id="body-wrapper" class="row w-100 my-3 flex-grow-1">
<aside class="col-lg-2 px-2">
{% include "sidebar.html" %}
</aside>
<main class="col d-flex flex-column">
<div class="container">
<main class="col d-flex flex-column flex-grow-1">
<div class="container d-flex flex-column flex-grow-1">
{% block content-title %}<h1 id="content-title">{{ title }}</h1>{% endblock %}
{% include "messages.html" %}
<div id="content">
<div id="content" class="d-flex flex-column flex-grow-1">
{% block content %}
<p>Default content...</p>
{% endblock content %}
@ -87,8 +82,7 @@
{% include "base_modal.html" with modal_id="login" %}
{% endif %}
<script src="{% static 'main.js' %}"></script>
<script src="{% static 'theme.js' %}"></script>
{% javascript 'main' %}
<script>
CSRF_TOKEN = "{{ csrf_token }}";

View File

@ -41,7 +41,7 @@
<i class="fab fa-gitlab"></i>
</a>
</div>
<div class="col-sm-1 text-end">
<div class="col-sm-1 text-sm-end">
<a href="#" class="text-muted">
<i class="fa fa-arrow-up" aria-hidden="true"></i>
</a>

View File

@ -17,7 +17,7 @@
Ton équipe est déjà formée ?
</h3>
</div>
<div class="col-sm text-end">
<div class="col-sm text-sm-end">
<div class="btn-group-vertical">
<a class="btn btn-primary btn-lg" href="{% url "registration:signup" %}" role="button">Inscris-toi maintenant !</a>
<a class="btn btn-light text-dark btn-lg" href="{% url "login" %}" role="button">J'ai déjà un compte</a>

View File

@ -3,7 +3,7 @@
<nav class="navbar navbar-expand-lg fixed-navbar shadow-sm">
<div class="container-fluid">
<a class="navbar-brand" href="https://tfjm.org/">
<img src="{% static "tfjm.svg" %}" style="height: 2em;" alt="Logo TFJM²" id="navbar-logo">
<img src="{% static "tfjm/img/tfjm.svg" %}" style="height: 2em;" alt="Logo TFJM²" id="navbar-logo">
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#navbarNavDropdown"
@ -61,12 +61,12 @@
</a>
</li>
{% endif %}
<li class="nav-item active">
<a class="nav-link" href="{% url "chat:chat" %}">
<i class="fas fa-comments"></i> {% trans "Chat" %}
</a>
</li>
{% endif %}
<li class="nav-item active d-none">
<a class="nav-link" href="{% url "participation:chat" %}">
<i class="fas fa-comments"></i> {% trans "Chat" %}
</a>
</li>
{% if user.registration.is_admin %}
<li class="nav-item active">
<a class="nav-link" href="{% url "admin:index" %}"><i class="fas fa-cog"></i> {% trans "Administration" %}</a>
@ -111,9 +111,12 @@
</a>
</li>
<li>
<a class="dropdown-item" href="{% url "logout" %}">
<i class="fas fa-sign-out-alt"></i> {% trans "Log out" %}
</a>
<form action="{% url 'logout' %}" method="post">
{% csrf_token %}
<button class="dropdown-item">
<i class="fas fa-sign-out-alt"></i> {% trans "Log out" %}
</button>
</form>
</li>
</ul>
</li>

View File

@ -0,0 +1,23 @@
{% load i18n crispy_forms_filters %}
{% if user.is_authenticated %}
<p class="errornote">
{% blocktrans trimmed %}
You are authenticated as {{ user }}, but are not authorized to
access this page. Would you like to login to a different account?
{% endblocktrans %}
</p>
{% endif %}
<form method="post" id="login-form">
<div id="form-content">
{{ form|as_crispy_errors }}
{% csrf_token %}
{{ form.username|as_crispy_field }}
<div class="form-text mb-3">
<i class="fas fa-info-circle"></i> {% trans "Your username is your e-mail address." %}
</div>
{{ form.password|as_crispy_field }}
<a href="{% url 'password_reset' %}" class="badge text-bg-warning">{% trans 'Forgotten your password?' %}</a>
</div>
<input type="submit" value="{% trans 'Log in' %}" class="btn btn-primary">
</form>

View File

@ -2,31 +2,11 @@
{% comment %}
SPDX-License-Identifier: GPL-2.0-or-later
{% endcomment %}
{% load i18n crispy_forms_filters %}
{% load i18n %}
{% block title %}{% trans "Log in" %}{% endblock %}
{% block content-title %}<h1>{% trans "Log in" %}</h1>{% endblock %}
{% block content %}
{% if user.is_authenticated %}
<p class="errornote">
{% blocktrans trimmed %}
You are authenticated as {{ user }}, but are not authorized to
access this page. Would you like to login to a different account?
{% endblocktrans %}
</p>
{% endif %}
<form method="post" id="login-form">
<div id="form-content">
{{ form|as_crispy_errors }}
{% csrf_token %}
{{ form.username|as_crispy_field }}
<div class="form-text mb-3">
<i class="fas fa-info-circle"></i> {% trans "Your username is your e-mail address." %}
</div>
{{ form.password|as_crispy_field }}
<a href="{% url 'password_reset' %}" class="badge text-bg-warning">{% trans 'Forgotten your password?' %}</a>
</div>
<input type="submit" value="{% trans 'Log in' %}" class="btn btn-primary">
</form>
{% include "registration/includes/login.html" %}
{% endblock %}

View File

@ -37,6 +37,7 @@ urlpatterns = [
path('search/', AdminSearchView.as_view(), name="haystack_search"),
path('api/', include('api.urls')),
path('chat/', include('chat.urls')),
path('draw/', include('draw.urls')),
path('participation/', include('participation.urls')),
path('registration/', include('registration.urls')),

View File

@ -3,6 +3,7 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import User
from django.views.generic import TemplateView
from haystack.generic_views import SearchView
@ -40,5 +41,9 @@ class UserRegistrationMixin(LoginRequiredMixin):
return super().dispatch(request, *args, **kwargs)
class LoginRequiredTemplateView(LoginRequiredMixin, TemplateView):
pass
class AdminSearchView(AdminMixin, SearchView):
pass

View File

@ -13,7 +13,7 @@ deps = coverage
commands =
python manage.py compilemessages -i .tox -i venv
coverage run --source=api,draw,logs,participation,registration,tfjm ./manage.py test api/ draw/ logs/ participation/ registration/ tfjm/
coverage run --source=api,draw,logs,participation,registration,tfjm ./manage.py test api/ chat/ draw/ logs/ participation/ registration/ tfjm/
coverage report -m
[testenv:linters]
@ -26,7 +26,7 @@ deps =
pep8-naming
pyflakes
commands =
flake8 api/ draw/ logs/ participation/ registration/ tfjm/
flake8 api/ chat/ss draw/ logs/ participation/ registration/ tfjm/
[flake8]
exclude =