Compare commits
367 Commits
17057a5fe5
...
dev
Author | SHA1 | Date | |
---|---|---|---|
8aec72d712
|
|||
6a521b6121
|
|||
62abfa94d6
|
|||
952315ea4d
|
|||
2e613799c9
|
|||
08805a6360
|
|||
6841659e41
|
|||
a84ffcf0a3
|
|||
203fc3cd54
|
|||
60f5236dee
|
|||
ab459ecc17
|
|||
7ad7659d78
|
|||
84eb08ec46
|
|||
3750828883
|
|||
ba36ad4071
|
|||
626433c464
|
|||
032b67ac51
|
|||
f3bd479fdc
|
|||
bc06cf4903
|
|||
6d43c4b97e
|
|||
0499885fc8
|
|||
63c96ff2d2
|
|||
efeb2628ad
|
|||
56aad288f4
|
|||
b33a69410a
|
|||
0a80e03b58
|
|||
73b94d5578
|
|||
97eea3b11a
|
|||
702c8d8c9e
|
|||
ca0601fb24
|
|||
d315c8371a
|
|||
7488d3eae1
|
|||
cfaf7c4287
|
|||
e3c216e44e
|
|||
73012bd61e
|
|||
bdf181e7e4
|
|||
c57ad854fe
|
|||
a2e5ab5f6a
|
|||
758a2c9a00
|
|||
fb10df77e5
|
|||
905b96fbcf
|
|||
be2e258948
|
|||
882570800c
|
|||
df31968a77
|
|||
df6fb3b3f3
|
|||
3807fbcf45
|
|||
8433390e19
|
|||
ec85f62ab6
|
|||
74b2a0c095
|
|||
67958335ab
|
|||
20410cc17f
|
|||
a5aff5ff21
|
|||
196dbc8275
|
|||
0847e5a308
|
|||
e5aa3ef059
|
|||
e1b4e1bb6b
|
|||
ecc59a6c8c
|
|||
b053a47a19
|
|||
ab2e49e8fb
|
|||
fe399c869d
|
|||
9de8a2ed0e
|
|||
d24f8cab16
|
|||
6cdf6331db
|
|||
65c6158b52
|
|||
4a5f48a834
|
|||
4ab706d219
|
|||
70f2be8b17
|
|||
4317947501
|
|||
f327a4c9c4
|
|||
1b24e90635
|
|||
338f0d456a
|
|||
2c4de8cec3
|
|||
6b7d52c79b
|
|||
f398bedcf3
|
|||
fdffe2331f
|
|||
42425c392d
|
|||
18f3ce4023
|
|||
620bbe7817
|
|||
12205f953b
|
|||
696863f6c3
|
|||
748720df50
|
|||
40db20a471
|
|||
2e99b3ea8e
|
|||
9721898731
|
|||
5c3b3d26c8
|
|||
d13ae89267
|
|||
44302a9ff4
|
|||
8b3f3af2b9
|
|||
dd397ae7c0
|
|||
3f2a757414
|
|||
d20d5f6266
|
|||
05a6570bed
|
|||
2a298a3ee4
|
|||
05c6333c5e
|
|||
d84db949c6
|
|||
2627b3a9b8
|
|||
2c8f6f22f2
|
|||
e258e6a337
|
|||
109748ffc6 | |||
4201a2dbe6 | |||
17c7d0ccc3
|
|||
dd45f77a5e
|
|||
eacebf1aa6
|
|||
21d4ac9d8d | |||
7c83ae8730 | |||
1977ffdbc9 | |||
a0a282df15
|
|||
603ee76664
|
|||
147cbff7f5
|
|||
8878ae8d8d
|
|||
4c8347072c
|
|||
73ea3d1717
|
|||
e026f49f8d
|
|||
ea03bd314b
|
|||
c12972b718
|
|||
2a775cedc1
|
|||
9bf3b7dff0
|
|||
cf92c78d03
|
|||
38ceef7a54
|
|||
ec2fa43e20
|
|||
85b3da09f6
|
|||
2c15774185
|
|||
08ad4f3888
|
|||
872009894d
|
|||
fd7fe90fce
|
|||
2ad538f5cc
|
|||
5e2add90a8
|
|||
635606eb13
|
|||
b828631106
|
|||
8216e0943f
|
|||
1138885fb4
|
|||
a43dc9c12a
|
|||
70050827d8
|
|||
f687deed14
|
|||
7a0341e7cf
|
|||
0129e32643
|
|||
64a2ea007e
|
|||
531eecf4b8
|
|||
bd416318ac
|
|||
90bec6bf5e
|
|||
ed5944e044
|
|||
a41c17576f
|
|||
80456f4da8
|
|||
1a641cb2d7
|
|||
8f3929875f
|
|||
f26f102650
|
|||
1e5d0ebcfc
|
|||
0cab21f344
|
|||
a771710094
|
|||
3b3dcff28b
|
|||
d6aa5eb0cc
|
|||
c6b9a84def
|
|||
675f19492c
|
|||
a5c210e9b6
|
|||
784002c085
|
|||
e77cc558de
|
|||
7bb0f78f34
|
|||
bfd1a76a2d
|
|||
b86dfe7351
|
|||
d36e97fa2e
|
|||
181bb86e49
|
|||
a121d1042b
|
|||
2d706b2b81
|
|||
ca91842c2d
|
|||
d617dd77c1
|
|||
d59bb75dce
|
|||
4a78e80399
|
|||
f3a4a99b78
|
|||
46fc5f39c8
|
|||
b464e7df1d
|
|||
7498677bbd
|
|||
ea8007aa07
|
|||
d9bb0a0860
|
|||
a594b268ea
|
|||
0bc5ef0a7f
|
|||
943276ef71
|
|||
13c815c62c
|
|||
35e3be8af3
|
|||
720de380d1
|
|||
ecf80f8b81
|
|||
3ca0148934
|
|||
58608ea5ff
|
|||
68da61a33b
|
|||
86e978faf2
|
|||
0845d0bfb6
|
|||
f457a2355e
|
|||
bacdd5cfcf
|
|||
3e24e10780
|
|||
adc4634f3e
|
|||
266afaf5c9
|
|||
059cae75c5
|
|||
91a1837c99
|
|||
b24201c529
|
|||
53302db56a
|
|||
49fda3df49
|
|||
3a0a98a331
|
|||
21c4d5d7f5
|
|||
338a19ec32
|
|||
5bfcaab831
|
|||
49e5d97ec9
|
|||
0e185f5046
|
|||
ab7cdd56cc
|
|||
7edd43f626
|
|||
aca23eaf8b
|
|||
a02697a3a7
|
|||
d3d72e090c
|
|||
6c76f1e633
|
|||
4a094002f0
|
|||
3045857897
|
|||
7a0b93b151
|
|||
7073f64aa6
|
|||
b4fc976197
|
|||
7a004596ca
|
|||
1493df0078
|
|||
7732a737bb
|
|||
b942baea17
|
|||
188b83ce2d
|
|||
29d9432ca2
|
|||
0181a1392d
|
|||
ec0419a6d7
|
|||
54016a1fbf
|
|||
7ae015cef9
|
|||
ea264fbca6
|
|||
758f714096
|
|||
40d24740ed
|
|||
b7344566ef
|
|||
0f5d0c8b40
|
|||
c45071c038
|
|||
aac4fc59e6
|
|||
78a43148a8
|
|||
ceedd0678c
|
|||
d13385fa01
|
|||
8996fc2cca
|
|||
65dcc978c1
|
|||
923b07b97e
|
|||
84860a2875
|
|||
6add9a1419
|
|||
eddb741eb7
|
|||
a763abf781
|
|||
78e8a92c3a
|
|||
424dee4aea
|
|||
a381b5583c
|
|||
867ee7efe1
|
|||
32b2d7239c
|
|||
6ce179bd60
|
|||
dba937fb03
|
|||
4efce6e325
|
|||
10a42d3633
|
|||
bb579d640c
|
|||
d7b4233282
|
|||
9092cf1846
|
|||
37b86d4ea0
|
|||
40988348d3
|
|||
1cbf95e6e1
|
|||
c4ec6a6f29
|
|||
779aec5e55
|
|||
bf5c673739
|
|||
a62e906b0e
|
|||
630633bab4
|
|||
8d7d7cd645
|
|||
e53575d31d
|
|||
412ff4e067
|
|||
29b01ebb13
|
|||
30b9a73df8
|
|||
572a6c3299
|
|||
c135da1f47
|
|||
6867c2cc2d
|
|||
1e7bd209a1
|
|||
109b603b7a
|
|||
6595409df0
|
|||
f1012efcaa
|
|||
5261a52401
|
|||
a914237f66
|
|||
2019c5c434
|
|||
234b84ef60
|
|||
b9295cc199
|
|||
3fae6a00dd
|
|||
37ad3cf8a6
|
|||
c522387482
|
|||
0006ecc90d
|
|||
6b16ed3cc8
|
|||
a44439671e
|
|||
5084bb65d9
|
|||
4583cf46b1
|
|||
a865361117
|
|||
4ea93d3426
|
|||
8777c562dd
|
|||
4ea70e5ab9
|
|||
df036ba384
|
|||
e9ae1fcb60
|
|||
bee04b0522
|
|||
b6d54d27cd
|
|||
3465da4c36
|
|||
4f129280c3
|
|||
d2c1a826a8
|
|||
0b9079b431
|
|||
6fa3a08a72
|
|||
64b7644e5e
|
|||
50d8bc2aed
|
|||
7f7ac5d5e6
|
|||
1dd9a5cf94
|
|||
40aa2e520f
|
|||
0ebee1910b
|
|||
81c2df7f10
|
|||
833b300fde
|
|||
12d25b64fe
|
|||
afbc67c413
|
|||
71e33b2177
|
|||
f95309be08
|
|||
0530441452
|
|||
4ff53e08db
|
|||
f9645b016a
|
|||
6b7b802d14
|
|||
1684c079e3
|
|||
0c45a88246
|
|||
de22a12e85
|
|||
415d83acc7
|
|||
eb7e7c1579
|
|||
348004320c
|
|||
9829541289
|
|||
1e1fef7a7b
|
|||
d0c9256c5b
|
|||
83300ad4b7
|
|||
92408b359b
|
|||
01ba0a1df9
|
|||
207af441a0
|
|||
2a2786ba6d
|
|||
1d01376703
|
|||
6e35bdc0b3
|
|||
9380fbaaf7
|
|||
295717256f
|
|||
87038dd6f4
|
|||
2155275627
|
|||
7b4e867e33
|
|||
2c54f315f6
|
|||
5cbc72b41f
|
|||
de504398d2
|
|||
cae1c6fdb8
|
|||
6a928ee35b
|
|||
bc535f4075
|
|||
64b91cf7e0
|
|||
54dafe1cec
|
|||
b16b6e422f
|
|||
8d08b18d08
|
|||
8c7e9648dd
|
|||
b3555a7807
|
|||
98d04b9093
|
|||
4d157b2bd7
|
|||
7c9083a6b8
|
|||
ece128836a
|
|||
2e574d0659
|
|||
850659bf48
|
|||
672529382d
|
|||
c1ce7cb70f
|
|||
bc67d1cf1f
|
|||
652e913f49
|
|||
089374b937
|
|||
226e5620f9
|
|||
ca9652cc60
|
|||
acd1d80c75
|
|||
e7c207d2af
|
|||
196ccb69ad
|
|||
2b941cb30f
|
|||
21ff044044
|
|||
2a85d4ff38
|
|||
037b22fcaa
|
|||
0474615746
|
15
.gitignore
vendored
@ -15,16 +15,6 @@ coverage
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# PyCharm project settings
|
||||
.idea
|
||||
|
||||
@ -32,7 +22,7 @@ coverage
|
||||
.vscode
|
||||
|
||||
# Local data
|
||||
secrets.py
|
||||
settings_local.py
|
||||
*.log
|
||||
media/
|
||||
output/
|
||||
@ -42,6 +32,3 @@ output/
|
||||
env/
|
||||
venv/
|
||||
db.sqlite3
|
||||
|
||||
# Don't git index
|
||||
whoosh_index/
|
||||
|
@ -1,25 +1,31 @@
|
||||
stages:
|
||||
- test
|
||||
- quality-assurance
|
||||
- build
|
||||
- release
|
||||
|
||||
py311:
|
||||
stage: test
|
||||
image: python:3.11-alpine
|
||||
before_script:
|
||||
- apk add --no-cache libmagic
|
||||
- apk add --no-cache git # Useful for django-haystack, remove when the newer versions are in PyPI
|
||||
- pip install tox --no-cache-dir
|
||||
script: tox -e py311
|
||||
variables:
|
||||
CONTAINER_TEST_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
|
||||
CONTAINER_RELEASE_IMAGE: $CI_REGISTRY_IMAGE:latest
|
||||
|
||||
py312:
|
||||
stage: test
|
||||
image: python:3.12-alpine
|
||||
before_script:
|
||||
- apk add --no-cache libmagic
|
||||
- apk add --no-cache git # Useful for django-haystack, remove when the newer versions are in PyPI
|
||||
- apk add --no-cache gettext
|
||||
- pip install tox --no-cache-dir
|
||||
script: tox -e py312
|
||||
|
||||
py313:
|
||||
stage: test
|
||||
image: python:3.13-alpine
|
||||
before_script:
|
||||
- apk add --no-cache libmagic
|
||||
- apk add --no-cache gettext
|
||||
- pip install tox --no-cache-dir
|
||||
script: tox -e py313
|
||||
|
||||
linters:
|
||||
stage: quality-assurance
|
||||
image: python:3-alpine
|
||||
@ -27,3 +33,29 @@ linters:
|
||||
- pip install tox --no-cache-dir
|
||||
script: tox -e linters
|
||||
allow_failure: true
|
||||
|
||||
build-image:
|
||||
image: docker
|
||||
stage: build
|
||||
services:
|
||||
- docker:dind
|
||||
before_script:
|
||||
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
|
||||
script:
|
||||
- docker build --pull -t $CONTAINER_TEST_IMAGE .
|
||||
- docker push $CONTAINER_TEST_IMAGE
|
||||
|
||||
release-image:
|
||||
image: docker
|
||||
stage: release
|
||||
services:
|
||||
- docker:dind
|
||||
before_script:
|
||||
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
|
||||
script:
|
||||
- docker pull $CONTAINER_TEST_IMAGE
|
||||
- docker tag $CONTAINER_TEST_IMAGE $CONTAINER_RELEASE_IMAGE
|
||||
- docker push $CONTAINER_RELEASE_IMAGE
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "main"
|
||||
|
||||
|
@ -1,9 +1,10 @@
|
||||
FROM python:3.12-alpine
|
||||
FROM python:3.13-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 libpq-dev libxml2-dev libxslt-dev \
|
||||
libmagic texlive texmf-dist-fontsrecommended texmf-dist-lang texmf-dist-latexextra uglify-js
|
||||
|
||||
RUN apk add --no-cache bash
|
||||
|
||||
@ -34,4 +35,4 @@ RUN ln -s /code/.bashrc /root/.bashrc
|
||||
ENTRYPOINT ["/code/entrypoint.sh"]
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["./manage.py", "shell_plus", "--ipython"]
|
||||
CMD ["./manage.py", "shell"]
|
||||
|
2
chat/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
28
chat/admin.py
Normal file
@ -0,0 +1,28 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Channel, Message
|
||||
|
||||
|
||||
@admin.register(Channel)
|
||||
class ChannelAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Modèle d'administration des canaux de chat.
|
||||
"""
|
||||
list_display = ('name', 'category', 'read_access', 'write_access', 'tournament', 'private',)
|
||||
list_filter = ('category', 'read_access', 'write_access', 'tournament', 'private',)
|
||||
search_fields = ('name', 'tournament__name', 'team__name', 'team__trigram',)
|
||||
autocomplete_fields = ('tournament', 'pool', 'team', 'invited', )
|
||||
|
||||
|
||||
@admin.register(Message)
|
||||
class MessageAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Modèle d'administration des messages de chat.
|
||||
"""
|
||||
list_display = ('channel', 'author', 'created_at', 'updated_at', 'content',)
|
||||
list_filter = ('channel', 'created_at', 'updated_at',)
|
||||
search_fields = ('author__username', 'author__first_name', 'author__last_name', 'content',)
|
||||
autocomplete_fields = ('channel', 'author', 'users_read',)
|
16
chat/apps.py
Normal file
@ -0,0 +1,16 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.db.models.signals import post_save
|
||||
|
||||
|
||||
class ChatConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "chat"
|
||||
|
||||
def ready(self):
|
||||
from chat import signals
|
||||
post_save.connect(signals.create_tournament_channels, "participation.Tournament")
|
||||
post_save.connect(signals.create_pool_channels, "participation.Pool")
|
||||
post_save.connect(signals.create_team_channel, "participation.Participation")
|
370
chat/consumers.py
Normal file
@ -0,0 +1,370 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Count, F, Q
|
||||
from registration.models import Registration
|
||||
|
||||
from .models import Channel, Message
|
||||
|
||||
|
||||
class ChatConsumer(AsyncJsonWebsocketConsumer):
|
||||
"""
|
||||
Ce consommateur gère les connexions WebSocket pour le chat.
|
||||
"""
|
||||
async def connect(self) -> None:
|
||||
"""
|
||||
Cette fonction est appelée lorsqu'un nouveau websocket tente de se connecter au serveur.
|
||||
On n'accept que si c'est un⋅e utilisateur⋅rice connecté⋅e.
|
||||
"""
|
||||
if '_fake_user_id' in self.scope['session']:
|
||||
# Dans le cas d'une impersonification, on charge l'utilisateur⋅rice concerné
|
||||
self.scope['user'] = await User.objects.aget(pk=self.scope['session']['_fake_user_id'])
|
||||
|
||||
# Récupération de l'utilisateur⋅rice courant⋅e
|
||||
user = self.scope['user']
|
||||
if user.is_anonymous:
|
||||
# L'utilisateur⋅rice n'est pas connecté⋅e
|
||||
await self.close()
|
||||
return
|
||||
|
||||
reg = await Registration.objects.aget(user_id=user.id)
|
||||
self.registration = reg
|
||||
|
||||
# Acceptation de la connexion
|
||||
await self.accept()
|
||||
|
||||
# Récupération des canaux accessibles en lecture et/ou en écriture
|
||||
self.read_channels = await Channel.get_accessible_channels(user, 'read')
|
||||
self.write_channels = await Channel.get_accessible_channels(user, 'write')
|
||||
|
||||
# Abonnement aux canaux de diffusion Websocket pour les différents canaux de chat
|
||||
async for channel in self.read_channels.all():
|
||||
await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name)
|
||||
# Abonnement à un canal de diffusion Websocket personnel, utile pour s'adresser à une unique personne
|
||||
await self.channel_layer.group_add(f"user-{user.id}", self.channel_name)
|
||||
|
||||
async def disconnect(self, close_code: int) -> None:
|
||||
"""
|
||||
Cette fonction est appelée lorsqu'un websocket se déconnecte du serveur.
|
||||
:param close_code: Le code d'erreur.
|
||||
"""
|
||||
if self.scope['user'].is_anonymous:
|
||||
# L'utilisateur⋅rice n'était pas connecté⋅e, on ne fait rien
|
||||
return
|
||||
|
||||
async for channel in self.read_channels.all():
|
||||
# Désabonnement des canaux de diffusion Websocket liés aux canaux de chat
|
||||
await self.channel_layer.group_discard(f"chat-{channel.id}", self.channel_name)
|
||||
# Désabonnement du canal de diffusion Websocket personnel
|
||||
await self.channel_layer.group_discard(f"user-{self.scope['user'].id}", self.channel_name)
|
||||
|
||||
async def receive_json(self, content: dict, **kwargs) -> None:
|
||||
"""
|
||||
Appelée lorsque le client nous envoie des données, décodées depuis du JSON.
|
||||
:param content: Les données envoyées par le client, décodées depuis du JSON. Doit contenir un champ 'type'.
|
||||
"""
|
||||
match content['type']:
|
||||
case 'fetch_channels':
|
||||
# Demande de récupération des canaux disponibles
|
||||
await self.fetch_channels()
|
||||
case 'send_message':
|
||||
# Envoi d'un message dans un canal
|
||||
await self.receive_message(**content)
|
||||
case 'edit_message':
|
||||
# Modification d'un message
|
||||
await self.edit_message(**content)
|
||||
case 'delete_message':
|
||||
# Suppression d'un message
|
||||
await self.delete_message(**content)
|
||||
case 'fetch_messages':
|
||||
# Récupération des messages d'un canal (ou d'une partie)
|
||||
await self.fetch_messages(**content)
|
||||
case 'mark_read':
|
||||
# Marquage de messages comme lus
|
||||
await self.mark_read(**content)
|
||||
case 'start_private_chat':
|
||||
# Démarrage d'une conversation privée avec un⋅e autre utilisateur⋅rice
|
||||
await self.start_private_chat(**content)
|
||||
case unknown:
|
||||
# Type inconnu, on soulève une erreur
|
||||
raise ValueError(f"Unknown message type: {unknown}")
|
||||
|
||||
async def fetch_channels(self) -> None:
|
||||
"""
|
||||
L'utilisateur⋅rice demande à récupérer la liste des canaux disponibles.
|
||||
On lui renvoie alors la liste des canaux qui lui sont accessibles en lecture,
|
||||
en fournissant nom, catégorie, permission de lecture et nombre de messages non lus.
|
||||
"""
|
||||
user = self.scope['user']
|
||||
|
||||
# Récupération des canaux accessibles en lecture, avec le nombre de messages non lus
|
||||
channels = self.read_channels.prefetch_related('invited') \
|
||||
.annotate(total_messages=Count('messages', distinct=True)) \
|
||||
.annotate(read_messages=Count('messages', filter=Q(messages__users_read=user), distinct=True)) \
|
||||
.annotate(unread_messages=F('total_messages') - F('read_messages')).all()
|
||||
|
||||
# Envoi de la liste des canaux
|
||||
message = {
|
||||
'type': 'fetch_channels',
|
||||
'channels': [
|
||||
{
|
||||
'id': channel.id,
|
||||
'name': channel.get_visible_name(user),
|
||||
'category': channel.category,
|
||||
'read_access': True,
|
||||
'write_access': await self.write_channels.acontains(channel),
|
||||
'unread_messages': channel.unread_messages,
|
||||
}
|
||||
async for channel in channels
|
||||
]
|
||||
}
|
||||
await self.send_json(message)
|
||||
|
||||
async def receive_message(self, channel_id: int, content: str, **kwargs) -> None:
|
||||
"""
|
||||
L'utilisateur⋅ice a envoyé un message dans un canal.
|
||||
On vérifie d'abord la permission d'écriture, puis on crée le message et on l'envoie à tou⋅tes les
|
||||
utilisateur⋅ices abonné⋅es au canal.
|
||||
|
||||
:param channel_id: Identifiant du canal où envoyer le message.
|
||||
:param content: Contenu du message.
|
||||
"""
|
||||
user = self.scope['user']
|
||||
|
||||
# Récupération du canal
|
||||
channel = await Channel.objects.prefetch_related('tournament__pools__juries', 'pool', 'team', 'invited') \
|
||||
.aget(id=channel_id)
|
||||
if not await self.write_channels.acontains(channel):
|
||||
# L'utilisateur⋅ice n'a pas la permission d'écrire dans ce canal, on abandonne
|
||||
return
|
||||
|
||||
# Création du message
|
||||
message = await Message.objects.acreate(
|
||||
author=user,
|
||||
channel=channel,
|
||||
content=content,
|
||||
)
|
||||
|
||||
# Envoi du message à toutes les personnes connectées sur le canal
|
||||
await self.channel_layer.group_send(f'chat-{channel.id}', {
|
||||
'type': 'chat.send_message',
|
||||
'id': message.id,
|
||||
'channel_id': channel.id,
|
||||
'timestamp': message.created_at.isoformat(),
|
||||
'author_id': message.author_id,
|
||||
'author': await message.aget_author_name(),
|
||||
'content': message.content,
|
||||
})
|
||||
|
||||
async def edit_message(self, message_id: int, content: str, **kwargs) -> None:
|
||||
"""
|
||||
L'utilisateur⋅ice a modifié un message.
|
||||
On vérifie d'abord que l'utilisateur⋅ice a le droit de modifier le message, puis on modifie le message
|
||||
et on envoie la modification à tou⋅tes les utilisateur⋅ices abonné⋅es au canal.
|
||||
|
||||
:param message_id: Identifiant du message à modifier.
|
||||
:param content: Nouveau contenu du message.
|
||||
"""
|
||||
user = self.scope['user']
|
||||
|
||||
# Récupération du message
|
||||
message = await Message.objects.aget(id=message_id)
|
||||
if user.id != message.author_id and not user.is_superuser:
|
||||
# Seul⋅e l'auteur⋅ice du message ou un⋅e admin peut modifier un message
|
||||
return
|
||||
|
||||
# Modification du contenu du message
|
||||
message.content = content
|
||||
await message.asave()
|
||||
|
||||
# Envoi de la modification à tou⋅tes les personnes connectées sur le canal
|
||||
await self.channel_layer.group_send(f'chat-{message.channel_id}', {
|
||||
'type': 'chat.edit_message',
|
||||
'id': message_id,
|
||||
'channel_id': message.channel_id,
|
||||
'content': content,
|
||||
})
|
||||
|
||||
async def delete_message(self, message_id: int, **kwargs) -> None:
|
||||
"""
|
||||
L'utilisateur⋅ice a supprimé un message.
|
||||
On vérifie d'abord que l'utilisateur⋅ice a le droit de supprimer le message, puis on supprime le message
|
||||
et on envoie la suppression à tou⋅tes les utilisateur⋅ices abonné⋅es au canal.
|
||||
|
||||
:param message_id: Identifiant du message à supprimer.
|
||||
"""
|
||||
user = self.scope['user']
|
||||
|
||||
# Récupération du message
|
||||
message = await Message.objects.aget(id=message_id)
|
||||
if user.id != message.author_id and not user.is_superuser:
|
||||
return
|
||||
|
||||
# Suppression effective du message
|
||||
await message.adelete()
|
||||
|
||||
# Envoi de la suppression à tou⋅tes les personnes connectées sur le canal
|
||||
await self.channel_layer.group_send(f'chat-{message.channel_id}', {
|
||||
'type': 'chat.delete_message',
|
||||
'id': message_id,
|
||||
'channel_id': message.channel_id,
|
||||
})
|
||||
|
||||
async def fetch_messages(self, channel_id: int, offset: int = 0, limit: int = 50, **_kwargs) -> None:
|
||||
"""
|
||||
L'utilisateur⋅ice demande à récupérer les messages d'un canal.
|
||||
On vérifie la permission de lecture, puis on renvoie les messages demandés.
|
||||
|
||||
:param channel_id: Identifiant du canal où récupérer les messages.
|
||||
:param offset: Décalage pour la pagination, à partir du dernier message.
|
||||
Par défaut : 0, on commence au dernier message.
|
||||
:param limit: Nombre de messages à récupérer. Par défaut, on récupère 50 messages.
|
||||
"""
|
||||
# Récupération du canal
|
||||
channel = await Channel.objects.aget(id=channel_id)
|
||||
if not await self.read_channels.acontains(channel):
|
||||
# L'utilisateur⋅rice n'a pas la permission de lire ce canal, on abandonne
|
||||
return
|
||||
|
||||
limit = min(limit, 200) # On limite le nombre de messages à 200 maximum
|
||||
|
||||
# Récupération des messages, avec un indicateur de lecture pour l'utilisateur⋅ice courant⋅e
|
||||
messages = Message.objects \
|
||||
.filter(channel=channel) \
|
||||
.annotate(read=Count('users_read', filter=Q(users_read=self.scope['user']))) \
|
||||
.order_by('-created_at')[offset:offset + limit].all()
|
||||
|
||||
# Envoi de la liste des messages, en les renvoyant dans l'ordre chronologique
|
||||
await self.send_json({
|
||||
'type': 'fetch_messages',
|
||||
'channel_id': channel_id,
|
||||
'messages': list(reversed([
|
||||
{
|
||||
'id': message.id,
|
||||
'timestamp': message.created_at.isoformat(),
|
||||
'author_id': message.author_id,
|
||||
'author': await message.aget_author_name(),
|
||||
'content': message.content,
|
||||
'read': message.read > 0,
|
||||
}
|
||||
async for message in messages
|
||||
]))
|
||||
})
|
||||
|
||||
async def mark_read(self, message_ids: list[int], **_kwargs) -> None:
|
||||
"""
|
||||
L'utilisateur⋅ice marque des messages comme lus, après les avoir affichés à l'écran.
|
||||
|
||||
:param message_ids: Liste des identifiants des messages qu'il faut marquer comme lus.
|
||||
"""
|
||||
# Récupération des messages à marquer comme lus
|
||||
messages = Message.objects.filter(id__in=message_ids)
|
||||
async for message in messages.all():
|
||||
# Ajout de l'utilisateur⋅ice courant⋅e à la liste des personnes ayant lu le message
|
||||
await message.users_read.aadd(self.scope['user'])
|
||||
|
||||
# Actualisation du nombre de messages non lus par canal
|
||||
unread_messages_by_channel = Message.objects.exclude(users_read=self.scope['user']).values('channel_id') \
|
||||
.annotate(unread_messages=Count('channel_id'))
|
||||
|
||||
# Envoi des identifiants des messages non lus et du nombre de messages non lus par canal, actualisés
|
||||
await self.send_json({
|
||||
'type': 'mark_read',
|
||||
'messages': [{'id': message.id, 'channel_id': message.channel_id} async for message in messages.all()],
|
||||
'unread_messages': {group['channel_id']: group['unread_messages']
|
||||
async for group in unread_messages_by_channel.all()},
|
||||
})
|
||||
|
||||
async def start_private_chat(self, user_id: int, **kwargs) -> None:
|
||||
"""
|
||||
L'utilisateur⋅ice souhaite démarrer une conversation privée avec un⋅e autre utilisateur⋅ice.
|
||||
Pour cela, on récupère le salon privé s'il existe, sinon on en crée un.
|
||||
Dans le cas d'une création, les deux personnes sont transférées immédiatement dans ce nouveau canal.
|
||||
|
||||
:param user_id: L'utilisateur⋅rice avec qui démarrer la conversation privée.
|
||||
"""
|
||||
user = self.scope['user']
|
||||
# Récupération de l'autre utilisateur⋅ice avec qui démarrer la conversation
|
||||
other_user = await User.objects.aget(id=user_id)
|
||||
|
||||
# Vérification de l'existence d'un salon privé entre les deux personnes
|
||||
channel_qs = Channel.objects.filter(private=True).filter(invited=user).filter(invited=other_user)
|
||||
if not await channel_qs.aexists():
|
||||
# Le salon privé n'existe pas, on le crée alors
|
||||
channel = await Channel.objects.acreate(
|
||||
name=f"{user.first_name} {user.last_name}, {other_user.first_name} {other_user.last_name}",
|
||||
category=Channel.ChannelCategory.PRIVATE,
|
||||
private=True,
|
||||
)
|
||||
await channel.invited.aset([user, other_user])
|
||||
|
||||
# On s'ajoute au salon privé
|
||||
await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name)
|
||||
|
||||
if user != other_user:
|
||||
# On transfère l'autre utilisateur⋅ice dans le salon privé
|
||||
await self.channel_layer.group_send(f"user-{other_user.id}", {
|
||||
'type': 'chat.start_private_chat',
|
||||
'channel': {
|
||||
'id': channel.id,
|
||||
'name': f"{user.first_name} {user.last_name}",
|
||||
'category': channel.category,
|
||||
'read_access': True,
|
||||
'write_access': True,
|
||||
}
|
||||
})
|
||||
else:
|
||||
# Récupération dudit salon privé
|
||||
channel = await channel_qs.afirst()
|
||||
|
||||
# Invitation de l'autre utilisateur⋅rice à rejoindre le salon privé
|
||||
await self.channel_layer.group_send(f"user-{user.id}", {
|
||||
'type': 'chat.start_private_chat',
|
||||
'channel': {
|
||||
'id': channel.id,
|
||||
'name': f"{other_user.first_name} {other_user.last_name}",
|
||||
'category': channel.category,
|
||||
'read_access': True,
|
||||
'write_access': True,
|
||||
}
|
||||
})
|
||||
|
||||
async def chat_send_message(self, message) -> None:
|
||||
"""
|
||||
Envoi d'un message à tou⋅tes les personnes connectées sur un canal.
|
||||
:param message: Dictionnaire contenant les informations du message à envoyer,
|
||||
contenant l'identifiant du message "id", l'identifiant du canal "channel_id",
|
||||
l'heure de création "timestamp", l'identifiant de l'auteur "author_id",
|
||||
le nom de l'auteur "author" et le contenu du message "content".
|
||||
"""
|
||||
await self.send_json({'type': 'send_message', 'id': message['id'], 'channel_id': message['channel_id'],
|
||||
'timestamp': message['timestamp'], 'author': message['author'],
|
||||
'content': message['content']})
|
||||
|
||||
async def chat_edit_message(self, message) -> None:
|
||||
"""
|
||||
Envoi d'une modification de message à tou⋅tes les personnes connectées sur un canal.
|
||||
:param message: Dictionnaire contenant les informations du message à modifier,
|
||||
contenant l'identifiant du message "id", l'identifiant du canal "channel_id"
|
||||
et le nouveau contenu "content".
|
||||
"""
|
||||
await self.send_json({'type': 'edit_message', 'id': message['id'], 'channel_id': message['channel_id'],
|
||||
'content': message['content']})
|
||||
|
||||
async def chat_delete_message(self, message) -> None:
|
||||
"""
|
||||
Envoi d'une suppression de message à tou⋅tes les personnes connectées sur un canal.
|
||||
:param message: Dictionnaire contenant les informations du message à supprimer,
|
||||
contenant l'identifiant du message "id" et l'identifiant du canal "channel_id".
|
||||
"""
|
||||
await self.send_json({'type': 'delete_message', 'id': message['id'], 'channel_id': message['channel_id']})
|
||||
|
||||
async def chat_start_private_chat(self, message) -> None:
|
||||
"""
|
||||
Envoi d'un message pour démarrer une conversation privée à une personne connectée.
|
||||
:param message: Dictionnaire contenant les informations du nouveau canal privé.
|
||||
"""
|
||||
await self.channel_layer.group_add(f"chat-{message['channel']['id']}", self.channel_name)
|
||||
await self.send_json({'type': 'start_private_chat', 'channel': message['channel']})
|
0
chat/management/__init__.py
Normal file
0
chat/management/commands/__init__.py
Normal file
167
chat/management/commands/create_chat_channels.py
Normal file
@ -0,0 +1,167 @@
|
||||
# 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
|
||||
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(settings.PREFERRED_LANGUAGE_CODE)
|
||||
|
||||
# Création de canaux généraux, d'annonces, d'aide jurys et orgas, etc.
|
||||
# Le canal d'annonces est accessibles à tous⋅tes, mais seul⋅es les admins peuvent y écrire.
|
||||
Channel.objects.update_or_create(
|
||||
name="Annonces",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.GENERAL,
|
||||
read_access=PermissionType.AUTHENTICATED,
|
||||
write_access=PermissionType.ADMIN,
|
||||
),
|
||||
)
|
||||
|
||||
# Un canal d'aide pour les bénévoles est dédié.
|
||||
Channel.objects.update_or_create(
|
||||
name="Aide jurys et orgas",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.GENERAL,
|
||||
read_access=PermissionType.VOLUNTEER,
|
||||
write_access=PermissionType.VOLUNTEER,
|
||||
),
|
||||
)
|
||||
|
||||
# Un canal de discussion générale en lien avec le tournoi est accessible librement.
|
||||
Channel.objects.update_or_create(
|
||||
name="Général",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.GENERAL,
|
||||
read_access=PermissionType.AUTHENTICATED,
|
||||
write_access=PermissionType.AUTHENTICATED,
|
||||
),
|
||||
)
|
||||
|
||||
# Un canal de discussion entre participant⋅es est accessible à tous⋅tes,
|
||||
# dont l'objectif est de faciliter la mise en relation entre élèves afin de constituer une équipe.
|
||||
Channel.objects.update_or_create(
|
||||
name="Je cherche une équipe",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.GENERAL,
|
||||
read_access=PermissionType.AUTHENTICATED,
|
||||
write_access=PermissionType.AUTHENTICATED,
|
||||
),
|
||||
)
|
||||
|
||||
# Un canal de discussion libre est accessible pour tous⋅tes.
|
||||
Channel.objects.update_or_create(
|
||||
name="Détente",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.GENERAL,
|
||||
read_access=PermissionType.AUTHENTICATED,
|
||||
write_access=PermissionType.AUTHENTICATED,
|
||||
),
|
||||
)
|
||||
|
||||
for tournament in Tournament.objects.all():
|
||||
# Pour chaque tournoi, on crée un canal d'annonces, un canal général et un de détente,
|
||||
# qui sont comme les canaux généraux du même nom mais réservés aux membres du tournoi concerné.
|
||||
# Les membres d'un tournoi sont les organisateur⋅rices, les juré⋅es d'une poule du tournoi
|
||||
# ainsi que les membres d'une équipe inscrite au tournoi et qui est validée.
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Annonces",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.TOURNAMENT_MEMBER,
|
||||
write_access=PermissionType.TOURNAMENT_ORGANIZER,
|
||||
tournament=tournament,
|
||||
),
|
||||
)
|
||||
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Général",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.TOURNAMENT_MEMBER,
|
||||
write_access=PermissionType.TOURNAMENT_MEMBER,
|
||||
tournament=tournament,
|
||||
),
|
||||
)
|
||||
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Détente",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.TOURNAMENT_MEMBER,
|
||||
write_access=PermissionType.TOURNAMENT_MEMBER,
|
||||
tournament=tournament,
|
||||
),
|
||||
)
|
||||
|
||||
# Un canal réservé à tous⋅tes les juré⋅es du tournoi est créé.
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Juré⋅es",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.JURY_MEMBER,
|
||||
write_access=PermissionType.JURY_MEMBER,
|
||||
tournament=tournament,
|
||||
),
|
||||
)
|
||||
|
||||
if tournament.remote:
|
||||
# Dans le cadre d'un tournoi distanciel, un canal pour les président⋅es de jury est créé.
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Président⋅es de jury",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.TOURNAMENT_JURY_PRESIDENT,
|
||||
write_access=PermissionType.TOURNAMENT_JURY_PRESIDENT,
|
||||
tournament=tournament,
|
||||
),
|
||||
)
|
||||
|
||||
for pool in tournament.pools.all():
|
||||
# Pour chaque poule d'un tournoi distanciel, on crée un canal pour les membres de la poule
|
||||
# (équipes et juré⋅es), et un pour les juré⋅es uniquement.
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Poule {pool.short_name}",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.POOL_MEMBER,
|
||||
write_access=PermissionType.POOL_MEMBER,
|
||||
pool=pool,
|
||||
),
|
||||
)
|
||||
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Poule {pool.short_name} - Jury",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.JURY_MEMBER,
|
||||
write_access=PermissionType.JURY_MEMBER,
|
||||
pool=pool,
|
||||
),
|
||||
)
|
||||
|
||||
for team in Team.objects.filter(participation__valid=True).all():
|
||||
# Chaque équipe validée a le droit à son canal de communication.
|
||||
Channel.objects.update_or_create(
|
||||
name=f"Équipe {team.trigram}",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TEAM,
|
||||
read_access=PermissionType.TEAM_MEMBER,
|
||||
write_access=PermissionType.TEAM_MEMBER,
|
||||
team=team,
|
||||
),
|
||||
)
|
200
chat/migrations/0001_initial.py
Normal file
@ -0,0 +1,200 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-27 07:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("participation", "0013_alter_pool_options_pool_room"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Channel",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255, verbose_name="name")),
|
||||
(
|
||||
"read_access",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("anonymous", "Everyone, including anonymous users"),
|
||||
("authenticated", "Authenticated users"),
|
||||
("volunteer", "All volunteers"),
|
||||
("tournament", "All members of a given tournament"),
|
||||
("organizer", "Tournament organizers only"),
|
||||
(
|
||||
"jury_president",
|
||||
"Tournament organizers and jury presidents of the tournament",
|
||||
),
|
||||
("jury", "Jury members of the pool"),
|
||||
("pool", "Jury members and participants of the pool"),
|
||||
(
|
||||
"team",
|
||||
"Members of the team and organizers of concerned tournaments",
|
||||
),
|
||||
(
|
||||
"private",
|
||||
"Private, reserved to explicit authorized users",
|
||||
),
|
||||
("admin", "Admin users"),
|
||||
],
|
||||
max_length=16,
|
||||
verbose_name="read permission",
|
||||
),
|
||||
),
|
||||
(
|
||||
"write_access",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("anonymous", "Everyone, including anonymous users"),
|
||||
("authenticated", "Authenticated users"),
|
||||
("volunteer", "All volunteers"),
|
||||
("tournament", "All members of a given tournament"),
|
||||
("organizer", "Tournament organizers only"),
|
||||
(
|
||||
"jury_president",
|
||||
"Tournament organizers and jury presidents of the tournament",
|
||||
),
|
||||
("jury", "Jury members of the pool"),
|
||||
("pool", "Jury members and participants of the pool"),
|
||||
(
|
||||
"team",
|
||||
"Members of the team and organizers of concerned tournaments",
|
||||
),
|
||||
(
|
||||
"private",
|
||||
"Private, reserved to explicit authorized users",
|
||||
),
|
||||
("admin", "Admin users"),
|
||||
],
|
||||
max_length=16,
|
||||
verbose_name="write permission",
|
||||
),
|
||||
),
|
||||
(
|
||||
"private",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="If checked, only users who have been explicitly added to the channel will be able to access it.",
|
||||
verbose_name="private",
|
||||
),
|
||||
),
|
||||
(
|
||||
"invited",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Extra users who have been invited to the channel, in addition to the permitted group of the channel.",
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="invited users",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pool",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="For a permission that concerns a pool, indicates what is the concerned pool.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="chat_channels",
|
||||
to="participation.pool",
|
||||
verbose_name="pool",
|
||||
),
|
||||
),
|
||||
(
|
||||
"team",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="For a permission that concerns a team, indicates what is the concerned team.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="chat_channels",
|
||||
to="participation.team",
|
||||
verbose_name="team",
|
||||
),
|
||||
),
|
||||
(
|
||||
"tournament",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="For a permission that concerns a tournament, indicates what is the concerned tournament.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="chat_channels",
|
||||
to="participation.tournament",
|
||||
verbose_name="tournament",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "channel",
|
||||
"verbose_name_plural": "channels",
|
||||
"ordering": ("name",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Message",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="updated at"),
|
||||
),
|
||||
("content", models.TextField(verbose_name="content")),
|
||||
(
|
||||
"author",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="chat_messages",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="author",
|
||||
),
|
||||
),
|
||||
(
|
||||
"channel",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="messages",
|
||||
to="chat.channel",
|
||||
verbose_name="channel",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "message",
|
||||
"verbose_name_plural": "messages",
|
||||
"ordering": ("created_at",),
|
||||
},
|
||||
),
|
||||
]
|
@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-28 11:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("chat", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="channel",
|
||||
options={
|
||||
"ordering": ("category", "name"),
|
||||
"verbose_name": "channel",
|
||||
"verbose_name_plural": "channels",
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="channel",
|
||||
name="category",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("general", "General channels"),
|
||||
("tournament", "Tournament channels"),
|
||||
("team", "Team channels"),
|
||||
("private", "Private channels"),
|
||||
],
|
||||
default="general",
|
||||
max_length=255,
|
||||
verbose_name="category",
|
||||
),
|
||||
),
|
||||
]
|
26
chat/migrations/0003_message_users_read.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-28 18:52
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("chat", "0002_alter_channel_options_channel_category"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="message",
|
||||
name="users_read",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Users who have read the message.",
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="users read",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,94 @@
|
||||
# Generated by Django 5.0.6 on 2024-05-26 20:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("chat", "0003_message_users_read"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="channel",
|
||||
name="category",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("general", "General channels"),
|
||||
("tournament", "Tournament channels"),
|
||||
("team", "Team channels"),
|
||||
("private", "Private channels"),
|
||||
],
|
||||
default="general",
|
||||
help_text="Category of the channel, between general channels, tournament-specific channels, team channels or private channels. Will be used to sort channels in the channel list.",
|
||||
max_length=255,
|
||||
verbose_name="category",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="channel",
|
||||
name="name",
|
||||
field=models.CharField(
|
||||
help_text="Visible name of the channel.",
|
||||
max_length=255,
|
||||
verbose_name="name",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="channel",
|
||||
name="read_access",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("anonymous", "Everyone, including anonymous users"),
|
||||
("authenticated", "Authenticated users"),
|
||||
("volunteer", "All volunteers"),
|
||||
("tournament", "All members of a given tournament"),
|
||||
("organizer", "Tournament organizers only"),
|
||||
(
|
||||
"jury_president",
|
||||
"Tournament organizers and jury presidents of the tournament",
|
||||
),
|
||||
("jury", "Jury members of the pool"),
|
||||
("pool", "Jury members and participants of the pool"),
|
||||
(
|
||||
"team",
|
||||
"Members of the team and organizers of concerned tournaments",
|
||||
),
|
||||
("private", "Private, reserved to explicit authorized users"),
|
||||
("admin", "Admin users"),
|
||||
],
|
||||
help_text="Permission type that is required to read the messages of the channels.",
|
||||
max_length=16,
|
||||
verbose_name="read permission",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="channel",
|
||||
name="write_access",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("anonymous", "Everyone, including anonymous users"),
|
||||
("authenticated", "Authenticated users"),
|
||||
("volunteer", "All volunteers"),
|
||||
("tournament", "All members of a given tournament"),
|
||||
("organizer", "Tournament organizers only"),
|
||||
(
|
||||
"jury_president",
|
||||
"Tournament organizers and jury presidents of the tournament",
|
||||
),
|
||||
("jury", "Jury members of the pool"),
|
||||
("pool", "Jury members and participants of the pool"),
|
||||
(
|
||||
"team",
|
||||
"Members of the team and organizers of concerned tournaments",
|
||||
),
|
||||
("private", "Private, reserved to explicit authorized users"),
|
||||
("admin", "Admin users"),
|
||||
],
|
||||
help_text="Permission type that is required to write a message to a channel.",
|
||||
max_length=16,
|
||||
verbose_name="write permission",
|
||||
),
|
||||
),
|
||||
]
|
2
chat/migrations/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
365
chat/models.py
Normal file
@ -0,0 +1,365 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.utils.text import format_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from participation.models import Pool, Team, Tournament
|
||||
from registration.models import ParticipantRegistration, Registration, VolunteerRegistration
|
||||
from tfjm.permissions import PermissionType
|
||||
|
||||
|
||||
class Channel(models.Model):
|
||||
"""
|
||||
Ce modèle représente un canal de chat, défini par son nom, sa catégorie, les permissions de lecture et d'écriture
|
||||
requises pour accéder au canal, et éventuellement un tournoi, une poule ou une équipe associée.
|
||||
"""
|
||||
|
||||
class ChannelCategory(models.TextChoices):
|
||||
GENERAL = 'general', _("General channels")
|
||||
TOURNAMENT = 'tournament', _("Tournament channels")
|
||||
TEAM = 'team', _("Team channels")
|
||||
PRIVATE = 'private', _("Private channels")
|
||||
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("name"),
|
||||
help_text=_("Visible name of the channel."),
|
||||
)
|
||||
|
||||
category = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("category"),
|
||||
choices=ChannelCategory,
|
||||
default=ChannelCategory.GENERAL,
|
||||
help_text=_("Category of the channel, between general channels, tournament-specific channels, team channels "
|
||||
"or private channels. Will be used to sort channels in the channel list."),
|
||||
)
|
||||
|
||||
read_access = models.CharField(
|
||||
max_length=16,
|
||||
verbose_name=_("read permission"),
|
||||
choices=PermissionType,
|
||||
help_text=_("Permission type that is required to read the messages of the channels."),
|
||||
)
|
||||
|
||||
write_access = models.CharField(
|
||||
max_length=16,
|
||||
verbose_name=_("write permission"),
|
||||
choices=PermissionType,
|
||||
help_text=_("Permission type that is required to write a message to a channel."),
|
||||
)
|
||||
|
||||
tournament = models.ForeignKey(
|
||||
'participation.Tournament',
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("tournament"),
|
||||
related_name='chat_channels',
|
||||
help_text=_("For a permission that concerns a tournament, indicates what is the concerned tournament."),
|
||||
)
|
||||
|
||||
pool = models.ForeignKey(
|
||||
'participation.Pool',
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("pool"),
|
||||
related_name='chat_channels',
|
||||
help_text=_("For a permission that concerns a pool, indicates what is the concerned pool."),
|
||||
)
|
||||
|
||||
team = models.ForeignKey(
|
||||
'participation.Team',
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("team"),
|
||||
related_name='chat_channels',
|
||||
help_text=_("For a permission that concerns a team, indicates what is the concerned team."),
|
||||
)
|
||||
|
||||
private = models.BooleanField(
|
||||
verbose_name=_("private"),
|
||||
default=False,
|
||||
help_text=_("If checked, only users who have been explicitly added to the channel will be able to access it."),
|
||||
)
|
||||
|
||||
invited = models.ManyToManyField(
|
||||
'auth.User',
|
||||
verbose_name=_("invited users"),
|
||||
related_name='+',
|
||||
blank=True,
|
||||
help_text=_("Extra users who have been invited to the channel, "
|
||||
"in addition to the permitted group of the channel."),
|
||||
)
|
||||
|
||||
def get_visible_name(self, user: User) -> str:
|
||||
"""
|
||||
Renvoie le nom du channel tel qu'il est visible pour l'utilisateur⋅rice donné.
|
||||
Dans le cas d'un canal classique, renvoie directement le nom.
|
||||
Dans le cas d'un canal privé, renvoie la liste des personnes membres du canal,
|
||||
à l'exception de la personne connectée, afin de ne pas afficher son propre nom.
|
||||
Dans le cas d'un chat avec uniquement soi-même, on affiche que notre propre nom.
|
||||
"""
|
||||
if self.private:
|
||||
# Le canal est privé, on renvoie la liste des personnes membres du canal
|
||||
# à l'exception de soi-même (sauf si on est la seule personne dans le canal)
|
||||
users = [f"{u.first_name} {u.last_name}" for u in self.invited.all() if u != user] \
|
||||
or [f"{user.first_name} {user.last_name}"]
|
||||
return ", ".join(users)
|
||||
# Le canal est public, on renvoie directement le nom
|
||||
return self.name
|
||||
|
||||
def __str__(self):
|
||||
return str(format_lazy(_("Channel {name}"), name=self.name))
|
||||
|
||||
@staticmethod
|
||||
async def get_accessible_channels(user: User, permission_type: str = 'read') -> QuerySet["Channel"]:
|
||||
"""
|
||||
Renvoie les canaux auxquels l'utilisateur⋅rice donné a accès, en lecture ou en écriture.
|
||||
|
||||
Types de permissions :
|
||||
ANONYMOUS : Tout le monde, y compris les utilisateur⋅rices non connecté⋅es
|
||||
AUTHENTICATED : Toustes les utilisateur⋅rices connecté⋅es
|
||||
VOLUNTEER : Toustes les bénévoles
|
||||
TOURNAMENT_MEMBER : Toustes les membres d'un tournoi donné (orgas, juré⋅es, participant⋅es)
|
||||
TOURNAMENT_ORGANIZER : Les organisateur⋅rices d'un tournoi donné
|
||||
TOURNAMENT_JURY_PRESIDENT : Les organisateur⋅rices et les président⋅es de jury d'un tournoi donné
|
||||
JURY_MEMBER : Les membres du jury d'une poule donnée, ou les organisateur⋅rices du tournoi
|
||||
POOL_MEMBER : Les membres du jury et les participant⋅es d'une poule donnée, ou les organisateur⋅rices du tournoi
|
||||
TEAM_MEMBER : Les membres d'une équipe donnée
|
||||
PRIVATE : Les utilisateur⋅rices explicitement invité⋅es
|
||||
ADMIN : Les utilisateur⋅rices administrateur⋅rices (qui ont accès à tout)
|
||||
|
||||
Les canaux privés sont utilisés pour les messages privés, et ne sont pas affichés aux admins.
|
||||
|
||||
:param user: L'utilisateur⋅rice dont on veut récupérer la liste des canaux.
|
||||
:param permission_type: Le type de permission concerné (read ou write).
|
||||
:return: Le Queryset des canaux autorisés.
|
||||
"""
|
||||
permission_type = 'write_access' if 'write' in permission_type.lower() else 'read_access'
|
||||
|
||||
qs = Channel.objects.none()
|
||||
if user.is_anonymous:
|
||||
# Les utilisateur⋅rices non connecté⋅es ont accès aux canaux publics pour toustes
|
||||
return Channel.objects.filter(**{permission_type: PermissionType.ANONYMOUS})
|
||||
|
||||
# Les utilisateur⋅rices connecté⋅es ont accès aux canaux publics pour les personnes connectées
|
||||
qs |= Channel.objects.filter(**{permission_type: PermissionType.AUTHENTICATED})
|
||||
registration = await Registration.objects.prefetch_related('user').aget(user_id=user.id)
|
||||
|
||||
if registration.is_admin:
|
||||
# Les administrateur⋅rices ont accès à tous les canaux, sauf les canaux privés sont iels ne sont pas membres
|
||||
return Channel.objects.prefetch_related('invited').exclude(~Q(invited=user) & Q(private=True)).all()
|
||||
|
||||
if registration.is_volunteer:
|
||||
registration = await VolunteerRegistration.objects \
|
||||
.prefetch_related('jury_in__tournament', 'organized_tournaments').aget(user_id=user.id)
|
||||
|
||||
# Les bénévoles ont accès aux canaux pour bénévoles
|
||||
qs |= Channel.objects.filter(**{permission_type: PermissionType.VOLUNTEER})
|
||||
|
||||
# Iels ont accès aux tournois dont iels sont organisateur⋅rices ou juré⋅es
|
||||
# pour la permission TOURNAMENT_MEMBER
|
||||
qs |= Channel.objects.filter(Q(tournament__in=registration.interesting_tournaments),
|
||||
**{permission_type: PermissionType.TOURNAMENT_MEMBER})
|
||||
|
||||
# Iels ont accès aux canaux pour les organisateur⋅rices des tournois dont iels sont organisateur⋅rices
|
||||
# pour la permission TOURNAMENT_ORGANIZER
|
||||
qs |= Channel.objects.filter(Q(tournament__in=registration.organized_tournaments.all()),
|
||||
**{permission_type: PermissionType.TOURNAMENT_ORGANIZER})
|
||||
|
||||
# Iels ont accès aux canaux pour les organisateur⋅rices et président⋅es de jury des tournois dont iels sont
|
||||
# organisateur⋅rices ou juré⋅es pour la permission TOURNAMENT_JURY_PRESIDENT
|
||||
qs |= Channel.objects.filter(Q(tournament__pools__in=registration.pools_presided.all())
|
||||
| Q(tournament__in=registration.organized_tournaments.all()),
|
||||
**{permission_type: PermissionType.TOURNAMENT_JURY_PRESIDENT})
|
||||
|
||||
# Iels ont accès aux canaux pour les juré⋅es des poules dont iels sont juré⋅es
|
||||
# ou les organisateur⋅rices des tournois dont iels sont organisateur⋅rices
|
||||
# pour la permission JURY_MEMBER
|
||||
qs |= Channel.objects.filter(Q(pool__in=registration.jury_in.all())
|
||||
| Q(pool__tournament__in=registration.organized_tournaments.all())
|
||||
| Q(pool__tournament__pools__in=registration.pools_presided.all()),
|
||||
**{permission_type: PermissionType.JURY_MEMBER})
|
||||
|
||||
# Iels ont accès aux canaux pour les juré⋅es et participant⋅es des poules dont iels sont juré⋅es
|
||||
# ou les organisateur⋅rices des tournois dont iels sont organisateur⋅rices
|
||||
# pour la permission POOL_MEMBER
|
||||
qs |= Channel.objects.filter(Q(pool__in=registration.jury_in.all())
|
||||
| Q(pool__tournament__in=registration.organized_tournaments.all())
|
||||
| Q(pool__tournament__pools__in=registration.pools_presided.all()),
|
||||
**{permission_type: PermissionType.POOL_MEMBER})
|
||||
else:
|
||||
registration = await ParticipantRegistration.objects \
|
||||
.prefetch_related('team__participation__pools', 'team__participation__tournament').aget(user_id=user.id)
|
||||
|
||||
team = registration.team
|
||||
tournaments = []
|
||||
if team.participation.valid:
|
||||
tournaments.append(team.participation.tournament)
|
||||
if team.participation.final:
|
||||
tournaments.append(await Tournament.objects.aget(final=True))
|
||||
|
||||
# Les participant⋅es ont accès aux canaux généraux pour le tournoi dont iels sont membres
|
||||
# Cela comprend la finale s'iels sont finalistes
|
||||
qs |= Channel.objects.filter(Q(tournament__in=tournaments),
|
||||
**{permission_type: PermissionType.TOURNAMENT_MEMBER})
|
||||
|
||||
# Iels ont accès aux canaux généraux pour les poules dont iels sont participant⋅es
|
||||
qs |= Channel.objects.filter(Q(pool__in=team.participation.pools.all()),
|
||||
**{permission_type: PermissionType.POOL_MEMBER})
|
||||
|
||||
# Iels ont accès aux canaux propres à leur équipe
|
||||
qs |= Channel.objects.filter(Q(team=team),
|
||||
**{permission_type: PermissionType.TEAM_MEMBER})
|
||||
|
||||
# Les utilisateur⋅rices ont de plus accès aux messages privés qui leur sont adressés
|
||||
qs |= Channel.objects.filter(invited=user).prefetch_related('invited')
|
||||
|
||||
return qs
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("channel")
|
||||
verbose_name_plural = _("channels")
|
||||
ordering = ('category', 'name',)
|
||||
|
||||
|
||||
class Message(models.Model):
|
||||
"""
|
||||
Ce modèle représente un message de chat.
|
||||
Un message appartient à un canal, et est défini par son contenu, son auteur⋅rice, sa date de création et sa date
|
||||
de dernière modification.
|
||||
De plus, on garde en mémoire les utilisateur⋅rices qui ont lu le message.
|
||||
"""
|
||||
channel = models.ForeignKey(
|
||||
Channel,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_("channel"),
|
||||
related_name='messages',
|
||||
)
|
||||
|
||||
author = models.ForeignKey(
|
||||
'auth.User',
|
||||
verbose_name=_("author"),
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='chat_messages',
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(
|
||||
verbose_name=_("created at"),
|
||||
auto_now_add=True,
|
||||
)
|
||||
|
||||
updated_at = models.DateTimeField(
|
||||
verbose_name=_("updated at"),
|
||||
auto_now=True,
|
||||
)
|
||||
|
||||
content = models.TextField(
|
||||
verbose_name=_("content"),
|
||||
)
|
||||
|
||||
users_read = models.ManyToManyField(
|
||||
'auth.User',
|
||||
verbose_name=_("users read"),
|
||||
related_name='+',
|
||||
blank=True,
|
||||
help_text=_("Users who have read the message."),
|
||||
)
|
||||
|
||||
def get_author_name(self) -> str:
|
||||
"""
|
||||
Renvoie le nom de l'auteur⋅rice du message, en fonction de son rôle dans l'organisation
|
||||
dans le cadre d'un⋅e bénévole, ou de son équipe dans le cadre d'un⋅e participant⋅e.
|
||||
"""
|
||||
registration = self.author.registration
|
||||
|
||||
author_name = f"{self.author.first_name} {self.author.last_name}"
|
||||
if registration.is_volunteer:
|
||||
if registration.is_admin:
|
||||
# Les administrateur⋅rices ont le suffixe (CNO)
|
||||
author_name += " (CNO)"
|
||||
|
||||
if self.channel.pool:
|
||||
if registration == self.channel.pool.jury_president:
|
||||
# Læ président⋅e de jury de la poule a le suffixe (P. jury)
|
||||
author_name += " (P. jury)"
|
||||
elif registration in self.channel.pool.juries.all():
|
||||
# Les juré⋅es de la poule ont le suffixe (Juré⋅e)
|
||||
author_name += " (Juré⋅e)"
|
||||
elif registration in self.channel.pool.tournament.organizers.all():
|
||||
# Les organisateur⋅rices du tournoi ont le suffixe (CRO)
|
||||
author_name += " (CRO)"
|
||||
else:
|
||||
# Les éventuel⋅les autres bénévoles ont le suffixe (Bénévole)
|
||||
author_name += " (Bénévole)"
|
||||
elif self.channel.tournament:
|
||||
if registration in self.channel.tournament.organizers.all():
|
||||
# Les organisateur⋅rices du tournoi ont le suffixe (CRO)
|
||||
author_name += " (CRO)"
|
||||
elif any([registration.id == pool.jury_president
|
||||
for pool in self.channel.tournament.pools.all()]):
|
||||
# Les président⋅es de jury des poules ont le suffixe (P. jury)
|
||||
# mentionnant l'ensemble des poules qu'iels président
|
||||
pools = ", ".join([pool.short_name
|
||||
for pool in self.channel.tournament.pools.all()
|
||||
if pool.jury_president == registration])
|
||||
author_name += f" (P. jury {pools})"
|
||||
elif any([pool.juries.contains(registration)
|
||||
for pool in self.channel.tournament.pools.all()]):
|
||||
# Les juré⋅es des poules ont le suffixe (Juré⋅e)
|
||||
# mentionnant l'ensemble des poules auxquelles iels participent
|
||||
pools = ", ".join([pool.short_name
|
||||
for pool in self.channel.tournament.pools.all()
|
||||
if pool.juries.acontains(registration)])
|
||||
author_name += f" (Juré⋅e {pools})"
|
||||
else:
|
||||
# Les éventuel⋅les autres bénévoles ont le suffixe (Bénévole)
|
||||
author_name += " (Bénévole)"
|
||||
else:
|
||||
if registration.organized_tournaments.exists():
|
||||
# Les organisateur⋅rices de tournois ont le suffixe (CRO) mentionnant les tournois organisés
|
||||
tournaments = ", ".join([tournament.name
|
||||
for tournament in registration.organized_tournaments.all()])
|
||||
author_name += f" (CRO {tournaments})"
|
||||
if Pool.objects.filter(jury_president=registration).exists():
|
||||
# Les président⋅es de jury ont le suffixe (P. jury) mentionnant les tournois présidés
|
||||
tournaments = Tournament.objects.filter(pools__jury_president=registration).distinct()
|
||||
tournaments = ", ".join([tournament.name for tournament in tournaments])
|
||||
author_name += f" (P. jury {tournaments})"
|
||||
elif registration.jury_in.exists():
|
||||
# Les juré⋅es ont le suffixe (Juré⋅e) mentionnant les tournois auxquels iels participent
|
||||
tournaments = Tournament.objects.filter(pools__juries=registration).distinct()
|
||||
tournaments = ", ".join([tournament.name for tournament in tournaments])
|
||||
author_name += f" (Juré⋅e {tournaments})"
|
||||
else:
|
||||
if registration.team_id:
|
||||
# Le trigramme de l'équipe de læ participant⋅e est ajouté en suffixe
|
||||
team = Team.objects.get(id=registration.team_id)
|
||||
author_name += f" ({team.trigram})"
|
||||
else:
|
||||
author_name += " (sans équipe)"
|
||||
|
||||
return author_name
|
||||
|
||||
async def aget_author_name(self) -> str:
|
||||
"""
|
||||
Fonction asynchrone pour récupérer le nom de l'auteur⋅rice du message.
|
||||
Voir `get_author_name` pour plus de détails.
|
||||
"""
|
||||
return await sync_to_async(self.get_author_name)()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("message")
|
||||
verbose_name_plural = _("messages")
|
||||
ordering = ('created_at',)
|
120
chat/signals.py
Normal file
@ -0,0 +1,120 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from chat.models import Channel
|
||||
from participation.models import Participation, Pool, Tournament
|
||||
from tfjm.permissions import PermissionType
|
||||
|
||||
|
||||
def create_tournament_channels(instance: Tournament, **_kwargs):
|
||||
"""
|
||||
Lorsqu'un tournoi est créé, on crée les canaux de chat associés.
|
||||
On crée notamment un canal d'annonces (accessible en écriture uniquement aux orgas),
|
||||
un canal général, un de détente, un pour les juré⋅es et un pour les président⋅es de jury.
|
||||
"""
|
||||
tournament = instance
|
||||
|
||||
# Création du canal « Tournoi - Annonces »
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Annonces",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.TOURNAMENT_MEMBER,
|
||||
write_access=PermissionType.TOURNAMENT_ORGANIZER,
|
||||
tournament=tournament,
|
||||
),
|
||||
)
|
||||
|
||||
# Création du canal « Tournoi - Général »
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Général",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.TOURNAMENT_MEMBER,
|
||||
write_access=PermissionType.TOURNAMENT_MEMBER,
|
||||
tournament=tournament,
|
||||
),
|
||||
)
|
||||
|
||||
# Création du canal « Tournoi - Détente »
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Détente",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.TOURNAMENT_MEMBER,
|
||||
write_access=PermissionType.TOURNAMENT_MEMBER,
|
||||
tournament=tournament,
|
||||
),
|
||||
)
|
||||
|
||||
# Création du canal « Tournoi - Juré⋅es »
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Juré⋅es",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.JURY_MEMBER,
|
||||
write_access=PermissionType.JURY_MEMBER,
|
||||
tournament=tournament,
|
||||
),
|
||||
)
|
||||
|
||||
if tournament.remote:
|
||||
# Création du canal « Tournoi - Président⋅es de jury » dans le cas d'un tournoi distanciel
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Président⋅es de jury",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.TOURNAMENT_JURY_PRESIDENT,
|
||||
write_access=PermissionType.TOURNAMENT_JURY_PRESIDENT,
|
||||
tournament=tournament,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def create_pool_channels(instance: Pool, **_kwargs):
|
||||
"""
|
||||
Lorsqu'une poule est créée, on crée les canaux de chat associés.
|
||||
On crée notamment un canal pour les membres de la poule et un pour les juré⋅es.
|
||||
Cela ne concerne que les tournois distanciels.
|
||||
"""
|
||||
pool = instance
|
||||
tournament = pool.tournament
|
||||
|
||||
if tournament.remote:
|
||||
# Dans le cadre d'un tournoi distanciel, on crée un canal pour les membres de la poule
|
||||
# et un pour les juré⋅es de la poule.
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Poule {pool.short_name}",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.POOL_MEMBER,
|
||||
write_access=PermissionType.POOL_MEMBER,
|
||||
pool=pool,
|
||||
),
|
||||
)
|
||||
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Poule {pool.short_name} - Jury",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.JURY_MEMBER,
|
||||
write_access=PermissionType.JURY_MEMBER,
|
||||
pool=pool,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def create_team_channel(instance: Participation, **_kwargs):
|
||||
"""
|
||||
Lorsqu'une équipe est validée, on crée un canal de chat associé.
|
||||
"""
|
||||
if instance.valid:
|
||||
Channel.objects.update_or_create(
|
||||
name=f"Équipe {instance.team.trigram}",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TEAM,
|
||||
read_access=PermissionType.TEAM_MEMBER,
|
||||
write_access=PermissionType.TEAM_MEMBER,
|
||||
team=instance.team,
|
||||
),
|
||||
)
|
17
chat/static/tfjm/chat_eteam.webmanifest
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"background_color": "white",
|
||||
"description": "Chat for ETEAM",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/tfjm/img/eteam.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"name": "ETEAM Chat",
|
||||
"short_name": "ETEAM Chat",
|
||||
"start_url": "/chat/fullscreen",
|
||||
"theme_color": "black"
|
||||
}
|
29
chat/static/tfjm/chat_tfjm.webmanifest
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"background_color": "white",
|
||||
"description": "Chat pour le TFJM²",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/tfjm/img/tfjm-square.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/tfjm/img/tfjm-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/tfjm/img/tfjm-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"name": "Chat TFJM²",
|
||||
"short_name": "Chat TFJM²",
|
||||
"start_url": "/chat/fullscreen",
|
||||
"theme_color": "black"
|
||||
}
|
912
chat/static/tfjm/js/chat.js
Normal file
@ -0,0 +1,912 @@
|
||||
(async () => {
|
||||
// Vérification de la permission pour envoyer des notifications
|
||||
// C'est utile pour prévenir les utilisateur⋅rices de l'arrivée de nouveaux messages les mentionnant
|
||||
await Notification.requestPermission()
|
||||
})()
|
||||
|
||||
const MAX_MESSAGES = 50 // Nombre maximal de messages à charger à la fois
|
||||
|
||||
const channel_categories = ['general', 'tournament', 'team', 'private'] // Liste des catégories de canaux
|
||||
let channels = {} // Liste des canaux disponibles
|
||||
let messages = {} // Liste des messages reçus par canal
|
||||
let selected_channel_id = null // Canal courant
|
||||
|
||||
/**
|
||||
* Affiche une nouvelle notification avec le titre donné et le contenu donné.
|
||||
* @param title Le titre de la notification
|
||||
* @param body Le contenu de la notification
|
||||
* @param timeout La durée (en millisecondes) après laquelle la notification se ferme automatiquement.
|
||||
* Définir à 0 (défaut) pour la rendre infinie.
|
||||
* @return Notification
|
||||
*/
|
||||
function showNotification(title, body, timeout = 0) {
|
||||
Notification.requestPermission().then((status) => {
|
||||
if (status === 'granted') {
|
||||
// On envoie la notification que si la permission a été donnée
|
||||
let notif = new Notification(title, {'body': body, 'icon': "/static/tfjm-192.png"})
|
||||
if (timeout > 0)
|
||||
setTimeout(() => notif.close(), timeout)
|
||||
return notif
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sélectionne le canal courant à afficher sur l'interface de chat.
|
||||
* Va alors définir le canal courant et mettre à jour les messages affichés.
|
||||
* @param channel_id L'identifiant du canal à afficher.
|
||||
*/
|
||||
function selectChannel(channel_id) {
|
||||
let channel = channels[channel_id]
|
||||
if (!channel) {
|
||||
// Le canal n'existe pas
|
||||
console.error('Channel not found:', channel_id)
|
||||
return
|
||||
}
|
||||
|
||||
selected_channel_id = channel_id
|
||||
// On stocke dans le stockage local l'identifiant du canal
|
||||
// pour pouvoir rouvrir le dernier canal ouvert dans le futur
|
||||
localStorage.setItem('chat.last-channel-id', channel_id)
|
||||
|
||||
// Définition du titre du contenu
|
||||
let channelTitle = document.getElementById('channel-title')
|
||||
channelTitle.innerText = channel.name
|
||||
|
||||
// Si on a pas le droit d'écrire dans le canal, on désactive l'input de message
|
||||
// On l'active sinon
|
||||
let messageInput = document.getElementById('input-message')
|
||||
messageInput.disabled = !channel.write_access
|
||||
|
||||
// On redessine la liste des messages à partir des messages stockés
|
||||
redrawMessages()
|
||||
}
|
||||
|
||||
/**
|
||||
* On récupère le message écrit par l'utilisateur⋅rice dans le champ de texte idoine,
|
||||
* et on le transmet ensuite au serveur.
|
||||
* Il ne s'affiche pas instantanément sur l'interface,
|
||||
* mais seulement une fois que le serveur aura validé et retransmis le message.
|
||||
*/
|
||||
function sendMessage() {
|
||||
// Récupération du message à envoyer
|
||||
let messageInput = document.getElementById('input-message')
|
||||
let message = messageInput.value
|
||||
// On efface le champ de texte après avoir récupéré le message
|
||||
messageInput.value = ''
|
||||
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
|
||||
// Envoi du message au serveur
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'send_message',
|
||||
'channel_id': selected_channel_id,
|
||||
'content': message,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la liste des canaux disponibles, à partir de la liste récupérée du serveur.
|
||||
* @param new_channels La liste des canaux à afficher.
|
||||
* Chaque canal doit être un objet avec les clés `id`, `name`, `category`
|
||||
* `read_access`, `write_access` et `unread_messages`, correspondant à l'identifiant du canal,
|
||||
* son nom, sa catégorie, la permission de lecture, d'écriture et le nombre de messages non lus.
|
||||
*/
|
||||
function setChannels(new_channels) {
|
||||
channels = {}
|
||||
for (let category of channel_categories) {
|
||||
// On commence par vider la liste des canaux sélectionnables
|
||||
let categoryList = document.getElementById(`nav-${category}-channels-tab`)
|
||||
categoryList.innerHTML = ''
|
||||
categoryList.parentElement.classList.add('d-none')
|
||||
}
|
||||
|
||||
for (let channel of new_channels)
|
||||
// On ajoute chaque canal à la liste des canaux
|
||||
addChannel(channel)
|
||||
|
||||
if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) {
|
||||
// Si aucun canal n'a encore été sélectionné et qu'il y a des canaux disponibles,
|
||||
// on commence par vérifier si on a stocké un canal précédemment sélectionné et on l'affiche si c'est le cas
|
||||
// Sinon, on affiche le premier canal disponible
|
||||
let last_channel_id = parseInt(localStorage.getItem('chat.last-channel-id'))
|
||||
if (last_channel_id && channels[last_channel_id])
|
||||
selectChannel(last_channel_id)
|
||||
else
|
||||
selectChannel(Object.keys(channels)[0])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un canal à la liste des canaux disponibles.
|
||||
* @param channel Le canal à ajouter. Doit être un objet avec les clés `id`, `name`, `category`,
|
||||
* `read_access`, `write_access` et `unread_messages`, correspondant à l'identifiant du canal,
|
||||
* son nom, sa catégorie, la permission de lecture, d'écriture et le nombre de messages non lus.
|
||||
*/
|
||||
async function addChannel(channel) {
|
||||
channels[channel.id] = channel
|
||||
if (!messages[channel.id])
|
||||
messages[channel.id] = new Map()
|
||||
|
||||
// On récupère la liste des canaux de la catégorie concernée
|
||||
let categoryList = document.getElementById(`nav-${channel.category}-channels-tab`)
|
||||
// On la rend visible si elle ne l'était pas déjà
|
||||
categoryList.parentElement.classList.remove('d-none')
|
||||
|
||||
// On crée un nouvel élément de liste pour la catégorie concernant le canal
|
||||
let navItem = document.createElement('li')
|
||||
navItem.classList.add('list-group-item', 'tab-channel')
|
||||
navItem.id = `tab-channel-${channel.id}`
|
||||
navItem.setAttribute('data-bs-dismiss', 'offcanvas')
|
||||
navItem.onclick = () => selectChannel(channel.id)
|
||||
categoryList.appendChild(navItem)
|
||||
|
||||
// L'élément est cliquable afin de sélectionner le canal
|
||||
let channelButton = document.createElement('button')
|
||||
channelButton.classList.add('nav-link')
|
||||
channelButton.type = 'button'
|
||||
channelButton.innerText = channel.name
|
||||
navItem.appendChild(channelButton)
|
||||
|
||||
// Affichage du nombre de messages non lus
|
||||
let unreadBadge = document.createElement('span')
|
||||
unreadBadge.classList.add('badge', 'rounded-pill', 'text-bg-light', 'ms-2')
|
||||
unreadBadge.id = `unread-messages-${channel.id}`
|
||||
unreadBadge.innerText = channel.unread_messages || 0
|
||||
if (!channel.unread_messages)
|
||||
unreadBadge.classList.add('d-none')
|
||||
channelButton.appendChild(unreadBadge)
|
||||
|
||||
// Si on veut trier les canaux par nombre décroissant de messages non lus,
|
||||
// on définit l'ordre de l'élément (propriété CSS) en fonction du nombre de messages non lus
|
||||
if (document.getElementById('sort-by-unread-switch').checked)
|
||||
navItem.style.order = `${-channel.unread_messages}`
|
||||
|
||||
// On demande enfin à récupérer les derniers messages du canal en question afin de les stocker / afficher
|
||||
fetchMessages(channel.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Un⋅e utilisateur⋅rice a envoyé un message, qui a été retransmis par le serveur.
|
||||
* On le stocke alors et on l'affiche sur l'interface si nécessaire.
|
||||
* On affiche également une notification si le message contient une mention pour tout le monde.
|
||||
* @param message Le message qui a été transmis. Doit être un objet avec
|
||||
* les clés `id`, `channel_id`, `author`, `author_id`, `content` et `timestamp`,
|
||||
* correspondant à l'identifiant du message, du canal, le nom de l'auteur⋅rice et l'heure d'envoi.
|
||||
*/
|
||||
function receiveMessage(message) {
|
||||
// On vérifie si la barre de défilement est tout en bas
|
||||
let scrollableContent = document.getElementById('chat-messages')
|
||||
let isScrolledToBottom = scrollableContent.scrollHeight - scrollableContent.clientHeight <= scrollableContent.scrollTop + 1
|
||||
|
||||
// On stocke le message dans la liste des messages du canal concerné
|
||||
// et on redessine les messages affichés si on est dans le canal concerné
|
||||
messages[message.channel_id].set(message.id, message)
|
||||
if (message.channel_id === selected_channel_id)
|
||||
redrawMessages()
|
||||
|
||||
// Si la barre de défilement était tout en bas, alors on la remet tout en bas après avoir redessiné les messages
|
||||
if (isScrolledToBottom)
|
||||
scrollableContent.scrollTop = scrollableContent.scrollHeight - scrollableContent.clientHeight
|
||||
|
||||
// On ajoute un à la liste des messages non lus du canal (il pourra être lu plus tard)
|
||||
updateUnreadBadge(message.channel_id, channels[message.channel_id].unread_messages + 1)
|
||||
|
||||
// Si le message contient une mention à @everyone, alors on envoie une notification (si la permission est donnée)
|
||||
if (message.content.includes("@everyone"))
|
||||
showNotification(channels[message.channel_id].name, `${message.author} : ${message.content}`)
|
||||
|
||||
// On envoie un événement personnalisé pour indiquer que les messages ont été mis à jour
|
||||
// Permettant entre autres de marquer le message comme lu si c'est le cas
|
||||
document.getElementById('message-list').dispatchEvent(new CustomEvent('updatemessages'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Un message a été modifié, et le serveur nous a transmis les nouvelles informations.
|
||||
* @param data Le nouveau message qui a été modifié.
|
||||
*/
|
||||
function editMessage(data) {
|
||||
// On met à jour le contenu du message
|
||||
messages[data.channel_id].get(data.id).content = data.content
|
||||
// Si le message appartient au canal courant, on redessine les messages
|
||||
if (data.channel_id === selected_channel_id)
|
||||
redrawMessages()
|
||||
}
|
||||
|
||||
/**
|
||||
* Un message a été supprimé, et le serveur nous a transmis les informations.
|
||||
* @param data Le message qui a été supprimé.
|
||||
*/
|
||||
function deleteMessage(data) {
|
||||
// On supprime le message de la liste des messages du canal concerné
|
||||
messages[data.channel_id].delete(data.id)
|
||||
// Si le message appartient au canal courant, on redessine les messages
|
||||
if (data.channel_id === selected_channel_id)
|
||||
redrawMessages()
|
||||
}
|
||||
|
||||
/**
|
||||
* Demande au serveur de récupérer les messages du canal donné.
|
||||
* @param channel_id L'identifiant du canal dont on veut récupérer les messages.
|
||||
* @param offset Le décalage à partir duquel on veut récupérer les messages,
|
||||
* correspond au nombre de messages en mémoire.
|
||||
* @param limit Le nombre maximal de messages à récupérer.
|
||||
*/
|
||||
function fetchMessages(channel_id, offset = 0, limit = MAX_MESSAGES) {
|
||||
// Envoi de la requête au serveur avec les différents paramètres
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'fetch_messages',
|
||||
'channel_id': channel_id,
|
||||
'offset': offset,
|
||||
'limit': limit,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Demande au serveur de récupérer les messages précédents du canal courant.
|
||||
* Par défaut, on récupère `MAX_MESSAGES` messages avant tous ceux qui ont été reçus sur ce canal.
|
||||
*/
|
||||
function fetchPreviousMessages() {
|
||||
let channel_id = selected_channel_id
|
||||
let offset = messages[channel_id].size
|
||||
fetchMessages(channel_id, offset, MAX_MESSAGES)
|
||||
}
|
||||
|
||||
/**
|
||||
* L'utilisateur⋅rice a demandé à récupérer une partie des messages d'un canal.
|
||||
* Cette fonction est alors appelée lors du retour du serveur.
|
||||
* @param data Dictionnaire contenant l'identifiant du canal concerné, et la liste des messages récupérés.
|
||||
*/
|
||||
function receiveFetchedMessages(data) {
|
||||
// Récupération du canal concerné ainsi que des nouveaux messages à mémoriser
|
||||
let channel_id = data.channel_id
|
||||
let new_messages = data.messages
|
||||
|
||||
if (!messages[channel_id])
|
||||
messages[channel_id] = new Map()
|
||||
|
||||
// Ajout des nouveaux messages à la liste des messages du canal
|
||||
for (let message of new_messages)
|
||||
messages[channel_id].set(message.id, message)
|
||||
|
||||
// On trie les messages reçus par date et heure d'envoi
|
||||
messages[channel_id] = new Map([...messages[channel_id].values()]
|
||||
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
|
||||
.map(message => [message.id, message]))
|
||||
|
||||
// Enfin, si le canal concerné est le canal courant, on redessine les messages
|
||||
if (channel_id === selected_channel_id)
|
||||
redrawMessages()
|
||||
}
|
||||
|
||||
/**
|
||||
* L'utilisateur⋅rice a indiqué au serveur que des messages ont été lus.
|
||||
* Cette fonction est appelée en retour, pour confirmer, et stocke quels messages ont été lus
|
||||
* et combien de messages sont non lus par canal.
|
||||
* @param data Dictionnaire contenant une clé `read`, contenant la liste des identifiants des messages
|
||||
* marqués comme lus avec leur canal respectif, et une clé `unread_messages` contenant le nombre
|
||||
* de messages non lus par canal.
|
||||
*/
|
||||
function markMessageAsRead(data) {
|
||||
for (let message of data.messages) {
|
||||
// Récupération du message à marquer comme lu
|
||||
let stored_message = messages[message.channel_id].get(message.id)
|
||||
// Marquage du message comme lu
|
||||
if (stored_message)
|
||||
stored_message.read = true
|
||||
}
|
||||
// Actualisation des badges contenant le nombre de messages non lus par canal
|
||||
updateUnreadBadges(data.unread_messages)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mise à jour des badges contenant le nombre de messages non lus par canal.
|
||||
* @param unreadMessages Dictionnaire des nombres de messages non lus par canal (identifiés par leurs identifiants)
|
||||
*/
|
||||
function updateUnreadBadges(unreadMessages) {
|
||||
for (let channel of Object.values(channels)) {
|
||||
// Récupération du nombre de messages non lus pour le canal en question et mise à jour du badge pour ce canal
|
||||
updateUnreadBadge(channel.id, unreadMessages[channel.id] || 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mise à jour du badge du nombre de messages non lus d'un canal.
|
||||
* Actualise sa visibilité.
|
||||
* @param channel_id Identifiant du canal concerné.
|
||||
* @param unreadMessagesCount Nombre de messages non lus du canal.
|
||||
*/
|
||||
function updateUnreadBadge(channel_id, unreadMessagesCount = 0) {
|
||||
// Vaut true si on veut trier les canaux par nombre de messages non lus ou non
|
||||
const sortByUnread = document.getElementById('sort-by-unread-switch').checked
|
||||
|
||||
// Récupération du canal concerné
|
||||
let channel = channels[channel_id]
|
||||
|
||||
// Récupération du nombre de messages non lus pour le canal en question, que l'on stocke
|
||||
channel.unread_messages = unreadMessagesCount
|
||||
|
||||
// On met à jour le badge du canal contenant le nombre de messages non lus
|
||||
let unreadBadge = document.getElementById(`unread-messages-${channel.id}`)
|
||||
unreadBadge.innerText = unreadMessagesCount.toString()
|
||||
|
||||
// Le badge est visible si et seulement si il y a au moins un message non lu
|
||||
if (unreadMessagesCount)
|
||||
unreadBadge.classList.remove('d-none')
|
||||
else
|
||||
unreadBadge.classList.add('d-none')
|
||||
|
||||
// S'il faut trier les canaux par nombre de messages non lus, on ajoute la propriété CSS correspondante
|
||||
if (sortByUnread)
|
||||
document.getElementById(`tab-channel-${channel.id}`).style.order = `${-unreadMessagesCount}`
|
||||
}
|
||||
|
||||
/**
|
||||
* La création d'un canal privé entre deux personnes a été demandée.
|
||||
* Cette fonction est appelée en réponse du serveur.
|
||||
* Le canal est ajouté à la liste s'il est nouveau, et automatiquement sélectionné.
|
||||
* @param data Dictionnaire contenant une unique clé `channel` correspondant aux informations du canal privé.
|
||||
*/
|
||||
function startPrivateChat(data) {
|
||||
// Récupération du canal
|
||||
let channel = data.channel
|
||||
if (!channel) {
|
||||
console.error('Private chat not found:', data)
|
||||
return
|
||||
}
|
||||
|
||||
if (!channels[channel.id]) {
|
||||
// Si le canal n'est pas récupéré, on l'ajoute à la liste
|
||||
channels[channel.id] = channel
|
||||
messages[channel.id] = new Map()
|
||||
addChannel(channel)
|
||||
}
|
||||
|
||||
// Sélection immédiate du canal privé
|
||||
selectChannel(channel.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour le composant correspondant à la liste des messages du canal sélectionné.
|
||||
* Le conteneur est d'abord réinitialisé, puis les messages sont affichés un à un à partir de ceux stockés.
|
||||
*/
|
||||
function redrawMessages() {
|
||||
// Récupération du composant HTML <ul> correspondant à la liste des messages affichés
|
||||
let messageList = document.getElementById('message-list')
|
||||
// On commence par le vider
|
||||
messageList.innerHTML = ''
|
||||
|
||||
let lastMessage = null
|
||||
let lastContentDiv = null
|
||||
|
||||
for (let message of messages[selected_channel_id].values()) {
|
||||
if (lastMessage && lastMessage.author === message.author) {
|
||||
// Si le message est écrit par læ même auteur⋅rice que le message précédent,
|
||||
// alors on les groupe ensemble
|
||||
let lastTimestamp = new Date(lastMessage.timestamp)
|
||||
let newTimestamp = new Date(message.timestamp)
|
||||
if ((newTimestamp - lastTimestamp) / 1000 < 60 * 10) {
|
||||
// Les messages sont groupés uniquement s'il y a une différence maximale de 10 minutes
|
||||
// entre le premier message du groupe et celui en étude
|
||||
// On ajoute alors le contenu du message en cours dans le dernier div de message
|
||||
let messageContentDiv = document.createElement('div')
|
||||
messageContentDiv.classList.add('message')
|
||||
messageContentDiv.setAttribute('data-message-id', message.id)
|
||||
lastContentDiv.appendChild(messageContentDiv)
|
||||
let messageContentSpan = document.createElement('span')
|
||||
messageContentSpan.innerHTML = markdownToHTML(message.content)
|
||||
messageContentDiv.appendChild(messageContentSpan)
|
||||
|
||||
// Enregistrement du menu contextuel pour le message permettant la modification, la suppression
|
||||
// et l'envoi de messages privés
|
||||
registerMessageContextMenu(message, messageContentDiv, messageContentSpan)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Création de l'élément <li> pour le bloc de messages
|
||||
let messageElement = document.createElement('li')
|
||||
messageElement.classList.add('list-group-item')
|
||||
messageList.appendChild(messageElement)
|
||||
|
||||
// Ajout d'un div contenant le nom de l'auteur⋅rice du message ainsi que la date et heure d'envoi
|
||||
let authorDiv = document.createElement('div')
|
||||
messageElement.appendChild(authorDiv)
|
||||
|
||||
// Ajout du nom de l'auteur⋅rice du message
|
||||
let authorSpan = document.createElement('span')
|
||||
authorSpan.classList.add('text-muted', 'fw-bold')
|
||||
authorSpan.innerText = message.author
|
||||
authorDiv.appendChild(authorSpan)
|
||||
|
||||
// Ajout de la date du message
|
||||
let dateSpan = document.createElement('span')
|
||||
dateSpan.classList.add('text-muted', 'float-end')
|
||||
dateSpan.innerText = new Date(message.timestamp).toLocaleString()
|
||||
authorDiv.appendChild(dateSpan)
|
||||
|
||||
// Enregistrement du menu contextuel pour le message permettant l'envoi de messages privés à l'auteur⋅rice
|
||||
registerSendPrivateMessageContextMenu(message, authorDiv, authorSpan)
|
||||
|
||||
let contentDiv = document.createElement('div')
|
||||
messageElement.appendChild(contentDiv)
|
||||
|
||||
// Ajout du contenu du message
|
||||
// Le contenu est mis dans un span lui-même inclus dans un div,
|
||||
let messageContentDiv = document.createElement('div')
|
||||
messageContentDiv.classList.add('message')
|
||||
messageContentDiv.setAttribute('data-message-id', message.id)
|
||||
contentDiv.appendChild(messageContentDiv)
|
||||
let messageContentSpan = document.createElement('span')
|
||||
messageContentSpan.innerHTML = markdownToHTML(message.content)
|
||||
messageContentDiv.appendChild(messageContentSpan)
|
||||
|
||||
// Enregistrement du menu contextuel pour le message permettant la modification, la suppression
|
||||
// et l'envoi de messages privés
|
||||
registerMessageContextMenu(message, messageContentDiv, messageContentSpan)
|
||||
|
||||
lastMessage = message
|
||||
lastContentDiv = contentDiv
|
||||
}
|
||||
|
||||
// Le bouton « Afficher les messages précédents » est affiché si et seulement si
|
||||
// il y a des messages à récupérer (c'est-à-dire si le nombre de messages récupérés est un multiple de MAX_MESSAGES)
|
||||
let fetchMoreButton = document.getElementById('fetch-previous-messages')
|
||||
if (!messages[selected_channel_id].size || messages[selected_channel_id].size % MAX_MESSAGES !== 0)
|
||||
fetchMoreButton.classList.add('d-none')
|
||||
else
|
||||
fetchMoreButton.classList.remove('d-none')
|
||||
|
||||
// On envoie un événement personnalisé pour indiquer que les messages ont été mis à jour
|
||||
// Permettant entre autres de marquer les messages visibles comme lus si c'est le cas
|
||||
messageList.dispatchEvent(new CustomEvent('updatemessages'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un texte écrit en Markdown en HTML.
|
||||
* Les balises Markdown suivantes sont supportées :
|
||||
* - Souligné : `_texte_`
|
||||
* - Gras : `**texte**`
|
||||
* - Italique : `*texte*`
|
||||
* - Code : `` `texte` ``
|
||||
* - Les liens sont automatiquement convertis
|
||||
* - Les esperluettes, guillemets et chevrons sont échappés.
|
||||
* @param text Le texte écrit en Markdown.
|
||||
* @return {string} Le texte converti en HTML.
|
||||
*/
|
||||
function markdownToHTML(text) {
|
||||
// On échape certains caractères spéciaux (esperluettes, chevrons, guillemets)
|
||||
let safeText = text.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
let lines = safeText.split('\n')
|
||||
let htmlLines = []
|
||||
for (let line of lines) {
|
||||
// Pour chaque ligne, on remplace le Markdown par un équivalent HTML (pour ce qui est supporté)
|
||||
let htmlLine = line
|
||||
.replaceAll(/_(.*)_/gim, '<span class="text-decoration-underline">$1</span>') // Souligné
|
||||
.replaceAll(/\*\*(.*)\*\*/gim, '<span class="fw-bold">$1</span>') // Gras
|
||||
.replaceAll(/\*(.*)\*/gim, '<span class="fst-italic">$1</span>') // Italique
|
||||
.replaceAll(/`(.*)`/gim, '<pre>$1</pre>') // Code
|
||||
.replaceAll(/(https?:\/\/\S+)/g, '<a href="$1" target="_blank">$1</a>') // Liens
|
||||
htmlLines.push(htmlLine)
|
||||
}
|
||||
// On joint enfin toutes les lignes par des balises de saut de ligne
|
||||
return htmlLines.join('<br>')
|
||||
}
|
||||
|
||||
/**
|
||||
* Ferme toutes les popovers ouvertes.
|
||||
*/
|
||||
function removeAllPopovers() {
|
||||
for (let popover of document.querySelectorAll('*[aria-describedby*="popover"]')) {
|
||||
let instance = bootstrap.Popover.getInstance(popover)
|
||||
if (instance)
|
||||
instance.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistrement du menu contextuel pour un⋅e auteur⋅rice de message,
|
||||
* donnant la possibilité d'envoyer un message privé.
|
||||
* @param message Le message écrit par l'auteur⋅rice du bloc en question.
|
||||
* @param div Le bloc contenant le nom de l'auteur⋅rice et de la date d'envoi du message.
|
||||
* Un clic droit sur lui affichera le menu contextuel.
|
||||
* @param span Le span contenant le nom de l'auteur⋅rice.
|
||||
* Il désignera l'emplacement d'affichage du popover.
|
||||
*/
|
||||
function registerSendPrivateMessageContextMenu(message, div, span) {
|
||||
// Enregistrement de l'écouteur d'événement pour le clic droit
|
||||
div.addEventListener('contextmenu', (menu_event) => {
|
||||
// On empêche le menu traditionnel de s'afficher
|
||||
menu_event.preventDefault()
|
||||
// On retire toutes les popovers déjà ouvertes
|
||||
removeAllPopovers()
|
||||
|
||||
// On crée le popover contenant le lien pour envoyer un message privé, puis on l'affiche
|
||||
const popover = bootstrap.Popover.getOrCreateInstance(span, {
|
||||
'title': message.author,
|
||||
'content': `<a id="send-private-message-link-${message.id}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>`,
|
||||
'html': true,
|
||||
})
|
||||
popover.show()
|
||||
|
||||
// Lorsqu'on clique sur le lien, on ferme le popover
|
||||
// et on demande à ouvrir le canal privé avec l'auteur⋅rice du message
|
||||
document.getElementById('send-private-message-link-' + message.id).addEventListener('click', event => {
|
||||
event.preventDefault()
|
||||
popover.dispose()
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'start_private_chat',
|
||||
'user_id': message.author_id,
|
||||
}))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistrement du menu contextuel pour un message,
|
||||
* donnant la possibilité de modifier ou de supprimer le message, ou d'envoyer un message privé à l'auteur⋅rice.
|
||||
* @param message Le message en question.
|
||||
* @param div Le bloc contenant le contenu du message.
|
||||
* Un clic droit sur lui affichera le menu contextuel.
|
||||
* @param span Le span contenant le contenu du message.
|
||||
* Il désignera l'emplacement d'affichage du popover.
|
||||
*/
|
||||
function registerMessageContextMenu(message, div, span) {
|
||||
// Enregistrement de l'écouteur d'événement pour le clic droit
|
||||
div.addEventListener('contextmenu', (menu_event) => {
|
||||
// On empêche le menu traditionnel de s'afficher
|
||||
menu_event.preventDefault()
|
||||
// On retire toutes les popovers déjà ouvertes
|
||||
removeAllPopovers()
|
||||
|
||||
// On crée le popover contenant les liens pour modifier, supprimer le message ou envoyer un message privé.
|
||||
let content = `<a id="send-private-message-link-msg-${message.id}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>`
|
||||
|
||||
// On ne peut modifier ou supprimer un message que si on est l'auteur⋅rice ou que l'on est administrateur⋅rice.
|
||||
let has_right_to_edit = message.author_id === USER_ID || IS_ADMIN
|
||||
if (has_right_to_edit) {
|
||||
content += `<hr class="my-1">`
|
||||
content += `<a id="edit-message-${message.id}" class="nav-link" href="#" tabindex="0">Modifier</a>`
|
||||
content += `<a id="delete-message-${message.id}" class="nav-link" href="#" tabindex="0">Supprimer</a>`
|
||||
}
|
||||
|
||||
const popover = bootstrap.Popover.getOrCreateInstance(span, {
|
||||
'content': content,
|
||||
'html': true,
|
||||
'placement': 'bottom',
|
||||
})
|
||||
popover.show()
|
||||
|
||||
// Lorsqu'on clique sur le lien d'envoi de message privé, on ferme le popover
|
||||
// et on demande à ouvrir le canal privé avec l'auteur⋅rice du message
|
||||
document.getElementById('send-private-message-link-msg-' + message.id).addEventListener('click', event => {
|
||||
event.preventDefault()
|
||||
popover.dispose()
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'start_private_chat',
|
||||
'user_id': message.author_id,
|
||||
}))
|
||||
})
|
||||
|
||||
if (has_right_to_edit) {
|
||||
// Si on a le droit de modifier ou supprimer le message, on enregistre les écouteurs d'événements
|
||||
// Le bouton de modification de message ouvre une boîte de dialogue pour modifier le message
|
||||
document.getElementById('edit-message-' + message.id).addEventListener('click', event => {
|
||||
event.preventDefault()
|
||||
// Fermeture du popover
|
||||
popover.dispose()
|
||||
|
||||
// Ouverture d'une boîte de diaologue afin de modifier le message
|
||||
let new_message = prompt("Modifier le message", message.content)
|
||||
if (new_message) {
|
||||
// Si le message a été modifié, on envoie la demande de modification au serveur
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'edit_message',
|
||||
'message_id': message.id,
|
||||
'content': new_message,
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// Le bouton de suppression de message demande une confirmation avant de supprimer le message
|
||||
document.getElementById('delete-message-' + message.id).addEventListener('click', event => {
|
||||
event.preventDefault()
|
||||
// Fermeture du popover
|
||||
popover.dispose()
|
||||
|
||||
// Demande de confirmation avant de supprimer le message
|
||||
if (confirm(`Supprimer le message ?\n${message.content}`)) {
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'delete_message',
|
||||
'message_id': message.id,
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Passe le chat en version plein écran, ou l'inverse si c'est déjà le cas.
|
||||
*/
|
||||
function toggleFullscreen() {
|
||||
let chatContainer = document.getElementById('chat-container')
|
||||
if (!chatContainer.getAttribute('data-fullscreen')) {
|
||||
// Le chat n'est pas en plein écran.
|
||||
// On le passe en plein écran en le plaçant en avant plan en position absolue
|
||||
// prenant toute la hauteur et toute la largeur
|
||||
chatContainer.setAttribute('data-fullscreen', 'true')
|
||||
chatContainer.classList.add('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3')
|
||||
window.history.replaceState({}, null, `?fullscreen=1`)
|
||||
}
|
||||
else {
|
||||
// Le chat est déjà en plein écran. On retire les tags CSS correspondant au plein écran.
|
||||
chatContainer.removeAttribute('data-fullscreen')
|
||||
chatContainer.classList.remove('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3')
|
||||
window.history.replaceState({}, null, `?fullscreen=0`)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Lorsqu'on effectue le moindre clic, on ferme les éventuelles popovers ouvertes
|
||||
document.addEventListener('click', removeAllPopovers)
|
||||
|
||||
// Lorsqu'on change entre le tri des canaux par ordre alphabétique et par nombre de messages non lus,
|
||||
// on met à jour l'ordre des canaux
|
||||
document.getElementById('sort-by-unread-switch').addEventListener('change', event => {
|
||||
const sortByUnread = event.target.checked
|
||||
for (let channel of Object.values(channels)) {
|
||||
let item = document.getElementById(`tab-channel-${channel.id}`)
|
||||
if (sortByUnread)
|
||||
// Si on trie par nombre de messages non lus,
|
||||
// on définit l'ordre de l'élément en fonction du nombre de messages non lus
|
||||
// à l'aide d'une propriété CSS
|
||||
item.style.order = `${-channel.unread_messages}`
|
||||
else
|
||||
// Sinon, les canaux sont de base triés par ordre alphabétique
|
||||
item.style.removeProperty('order')
|
||||
}
|
||||
|
||||
// On stocke le mode de tri dans le stockage local
|
||||
localStorage.setItem('chat.sort-by-unread', sortByUnread)
|
||||
})
|
||||
|
||||
// On récupère le mode de tri des canaux depuis le stockage local
|
||||
if (localStorage.getItem('chat.sort-by-unread') === 'true') {
|
||||
document.getElementById('sort-by-unread-switch').checked = true
|
||||
document.getElementById('sort-by-unread-switch').dispatchEvent(new Event('change'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Des données sont reçues depuis le serveur. Elles sont traitées dans cette fonction,
|
||||
* qui a pour but de trier et de répartir dans d'autres sous-fonctions.
|
||||
* @param data Le message reçu.
|
||||
*/
|
||||
function processMessage(data) {
|
||||
// On traite le message en fonction de son type
|
||||
switch (data.type) {
|
||||
case 'fetch_channels':
|
||||
setChannels(data.channels)
|
||||
break
|
||||
case 'send_message':
|
||||
receiveMessage(data)
|
||||
break
|
||||
case 'edit_message':
|
||||
editMessage(data)
|
||||
break
|
||||
case 'delete_message':
|
||||
deleteMessage(data)
|
||||
break
|
||||
case 'fetch_messages':
|
||||
receiveFetchedMessages(data)
|
||||
break
|
||||
case 'mark_read':
|
||||
markMessageAsRead(data)
|
||||
break
|
||||
case 'start_private_chat':
|
||||
startPrivateChat(data)
|
||||
break
|
||||
default:
|
||||
// Le type de message est inconnu. On affiche une erreur dans la console.
|
||||
console.log(data)
|
||||
console.error('Unknown message type:', data.type)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration du socket de chat, permettant de communiquer avec le serveur.
|
||||
* @param nextDelay Correspond au délai de reconnexion en cas d'erreur.
|
||||
* Augmente exponentiellement en cas d'erreurs répétées,
|
||||
* et se réinitialise à 1s en cas de connexion réussie.
|
||||
*/
|
||||
function setupSocket(nextDelay = 1000) {
|
||||
// Ouverture du socket
|
||||
socket = new WebSocket(
|
||||
(document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/ws/chat/'
|
||||
)
|
||||
let socketOpen = false
|
||||
|
||||
// Écoute des messages reçus depuis le serveur
|
||||
socket.addEventListener('message', e => {
|
||||
// Analyse du message reçu en tant que JSON
|
||||
const data = JSON.parse(e.data)
|
||||
|
||||
// Traite le message reçu
|
||||
processMessage(data)
|
||||
})
|
||||
|
||||
// En cas d'erreur, on affiche un message et on réessaie de se connecter après un certain délai
|
||||
// Ce délai double après chaque erreur répétée, jusqu'à un maximum de 2 minutes
|
||||
socket.addEventListener('close', e => {
|
||||
console.error('Chat socket closed unexpectedly, restarting…')
|
||||
setTimeout(() => setupSocket(Math.max(socketOpen ? 1000 : 2 * nextDelay, 120000)), nextDelay)
|
||||
})
|
||||
|
||||
// En cas de connexion réussie, on demande au serveur les derniers messages pour chaque canal
|
||||
socket.addEventListener('open', e => {
|
||||
socketOpen = true
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'fetch_channels',
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration du swipe pour ouvrir et fermer le sélecteur de canaux.
|
||||
* Fonctionne a priori uniquement sur les écrans tactiles.
|
||||
* Lorsqu'on swipe de la gauche vers la droite, depuis le côté gauche de l'écran, on ouvre le sélecteur de canaux.
|
||||
* Quand on swipe de la droite vers la gauche, on ferme le sélecteur de canaux.
|
||||
*/
|
||||
function setupSwipeOffscreen() {
|
||||
// Récupération du sélecteur de canaux
|
||||
const offcanvas = new bootstrap.Offcanvas(document.getElementById('channelSelector'))
|
||||
|
||||
// L'écran a été touché. On récupère la coordonnée X de l'emplacement touché.
|
||||
let lastX = null
|
||||
document.addEventListener('touchstart', (event) => {
|
||||
if (event.touches.length === 1)
|
||||
lastX = event.touches[0].clientX
|
||||
})
|
||||
|
||||
// Le doigt a été déplacé. Selon le nouvel emplacement du doigt, on ouvre ou on ferme le sélecteur de canaux.
|
||||
document.addEventListener('touchmove', (event) => {
|
||||
if (event.touches.length === 1 && lastX !== null) {
|
||||
// L'écran a été touché à un seul doigt, et on a déjà récupéré la coordonnée X touchée.
|
||||
const diff = event.touches[0].clientX - lastX
|
||||
if (diff > window.innerWidth / 10 && lastX < window.innerWidth / 4) {
|
||||
// Si le déplacement correspond à au moins 10 % de la largeur de l'écran vers la droite
|
||||
// et que le point de départ se trouve dans le quart gauche de l'écran, alors on ouvre le sélecteur
|
||||
offcanvas.show()
|
||||
lastX = null
|
||||
}
|
||||
else if (diff < -window.innerWidth / 10) {
|
||||
// Si le déplacement correspond à au moins 10 % de la largeur de l'écran vers la gauche,
|
||||
// alors on ferme le sélecteur
|
||||
offcanvas.hide()
|
||||
lastX = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Le doigt a été relâché. On réinitialise la coordonnée X touchée.
|
||||
document.addEventListener('touchend', () => {
|
||||
lastX = null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration du suivi de lecture des messages.
|
||||
* Lorsque l'utilisateur⋅rice scrolle dans la fenêtre de chat, on vérifie quels sont les messages qui sont
|
||||
* visibles à l'écran, et on les marque comme lus.
|
||||
*/
|
||||
function setupReadTracker() {
|
||||
// Récupération du conteneur de messages
|
||||
const scrollableContent = document.getElementById('chat-messages')
|
||||
const messagesList = document.getElementById('message-list')
|
||||
let markReadBuffer = []
|
||||
let markReadTimeout = null
|
||||
|
||||
// Lorsqu'on scrolle, on récupère les anciens messages si on est tout en haut,
|
||||
// et on marque les messages visibles comme lus
|
||||
scrollableContent.addEventListener('scroll', () => {
|
||||
if (scrollableContent.clientHeight - scrollableContent.scrollTop === scrollableContent.scrollHeight
|
||||
&& !document.getElementById('fetch-previous-messages').classList.contains('d-none')) {
|
||||
// Si l'utilisateur⋅rice est en haut du chat, on récupère la liste des anciens messages
|
||||
fetchPreviousMessages()}
|
||||
|
||||
// On marque les messages visibles comme lus
|
||||
markVisibleMessagesAsRead()
|
||||
})
|
||||
|
||||
// Lorsque les messages stockés sont mis à jour, on vérifie quels sont les messages visibles à marquer comme lus
|
||||
messagesList.addEventListener('updatemessages', () => markVisibleMessagesAsRead())
|
||||
|
||||
/**
|
||||
* Marque les messages visibles à l'écran comme lus.
|
||||
* On récupère pour cela les coordonnées du conteneur de messages ainsi que les coordonnées de chaque message
|
||||
* et on vérifie si le message est visible à l'écran. Si c'est le cas, on le marque comme lu.
|
||||
* Après 3 secondes d'attente après qu'aucun message n'ait été lu,
|
||||
* on envoie la liste des messages lus au serveur.
|
||||
*/
|
||||
function markVisibleMessagesAsRead() {
|
||||
// Récupération des coordonnées visibles du conteneur de messages
|
||||
let viewport = scrollableContent.getBoundingClientRect()
|
||||
|
||||
for (let item of messagesList.querySelectorAll('.message')) {
|
||||
let message = messages[selected_channel_id].get(parseInt(item.getAttribute('data-message-id')))
|
||||
if (!message.read) {
|
||||
// Si le message n'a pas déjà été lu, on récupère ses coordonnées
|
||||
let rect = item.getBoundingClientRect()
|
||||
if (rect.top >= viewport.top && rect.bottom <= viewport.bottom) {
|
||||
// Si les coordonnées sont entièrement incluses dans le rectangle visible, on le marque comme lu
|
||||
// et comme étant à envoyer au serveur
|
||||
message.read = true
|
||||
markReadBuffer.push(message.id)
|
||||
if (markReadTimeout)
|
||||
clearTimeout(markReadTimeout)
|
||||
// 3 secondes après qu'aucun nouveau message n'ait été rajouté, on envoie la liste des messages
|
||||
// lus au serveur
|
||||
markReadTimeout = setTimeout(() => {
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'mark_read',
|
||||
'message_ids': markReadBuffer,
|
||||
}))
|
||||
markReadBuffer = []
|
||||
markReadTimeout = null
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On considère les messages d'ores-et-déjà visibles comme lus
|
||||
markVisibleMessagesAsRead()
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration de la demande d'installation de l'application en tant qu'application web progressive (PWA).
|
||||
* Lorsque l'utilisateur⋅rice arrive sur la page, on lui propose de télécharger l'application
|
||||
* pour l'ajouter à son écran d'accueil.
|
||||
* Fonctionne uniquement sur les navigateurs compatibles.
|
||||
*/
|
||||
function setupPWAPrompt() {
|
||||
let deferredPrompt = null
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (e) => {
|
||||
// Une demande d'installation a été faite. On commence par empêcher l'action par défaut.
|
||||
e.preventDefault()
|
||||
deferredPrompt = e
|
||||
|
||||
// L'installation est possible, on rend visible le bouton de téléchargement
|
||||
// ainsi que le message qui indique c'est possible.
|
||||
let btn = document.getElementById('install-app-home-screen')
|
||||
let alert = document.getElementById('alert-download-chat-app')
|
||||
btn.classList.remove('d-none')
|
||||
alert.classList.remove('d-none')
|
||||
btn.onclick = function () {
|
||||
// Lorsque le bouton de téléchargement est cliqué, on lance l'installation du PWA.
|
||||
deferredPrompt.prompt()
|
||||
deferredPrompt.userChoice.then((choiceResult) => {
|
||||
if (choiceResult.outcome === 'accepted') {
|
||||
// Si l'installation a été acceptée, on masque le bouton de téléchargement.
|
||||
deferredPrompt = null
|
||||
btn.classList.add('d-none')
|
||||
alert.classList.add('d-none')
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setupSocket() // Configuration du Websocket
|
||||
setupSwipeOffscreen() // Configuration du swipe sur les écrans tactiles pour le sélecteur de canaux
|
||||
setupReadTracker() // Configuration du suivi de lecture des messages
|
||||
setupPWAPrompt() // Configuration de l'installateur d'application en tant qu'application web progressive
|
||||
})
|
25
chat/templates/chat/chat.html
Normal file
@ -0,0 +1,25 @@
|
||||
{% 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 #}
|
||||
{% if TFJM.APP == "TFJM" %}
|
||||
<link rel="manifest" href="{% static "tfjm/chat_tfjm.webmanifest" %}">
|
||||
{% elif TFJM.APP == "ETEAM" %}
|
||||
<link rel="manifest" href="{% static "tfjm/chat_eteam.webmanifest" %}">
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content-title %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "chat/content.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
{# Ce script contient toutes les données pour la gestion du chat. #}
|
||||
{% javascript 'chat' %}
|
||||
{% endblock %}
|
126
chat/templates/chat/content.html
Normal file
@ -0,0 +1,126 @@
|
||||
{% load i18n %}
|
||||
|
||||
<noscript>
|
||||
{# Le chat fonctionne à l'aide d'un script JavaScript, sans JavaScript activé il n'est pas possible d'utiliser le chat. #}
|
||||
{% trans "JavaScript must be enabled on your browser to access chat." %}
|
||||
</noscript>
|
||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="channelSelector" aria-labelledby="offcanvasTitle">
|
||||
<div class="offcanvas-header">
|
||||
{# Titre du sélecteur de canaux #}
|
||||
<h3 class="offcanvas-title" id="offcanvasTitle">{% trans "Chat channels" %}</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
{# Contenu du sélecteur de canaux #}
|
||||
<div class="form-switch form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="sort-by-unread-switch">
|
||||
<label class="form-check-label" for="sort-by-unread-switch">{% trans "Sort by unread messages" %}</label>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush" id="nav-channels-tab">
|
||||
{# Liste des différentes catégories, avec les canaux par catégorie #}
|
||||
<li class="list-group-item d-none">
|
||||
{# Canaux généraux #}
|
||||
<h4>{% trans "General channels" %}</h4>
|
||||
<ul class="list-group list-group-flush" id="nav-general-channels-tab"></ul>
|
||||
</li>
|
||||
<li class="list-group-item d-none">
|
||||
{# Canaux liés à un tournoi #}
|
||||
<h4>{% trans "Tournament channels" %}</h4>
|
||||
<ul class="list-group list-group-flush" id="nav-tournament-channels-tab"></ul>
|
||||
</li>
|
||||
<li class="list-group-item d-none">
|
||||
{# Canaux d'équipes #}
|
||||
<h4>{% trans "Team channels" %}</h4>
|
||||
<ul class="list-group list-group-flush" id="nav-team-channels-tab"></ul>
|
||||
</li>
|
||||
<li class="list-group-item d-none">
|
||||
{# Échanges privés #}
|
||||
<h4>{% trans "Private channels" %}</h4>
|
||||
<ul class="list-group list-group-flush" id="nav-private-channels-tab"></ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info d-none" id="alert-download-chat-app">
|
||||
{# Lorsque l'application du chat est installable (par exemple sur un Chrome sur Android), on affiche le message qui indique que c'est bien possible. #}
|
||||
{% trans "You can install a shortcut to the chat on your home screen using the download button on the header." %}
|
||||
</div>
|
||||
|
||||
{# Conteneur principal du chat. #}
|
||||
{# Lorsque le chat est en plein écran, on le place en coordonnées absolues, occupant tout l'espace de l'écran. #}
|
||||
<div class="card tab-content w-100 mh-100{% if request.GET.fullscreen == '1' or fullscreen %} position-absolute top-0 start-0 vh-100 z-3{% endif %}"
|
||||
style="height: 95vh" id="chat-container">
|
||||
<div class="card-header">
|
||||
<h3>
|
||||
{% if fullscreen %}
|
||||
{# Lorsque le chat est en plein écran, on affiche le bouton de déconnexion. #}
|
||||
{# Le bouton de déconnexion doit être présent dans un formulaire. Le formulaire doit inclure toute la ligne. #}
|
||||
<form action="{% url 'chat:logout' %}" method="post">
|
||||
{% csrf_token %}
|
||||
{% endif %}
|
||||
{# Bouton qui permet d'ouvrir le sélecteur de canaux #}
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="offcanvas" data-bs-target="#channelSelector"
|
||||
aria-controls="channelSelector" aria-expanded="false" aria-label="Toggle channel selector">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<span id="channel-title"></span> {# Titre du canal sélectionné #}
|
||||
{% if not fullscreen %}
|
||||
{# Dans le cas où on est pas uniquement en plein écran (cas de l'application), on affiche les boutons pour passer en ou quitter le mode plein écran. #}
|
||||
<button class="btn float-end" type="button" onclick="toggleFullscreen()" title="{% trans "Toggle fullscreen mode" %}">
|
||||
<i class="fas fa-expand"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
{# Le bouton de déconnexion n'est affiché que sur l'application. #}
|
||||
<button class="btn float-end" title="{% trans "Log out" %}">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{# On affiche le bouton d'installation uniquement dans le cas où l'application est installable sur l'écran d'accueil. #}
|
||||
<button class="btn float-end d-none" type="button" id="install-app-home-screen" title="{% trans "Install app on home screen" %}">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
{% if fullscreen %}
|
||||
</form>
|
||||
{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{# Contenu de la carte, contenant la liste des messages. La liste des messages est affichée à l'envers pour avoir un scroll plus cohérent. #}
|
||||
<div class="card-body d-flex flex-column-reverse flex-grow-0 overflow-y-scroll" id="chat-messages">
|
||||
{# Correspond à la liste des messages à afficher. #}
|
||||
<ul class="list-group list-group-flush" id="message-list"></ul>
|
||||
{# S'il y a des messages à récupérer, on affiche un lien qui permet de récupérer les anciens messages. #}
|
||||
<div class="text-center d-none" id="fetch-previous-messages">
|
||||
<a href="#" class="nav-link" onclick="event.preventDefault(); fetchPreviousMessages()">
|
||||
{% trans "Fetch previous messages…" %}
|
||||
</a>
|
||||
<hr>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Pied de la carte, contenant le formulaire pour envoyer un message. #}
|
||||
<div class="card-footer mt-auto">
|
||||
{# Lorsqu'on souhaite envoyer un message, on empêche le formulaire de s'envoyer et on envoie le message par websocket. #}
|
||||
<form onsubmit="event.preventDefault(); sendMessage()">
|
||||
<div class="input-group">
|
||||
<label for="input-message" class="input-group-text">
|
||||
<i class="fas fa-comment"></i>
|
||||
</label>
|
||||
{# Affichage du contrôleur de texte pour rédiger le message à envoyer. #}
|
||||
<input type="text" class="form-control" id="input-message" placeholder="{% trans "Send message…" %}" autofocus autocomplete="off">
|
||||
<button class="input-group-text btn btn-success" type="submit">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{# Récupération de l'utilisateur⋅rice courant⋅e afin de pouvoir effectuer des tests plus tard. #}
|
||||
const USER_ID = {{ request.user.id }}
|
||||
{# Récupération du statut administrateur⋅rice de l'utilisateur⋅rice connecté⋅e afin de pouvoir effectuer des tests plus tard. #}
|
||||
const IS_ADMIN = {{ request.user.registration.is_admin|yesno:"true,false" }}
|
||||
</script>
|
47
chat/templates/chat/fullscreen.html
Normal file
@ -0,0 +1,47 @@
|
||||
{% 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">
|
||||
{% if TFJM.APP == "TFJM" %}
|
||||
<title>{% trans "TFJM² Chat" %}</title>
|
||||
<meta name="description" content="{% trans "TFJM² Chat" %}">
|
||||
{% elif TFJM.APP == "ETEAM" %}
|
||||
<title>{% trans "ETEAM Chat" %}</title>
|
||||
<meta name="description" content="{% trans "ETEAM Chat" %}">
|
||||
{% endif %}
|
||||
|
||||
{# Favicon #}
|
||||
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
{# Bootstrap CSS #}
|
||||
<link href="{% static "bootstrap/css/bootstrap.min.css" %}" rel="stylesheet" type="text/css">
|
||||
{# Fontawesome CSS #}
|
||||
<link href="{% static "fontawesome/css/all.min.css" %}" rel="stylesheet" type="text/css">
|
||||
<link href="{% static "fontawesome/css/v4-shims.css" %}">
|
||||
{# bootstrap-select CSS #}
|
||||
<link href="{% static "bootstrap-select/css/bootstrap-select.min.css" %}" rel="stylesheet" type="text/css">
|
||||
|
||||
{# Bootstrap JavaScript #}
|
||||
<script type="application/javascript" src="{% static "bootstrap/js/bootstrap.bundle.min.js" %}" charset="utf-8"></script>
|
||||
|
||||
{# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #}
|
||||
{% if TFJM.APP == "TFJM" %}
|
||||
<link rel="manifest" href="{% static "tfjm/chat_tfjm.webmanifest" %}">
|
||||
{% elif TFJM.APP == "ETEAM" %}
|
||||
<link rel="manifest" href="{% static "tfjm/chat_eteam.webmanifest" %}">
|
||||
{% endif %}
|
||||
</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>
|
43
chat/templates/chat/login.html
Normal file
@ -0,0 +1,43 @@
|
||||
{% 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 "Chat" %} - {% trans "Log in" %}
|
||||
</title>
|
||||
<meta name="description" content="{% trans "Chat" %}">
|
||||
|
||||
{# Favicon #}
|
||||
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
{# Bootstrap CSS #}
|
||||
<link href="{% static "bootstrap/css/bootstrap.min.css" %}" rel="stylesheet" type="text/css">
|
||||
{# Fontawesome CSS #}
|
||||
<link href="{% static "fontawesome/css/all.min.css" %}" rel="stylesheet" type="text/css">
|
||||
<link href="{% static "fontawesome/css/v4-shims.css" %}">
|
||||
|
||||
{# Bootstrap JavaScript #}
|
||||
<script type="application/javascript" src="{% static "bootstrap/js/bootstrap.bundle.min.js" %}" charset="utf-8"></script>
|
||||
|
||||
{# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #}
|
||||
{% if TFJM.APP == "TFJM" %}
|
||||
<link rel="manifest" href="{% static "tfjm/chat_tfjm.webmanifest" %}">
|
||||
{% elif TFJM.APP == "ETEAM" %}
|
||||
<link rel="manifest" href="{% static "tfjm/chat_eteam.webmanifest" %}">
|
||||
{% endif %}
|
||||
</head>
|
||||
<body class="d-flex w-100 h-100 flex-column">
|
||||
<div class="container">
|
||||
<h1>{% trans "Log in" %}</h1>
|
||||
{% include "registration/includes/login.html" %}
|
||||
</div>
|
||||
|
||||
{# Inclusion du script permettant de gérer le thème sombre et le thème clair #}
|
||||
{% javascript 'theme' %}
|
||||
</body>
|
||||
</html>
|
2
chat/tests.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
18
chat/urls.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.contrib.auth.views import LoginView, LogoutView
|
||||
from django.urls import path
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from tfjm.views import LoginRequiredTemplateView
|
||||
|
||||
app_name = 'chat'
|
||||
|
||||
urlpatterns = [
|
||||
path('', LoginRequiredTemplateView.as_view(template_name="chat/chat.html",
|
||||
extra_context={'title': _("Chat")}), name='chat'),
|
||||
path('fullscreen/', LoginRequiredTemplateView.as_view(template_name="chat/fullscreen.html", login_url='chat:login'),
|
||||
name='fullscreen'),
|
||||
path('login/', LoginView.as_view(template_name="chat/login.html"), name='login'),
|
||||
path('logout/', LogoutView.as_view(next_page='chat:fullscreen'), name='logout'),
|
||||
]
|
BIN
docs/_static/img/draw_choose_problem.png
vendored
Normal file
After Width: | Height: | Size: 27 KiB |
BIN
docs/_static/img/draw_choose_problem_full.png
vendored
Normal file
After Width: | Height: | Size: 84 KiB |
BIN
docs/_static/img/draw_end_round_1.png
vendored
Normal file
After Width: | Height: | Size: 124 KiB |
BIN
docs/_static/img/draw_example.png
vendored
Normal file
After Width: | Height: | Size: 101 KiB |
BIN
docs/_static/img/draw_general.png
vendored
Normal file
After Width: | Height: | Size: 107 KiB |
BIN
docs/_static/img/draw_last_rolls.png
vendored
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
docs/_static/img/draw_passage_tables.png
vendored
Normal file
After Width: | Height: | Size: 81 KiB |
BIN
docs/_static/img/draw_recap.png
vendored
Normal file
After Width: | Height: | Size: 48 KiB |
BIN
docs/_static/img/draw_start.png
vendored
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
docs/_static/img/draw_tournament_tabs.png
vendored
Normal file
After Width: | Height: | Size: 9.8 KiB |
BIN
docs/_static/img/draw_waiting_choose_problem_order.png
vendored
Normal file
After Width: | Height: | Size: 76 KiB |
BIN
docs/_static/img/draw_waiting_choose_problem_order_full.png
vendored
Normal file
After Width: | Height: | Size: 131 KiB |
BIN
docs/_static/img/draw_waiting_passage_order.png
vendored
Normal file
After Width: | Height: | Size: 65 KiB |
BIN
docs/_static/img/draw_waiting_passage_order_full.png
vendored
Normal file
After Width: | Height: | Size: 92 KiB |
BIN
docs/_static/img/draw_waiting_problem_draw.png
vendored
Normal file
After Width: | Height: | Size: 29 KiB |
BIN
docs/_static/img/draw_waiting_problem_draw_full.png
vendored
Normal file
After Width: | Height: | Size: 86 KiB |
BIN
docs/_static/img/payment_bank_transfer.png
vendored
Normal file
After Width: | Height: | Size: 118 KiB |
BIN
docs/_static/img/payment_grouped.png
vendored
Normal file
After Width: | Height: | Size: 115 KiB |
BIN
docs/_static/img/payment_hello_asso_confirmation.png
vendored
Normal file
After Width: | Height: | Size: 107 KiB |
BIN
docs/_static/img/payment_hello_asso_step_1.png
vendored
Normal file
After Width: | Height: | Size: 101 KiB |
BIN
docs/_static/img/payment_hello_asso_step_2.png
vendored
Normal file
After Width: | Height: | Size: 87 KiB |
BIN
docs/_static/img/payment_index.png
vendored
Normal file
After Width: | Height: | Size: 120 KiB |
BIN
docs/_static/img/payment_scholarship.png
vendored
Normal file
After Width: | Height: | Size: 94 KiB |
@ -18,7 +18,7 @@
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = 'Plateforme du TFJM²'
|
||||
copyright = "2020-2023"
|
||||
copyright = "2020-2024"
|
||||
author = "Animath"
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@ author = "Animath"
|
||||
# ones.
|
||||
extensions = [
|
||||
"sphinx_rtd_theme",
|
||||
"sphinx_rtd_dark_mode",
|
||||
]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
@ -58,3 +59,5 @@ html_theme = 'sphinx_rtd_theme'
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
|
||||
default_dark_mode = True
|
||||
|
@ -2,3 +2,22 @@ Développer la plateforme
|
||||
========================
|
||||
|
||||
Cette page est dédiée aux responsables informatiques qui cherchent à contribuer à la plateforme.
|
||||
|
||||
Présentation
|
||||
------------
|
||||
|
||||
La plateforme d'inscription du TFJM² actuelle est née lors de l'édition 2020. Elle n'est
|
||||
pas la première à exister, elle succède à une précédente, moins fonctionnelle, dont les
|
||||
sources ont été perdues. Elle a été développée par Emmy D'Anello, bénévole pour Animath,
|
||||
qui la maintient au moins jusqu'en 2024.
|
||||
|
||||
La plateforme est développée en Python, utilisant le framework web
|
||||
`Django <https://www.djangoproject.com/>`_. Elle est diponible librement sous licence GPLv3
|
||||
à l'adresse `<https://gitlab.com/animath/si/plateforme-tfjm>`_.
|
||||
|
||||
L'instance de production est accessible à l'adresse `<https://inscription.tfjm.org/>`_.
|
||||
Une instance de développement est accessible à l'adresse `<https://inscription-dev.tfjm.org/>`_.
|
||||
|
||||
Les deux instances sont hébergées sur le serveur d'Animath. La documentation spécifique
|
||||
à l'installation des services d'Animath peut être trouvée à l'adresse
|
||||
`<https://doc.animath.live/>`_.
|
||||
|
309
docs/dev/install.rst
Normal file
@ -0,0 +1,309 @@
|
||||
Installation de la plateforme du TFJM²
|
||||
======================================
|
||||
|
||||
Cette page documente la procédure d'installation de la plateforme du TFJM²,
|
||||
aussi bien dans un environnement de développement que de production.
|
||||
|
||||
|
||||
Installation en production
|
||||
--------------------------
|
||||
|
||||
Installation de Docker
|
||||
""""""""""""""""""""""
|
||||
|
||||
Les outils du TFJM² sont déployés en utilisant `Docker <https://www.docker.com/>`_.
|
||||
Pour plus de détails sur la configuration pour le TFJM², voir
|
||||
`la page dédiée à Docker<docker.html>`_.
|
||||
|
||||
Commencez par installer Docker et Docker-Compose (qui sont a priori déjà installés
|
||||
sur la plateforme du TFJM²), en commençant par installer le dépôt APT de Docker :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# Add Docker's official GPG key:
|
||||
sudo apt update
|
||||
sudo apt install ca-certificates curl gnupg
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
|
||||
sudo chmod a+r /usr/share/keyrings/docker-archive-keyring.gpg
|
||||
|
||||
# Add the repository to Apt sources:
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
|
||||
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
|
||||
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt update
|
||||
|
||||
sudo apt install docker-ce docker-compose-plugin
|
||||
|
||||
Installation de la plateforme
|
||||
"""""""""""""""""""""""""""""
|
||||
|
||||
Dans le fichier ``docker-compose.yml``, configurer :
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
version: "3.8"
|
||||
services:
|
||||
# […]
|
||||
inscription:
|
||||
build: https://gitlab.com/animath/si/plateforme.git
|
||||
links:
|
||||
- postgres
|
||||
- redis
|
||||
- elasticsearch
|
||||
env_file:
|
||||
- ./secrets/inscription.env
|
||||
restart: always
|
||||
volumes:
|
||||
- "./data/inscription/media:/code/media"
|
||||
- "/etc/localtime:/etc/localtime:ro"
|
||||
networks:
|
||||
- tfjm
|
||||
labels:
|
||||
- "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"
|
||||
|
||||
postgres:
|
||||
image: postgres:16
|
||||
volumes:
|
||||
- "./data/postgresql_16:/var/lib/postgresql/data"
|
||||
restart: always
|
||||
env_file:
|
||||
- ./secrets/postgres.env
|
||||
networks:
|
||||
- tfjm
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
restart: always
|
||||
networks:
|
||||
- tfjm
|
||||
|
||||
elasticsearch:
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.9
|
||||
restart: always
|
||||
env_file:
|
||||
- ./secrets/elasticsearch.env
|
||||
networks:
|
||||
- tfjm
|
||||
# […]
|
||||
|
||||
En cas d'instance de pré-production, il est possible de changer de branche en
|
||||
rajoutant par exemple ``#dev`` à la fin de l'URL.
|
||||
|
||||
Les différents paramètres peuvent être modifiés si nécessaire, à commencer par les
|
||||
versions des autres services (PostgreSQL, Redis, Elasticsearch). Penser à mettre à jour
|
||||
cette documentation en cas de mise à jour future. Il est attendu d'avoir un réseau
|
||||
``tfjm`` configuré :
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
networks:
|
||||
tfjm:
|
||||
driver: bridge
|
||||
|
||||
L'URL utilisée peut être modifiée en changeant les options de Traefik, section ``labels``.
|
||||
|
||||
Configuration
|
||||
"""""""""""""
|
||||
|
||||
La configuration se fait essentiellement dans les fichiers d'environnement.
|
||||
|
||||
Pour la base de données, une seule variable d'environnement est requise :
|
||||
``POSTGRES_PASSWORD``, qui correspond au mot de passe maître de PostgreSQL.
|
||||
|
||||
Les variables d'environnement de ElasticSearch se résume à du paramétrage de mémoire :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
discovery.type=single-node
|
||||
ES_JAVA_OPTS=-Xms512m -Xmx512m
|
||||
|
||||
Redis n'a pas besoin de configuration particulière.
|
||||
|
||||
Enfin, pour la plateforme elle-même, il faut configurer les variables suivantes :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
TFJM_STAGE=prod
|
||||
DJANGO_SECRET_KEY=
|
||||
DJANGO_DB_TYPE=PostgreSQL
|
||||
DJANGO_DB_HOST=postgres
|
||||
DJANGO_DB_PORT=
|
||||
DJANGO_DB_NAME=
|
||||
DJANGO_DB_USER=
|
||||
DJANGO_DB_PASSWORD=
|
||||
REDIS_SERVER_HOST=redis
|
||||
REDIS_SERVER_PORT=6379
|
||||
SMTP_HOST=ssl0.ovh.net
|
||||
SMTP_PORT=465
|
||||
SMTP_HOST_USER=contact@tfjm.org
|
||||
SMTP_HOST_PASSWORD=
|
||||
FROM_EMAIL=Contact TFJM²
|
||||
SERVER_EMAIL=contact@tfjm.org
|
||||
HAYSTACK_INDEX_NAME=inscription-tfjm
|
||||
SYMPA_HOST=lists.tfjm.org
|
||||
SYMPA_URL=lists.tfjm.org/sympa
|
||||
SYMPA_EMAIL=contact@tfjm.org
|
||||
SYMPA_PASSWORD=
|
||||
HELLOASSO_CLIENT_ID=
|
||||
HELLOASSO_CLIENT_SECRET=
|
||||
|
||||
* ``TFJM_STAGE`` : ``prod`` ou ``dev``. ``prod`` est utilisé pour la production,
|
||||
``dev`` pour le développement ou pré-production. Permet de désactiver certaines
|
||||
fonctionnalités de production, notamment l'envoi de mails, le cache Redis, les listes
|
||||
de diffusion, et d'activer le débogage. Les erreurs seront ainsi directement affichées
|
||||
en développement tandis qu'elles seront envoyées par mail en production.
|
||||
* ``DJANGO_SECRET_KEY`` : correspond à
|
||||
`la clé secrète Django <https://docs.djangoproject.com/fr/5.0/ref/settings/#secret-key>`_,
|
||||
permettant de signer les sessions et les cookies. Il est important de la garder secrète.
|
||||
Pour la générer, il est possible d'utiliser la commande
|
||||
``python3 manage.py generate_secret_key``.
|
||||
* ``DJANGO_DB_TYPE`` : Le type de base de données à utiliser, parmi
|
||||
``PostgreSQL``, ``MySQL`` et ``SQLite``. ``SQLite`` n'est à utiliser qu'en développement
|
||||
local.
|
||||
* ``DJANGO_DB_HOST`` : L'hôte de la base de données pour les bases de données
|
||||
``PostgreSQL`` et ``MySQL``. Pour ``SQLite``, il faut mettre le chemin vers le fichier
|
||||
de base de données. Dans l'exemple ci-dessus, ``postgres`` est l'hôte Docker.
|
||||
* ``DJANGO_DB_PORT`` : Le port de la base de données. (PostgreSQL ou MySQL uniquement)
|
||||
Si laissé vide, utilisera le port par défaut du service, ``5432`` pour PostgreSQL et
|
||||
``3306`` pour MySQL.
|
||||
* ``DJANGO_DB_NAME`` : Le nom de la base de données. (PostgreSQL ou MySQL uniquement)
|
||||
* ``DJANGO_DB_USER`` : Le nom d'utilisateur de la base de données.
|
||||
(PostgreSQL ou MySQL uniquement)
|
||||
* ``DJANGO_DB_PASSWORD`` : Le mot de passe de la base de données.
|
||||
(PostgreSQL ou MySQL uniquement)
|
||||
* ``REDIS_SERVER_HOST`` : L'hôte de Redis (en production uniquement). Dans l'exemple
|
||||
ci-dessus, ``redis`` est l'hôte Docker.
|
||||
* ``REDIS_SERVER_PORT`` : Le port de Redis (en production uniquement). Si laissé vide,
|
||||
utilisera le port par défaut du service, ``6379``.
|
||||
* ``SMTP_HOST`` : L'hôte du serveur SMTP à utiliser pour envoyer les mails. Utilise par
|
||||
défaut le serveur d'OVH.
|
||||
* ``SMTP_PORT`` : Le port du serveur SMTP à utiliser pour envoyer les mails.
|
||||
* ``SMTP_HOST_USER`` : Le nom d'utilisateur du serveur SMTP à utiliser pour envoyer les
|
||||
mails. Correspond à l'identifiant OVH.
|
||||
* ``SMTP_HOST_PASSWORD`` : Le mot de passe du serveur SMTP à utiliser pour envoyer les
|
||||
mails. Correspond au mot de passe OVH.
|
||||
* ``FROM_EMAIL`` : Le nom lisible à utiliser comme expéditeur des mails
|
||||
(défaut : Contact TFJM²).
|
||||
* ``SERVER_EMAIL`` : L'adresse mail à utiliser comme expéditeur des mails
|
||||
(défaut : contact@tfjm.org).
|
||||
* ``HAYSTACK_INDEX_NAME`` : Le nom de l'index ElasticSearch à utiliser pour les recherches
|
||||
dans ElasticSearch (défaut : inscription-tfjm).
|
||||
* ``SYMPA_HOST`` : Le domaine des listes de diffusion Sympa utilisé.
|
||||
* ``SYMPA_URL`` : L'URL du serveur Sympa à utiliser pour gérer les listes de diffusion.
|
||||
* ``SYMPA_EMAIL`` : L'adresse mail à utiliser pour se connecter à Sympa.
|
||||
* ``SYMPA_PASSWORD`` : Le mot de passe à utiliser pour se connecter à Sympa.
|
||||
* ``HELLOASSO_CLIENT_ID`` : L'identifiant client HelloAsso à utiliser pour gérer les
|
||||
paiements HelloAsso.
|
||||
* ``HELLOASSO_CLIENT_SECRET`` : Le secret client HelloAsso à utiliser pour gérer les
|
||||
paiements HelloAsso. Doit être maintenu secret.
|
||||
|
||||
Installation de la base de données
|
||||
""""""""""""""""""""""""""""""""""
|
||||
|
||||
Pour gérer la base de données PostgreSQL, on peut utiliser les commandes suivantes :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
sudo docker compose up -d postgres
|
||||
sudo docker compose exec -u postgres postgres createuser -P inscription_tfjm # Création du compte `inscription_tfjm` en demander un mot de passe. À ne faire qu'une seule fois
|
||||
sudo docker compose exec -u postgres postgres createdb -O inscription_tfjm inscription_tfjm # Création de la base de données `inscription_tfjm` en utilisant le compte `inscription_tfjm`
|
||||
sudo docker compose exec -u postgres pg_dump inscription_tfjm > inscription_tfjm.sql # Pour sauvegarder la base de données `inscription_tfjm`
|
||||
sudo docker compose exec -u postgres dropdb inscription_tfjm # Pour supprimer la base de données `inscription_tfjm`
|
||||
|
||||
La suppression et la recréation sont utiles en cas de réinitialisation annuelle de la base
|
||||
de données.
|
||||
|
||||
|
||||
Lancement
|
||||
"""""""""
|
||||
|
||||
Pour lancer la plateforme, il suffit de lancer la commande suivante :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
sudo docker compose up -d inscription
|
||||
|
||||
Pour arrêter la plateforme, il suffit de lancer la commande suivante :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
sudo docker compose stop inscription
|
||||
|
||||
En cas de mise à jour de la plateforme, il suffit de lancer la commande suivante :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
sudo docker compose up -d --build inscription
|
||||
|
||||
Selon le principe de Docker, les données sont conservées dans un volume Docker, ici
|
||||
dans le dossier ``data/inscription``. Le reste est volatile, puisqu'il peut être recréé
|
||||
à partir du code source. Attention donc de ne pas coder en production (ce qui est de toute
|
||||
façon à proscrire !).
|
||||
|
||||
Les migrations de base de données sont automatiquement appliquées au lancement de la
|
||||
plateforme, de même que la collecte de fichiers statiques ou encore la génération de la
|
||||
documentation. Il n'y a rien à faire de spécial post-lancement, si ce n'est vérifier que
|
||||
tout fonctionne correctement.
|
||||
|
||||
|
||||
Installation en développement
|
||||
-----------------------------
|
||||
|
||||
L'installation sur une machine locale est plus légère et est utile pour pouvoir tester
|
||||
rapidement.
|
||||
|
||||
Commencez par récupérer le code source de la plateforme :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
git clone https://gitlab.com/animath/si/plateforme-tfjm.git
|
||||
cd plateforme-tfjm
|
||||
|
||||
Afin de pouvoir isoler l'environnement de développement, il est conseillé d'utiliser
|
||||
un environnement virtuel Python. Commencez alors par installer Python (si ce n'est pas
|
||||
déjà fait, au moins en version 3.10) ainsi que ``virtualenv``, puis vous pouvez créer
|
||||
l'environnement virtuel, et entrer dedans :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
Il vous faut ensuite installer les dépendances de la plateforme :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pip install -r requirements.txt
|
||||
|
||||
Pour exécuter des tests, il est nécessaire d'installer ``tox`` en supplément.
|
||||
|
||||
Ensuite, vous devez initialiser la base de données locale (qui sera stockée dans un
|
||||
fichier SQLite ``db.sqlite3``) :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
python3 manage.py migrate
|
||||
|
||||
Enfin, vous pouvez lancer la plateforme :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
python3 manage.py runserver
|
||||
|
||||
Vous pouvez alors accéder à la plateforme à l'adresse `<http://localhost:8000/>`_.
|
||||
Elle se recharge automatiquement à chaque modification du code source, inutile de la
|
||||
relancer.
|
||||
|
||||
Pour arrêter la plateforme, il suffit d'appuyer sur ``Ctrl+C`` dans le terminal.
|
||||
|
||||
Pour vous créer un compte administrateur⋅rice, il suffit de lancer la commande suivante :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
python3 manage.py createsuperuser
|
||||
|
||||
En cas de problème, vous pouvez librement supprimer la base de données locale et la
|
||||
recréer en réexécutant la commande ``python3 manage.py migrate``.
|
211
docs/dev/transition.rst
Normal file
@ -0,0 +1,211 @@
|
||||
Transition d'années
|
||||
===================
|
||||
|
||||
Entre deux sessions du TFJM², certaines opérations doivent être effectuées chaque année,
|
||||
afin de réinitialiser les données et de passer à l'année suivante.
|
||||
|
||||
Réinitialisation de la base de données
|
||||
--------------------------------------
|
||||
|
||||
Conservation des autorisations de droit à l'image
|
||||
"""""""""""""""""""""""""""""""""""""""""""""""""
|
||||
|
||||
La base de données du TFJM² est supprimée chaque année, avant chaque tournoi. Il n'y a
|
||||
pas de conservation de données personnelles à l'exception des autorisations de droit
|
||||
à l'image qui doivent être conservées pour des raisons légales pendant 5 ans.
|
||||
|
||||
Elles doivent alors être stockées sur Owncloud. Pour cela, il faut commencer par créer
|
||||
un dossier dans Owncloud, qui stockera lesdites autorisations.
|
||||
|
||||
Rendez-vous ensuite dans le conteneur Docker et exécuter le script :
|
||||
|
||||
.. code:: bash
|
||||
|
||||
./manage.py export_photo_authorizations
|
||||
|
||||
Cela a pour effet de générer un dossier dans ``output/photo_authorizations``, qui contient
|
||||
un dossier par équipe avec les différentes autorisations de droit à l'image.
|
||||
|
||||
Il faut maintenant récupérer ce dossier. Sortir du conteneur, et exécuter dans ``/srv/TFJM`` :
|
||||
|
||||
.. code:: bash
|
||||
|
||||
sudo docker cp tfjm-inscription-1:/code/output/photo_authorizations .
|
||||
sudo mv photo_authorizations/* "data/owncloud/data/Emmy/files/Autorisations de droit à l'image/Autorisations de droit à l'image 2024/"
|
||||
sudo chown -R www-data:root "data/owncloud/data/Emmy/files/Autorisations de droit à l'image/Autorisations de droit à l'image 2024"
|
||||
sudo rmdir photo_authorizations
|
||||
|
||||
Il faut enfin réactualiser Owncloud. Exécuter en tant que www-data :
|
||||
|
||||
.. code:: bash
|
||||
|
||||
sudo docker compose exec -u www-data cloud php occ files:scan Emmy
|
||||
|
||||
Vérifiez enfin que les fichiers sont bien accessibles dans l'interface Web.
|
||||
Ne pas oublier enfin de partager le dossier.
|
||||
|
||||
|
||||
Sauvegarde de secours
|
||||
"""""""""""""""""""""
|
||||
|
||||
Si les données doivent être supprimées, il peut être utile de réaliser une sauvegarde à conserver
|
||||
quelques mois.
|
||||
|
||||
.. danger::
|
||||
|
||||
Cette sauvegarde ne doit être faite qu'à des fins utiles et supprimée dès que plus nécessaire.
|
||||
|
||||
Sauvegardez alors le dossier ``/srv/TFJM/data/inscription/media`` et exportez la base de données :
|
||||
|
||||
.. code:: bash
|
||||
|
||||
sudo cp -r data/inscription/media data/inscription/media-2024
|
||||
sudo docker compose exec -u postgres postgres pg_dump inscription_tfjm | sudo tee inscription_tfjm_bkp_2024.sql > /dev/null
|
||||
|
||||
|
||||
Réinitialisation effective
|
||||
""""""""""""""""""""""""""
|
||||
|
||||
Il est désormais possible de réinitialiser la base de données, après avoir éteint le serveur :
|
||||
|
||||
.. code:: bash
|
||||
|
||||
sudo docker compose stop inscription
|
||||
sudo rm -r data/inscription/media/*
|
||||
sudo docker compose exec -u postgres postgres dropdb inscription_tfjm
|
||||
sudo docker compose exec -u postgres postgres createdb -O inscription_tfjm inscription_tfjm
|
||||
|
||||
Redémarrez enfin le serveur (les migrations seront créées automatiquement)
|
||||
et créez un nouveau compte administrateur⋅rice :
|
||||
|
||||
.. code:: bash
|
||||
|
||||
sudo docker compose up -d inscription
|
||||
sudo docker compose exec inscription bash
|
||||
./manage.py createsuperuser
|
||||
|
||||
Vérifiez finalement le bon fonctionnement du site.
|
||||
|
||||
|
||||
Sites Django
|
||||
""""""""""""
|
||||
|
||||
Après avoir réinitialisé les données, il faut mettre à jour le site Django, qui permettra
|
||||
d'avoir notamment des noms de domaine correct dans les mails envoyés.
|
||||
|
||||
Se connecter alors sur le site réouvert, puis dans la partie « Administration », chercher la
|
||||
section « Sites » et modifier l'unique site présent. Vous pouvez ensuite effectuer les modifications
|
||||
à réaliser.
|
||||
|
||||
|
||||
Nouveaux paramètres pour la nouvelle année
|
||||
------------------------------------------
|
||||
|
||||
Certains paramètres doivent être modifiés pour prendre en compte la nouvelle année.
|
||||
|
||||
Dates d'inscription
|
||||
"""""""""""""""""""
|
||||
|
||||
Les inscriptions sont permises uniquement entre l'ouverture et la fermeture, afin d'éviter
|
||||
d'avoir des personnes s'inscrivant en dehors du TFJM².
|
||||
|
||||
Pour cela, dans votre projet local, rendez-vous dans ``tfjm/settings.py`` et cherchez
|
||||
le paramètre ``REGISTRATION_DATES`` (pour le TFJM²). Modifiez alors les sous-paramètres
|
||||
``open`` et ``close`` pour définir les dates pendant lesquelles les inscriptions des
|
||||
participant⋅es sont permises pour cette nouvelle année. Elles doivent être au format ISO.
|
||||
|
||||
Exemple pour l'année 2025 où les inscriptions ouvrent au 8 janvier midi pour fermer
|
||||
le 2 mars à 22h :
|
||||
|
||||
.. code:: python
|
||||
|
||||
REGISTRATION_DATES = dict(
|
||||
open=datetime.fromisoformat("2025-01-15T12:00:00+0100"),
|
||||
close=datetime.fromisoformat("2025-03-02T22:00:00+0100"),
|
||||
)
|
||||
|
||||
Il faudra ensuite commiter la modification et redémarrer le serveur pour que la modification
|
||||
prenne effet.
|
||||
|
||||
|
||||
Noms des problèmes
|
||||
""""""""""""""""""
|
||||
|
||||
Toujours dans la configuration dans ``tfjm/settings.py``, la liste des problèmes doit être
|
||||
modifiée pour que leurs noms s'affichent correctement lors du tirage au sort.
|
||||
|
||||
Cherchez le paramètre ``PROBLEMS`` et mettez alors à jour la liste, dans l'ordre, des noms
|
||||
des problèmes.
|
||||
|
||||
À nouveau, il est nécessaire de commiter la modification et redémarrer le serveur.
|
||||
|
||||
|
||||
Paramètres des tournois
|
||||
"""""""""""""""""""""""
|
||||
|
||||
Il faut enfin paramétrer les différentes dates des tournois.
|
||||
|
||||
Pour cela, connectez-vous sur la plateforme (avec un compte administrateur⋅rice), et dans l'onglet
|
||||
« Tournois », vous pouvez créer les différents tournois avec les différentes dates pour chaque tournoi.
|
||||
Plus d'information sur les différents paramètres dans la `section concernée
|
||||
<../orga.html#creer-un-tournoi>`_
|
||||
|
||||
|
||||
À la fin du tournoi
|
||||
-------------------
|
||||
|
||||
Lorsque le tournoi est terminé, il faut récupérer les informations à stocker de façon pérenne,
|
||||
notamment les solutions des équipes, les résultats ainsi que les autorisation de droit à l'image
|
||||
comme indiqué précédemment.
|
||||
|
||||
Conservation des autorisations de droit à l'image
|
||||
"""""""""""""""""""""""""""""""""""""""""""""""""
|
||||
|
||||
Se référer à la section plus haut.
|
||||
|
||||
|
||||
Conservation des solutions des équipes
|
||||
""""""""""""""""""""""""""""""""""""""
|
||||
|
||||
Le processus est très similaire à la conservation des autorisations de droit à l'image.
|
||||
Il faut d'abord, dans le conteneur, lancer le script dédié pour récupérer les solutions
|
||||
dans ``/code/output/solutions`` :
|
||||
|
||||
.. code:: bash
|
||||
|
||||
./manage.py export_solutions
|
||||
|
||||
On sort du conteneur et on récupère les solutions pour les déplacer dans Owncloud :
|
||||
|
||||
.. code:: bash
|
||||
|
||||
sudo docker cp tfjm-inscription-1:/code/output/solutions .
|
||||
sudo mv solutions/* "data/owncloud/data/Emmy/files/Solutions écrites 2024/"
|
||||
sudo chown -R www-data:root "data/owncloud/data/Emmy/files/Solutions écrites 2024"
|
||||
sudo rmdir solutions
|
||||
|
||||
Il faut enfin réactualiser Owncloud. Exécuter en tant que www-data :
|
||||
|
||||
.. code:: bash
|
||||
|
||||
sudo docker compose exec -u www-data cloud php occ files:scan Emmy
|
||||
|
||||
Vérifiez enfin que les fichiers sont bien accessibles dans l'interface Web.
|
||||
Ne pas oublier enfin de partager le dossier.
|
||||
|
||||
|
||||
Génération de la page de résultats Wordpress
|
||||
""""""""""""""""""""""""""""""""""""""""""""
|
||||
|
||||
Pour finir, il est possible de récupérer les notes pour chaque tournoi afin de générer
|
||||
la page Wordpress dans la section *Éditions précédentes*.
|
||||
|
||||
Il suffit de lancer le script ``./manage.py export_results``, qui donne le texte brut pour
|
||||
Wordpress à ajouter sur la page de l'édition qui vient de se terminer dans l'onglet
|
||||
*Éditions précédentes*.
|
||||
|
||||
Pensez à bien inclure sur cette page le lien vers les problèmes de l'année, ainsi que le
|
||||
lien vers le dossier partagé dans le Owncloud concernant les solutions des équipes.
|
||||
|
||||
Assurez-vous de mettre à jour la page *Éditions précédentes* afin d'inclure le lien vers
|
||||
la page nouvellement créée.
|
645
docs/draw.rst
Normal file
@ -0,0 +1,645 @@
|
||||
Tirage au sort
|
||||
==============
|
||||
|
||||
La phase de tirage au sort est celle qui va déterminer d'une part
|
||||
dans quelle poule se trouve chaque équipe et dans quelle ordre elles
|
||||
défendront leurs problèmes, et d'autre part quel problème défendra
|
||||
quelle équipe. Cette phase a lieu dans la semaine qui précède le
|
||||
tournoi, typiquement le mardi entre 19h et 21h.
|
||||
|
||||
Exception pour la finale nationale : seul le premier tour est tiré
|
||||
au sort à ce moment-là, le tirage au sort pour le second tour est
|
||||
réalisé immédiatement après le premier tour et dépend des résultats.
|
||||
Sinon, pour les finales régionales, les tirages au sort pour les
|
||||
tours 1 et 2 sont réalisés successivement, bien que les solutions
|
||||
à opposer et à rapporter pour le second tour ne sont pas accessibles
|
||||
avant la fin du premier tour.
|
||||
|
||||
**Disclaimer :** si cette documentation est normalement tenue à jour,
|
||||
seul le règlement et ses annexes font foi. Elle est essentiellement ici
|
||||
pour décrire le fonctionnement de la plateforme vis-à-vis du tirage
|
||||
au sort. En cas de litige, merci de se fier au règlement, et de
|
||||
s'y référer `sur le site du TFJM² <https://tfjm.org/reglement/>`_.
|
||||
|
||||
|
||||
Principe
|
||||
--------
|
||||
|
||||
.. warning:: Cette section arborde en détail le fonctionnement théorique
|
||||
du tirage au sort. Si vous souhaitez comprendre comment se déroule
|
||||
le tirage au sort en pratique sur la plateforme, merci de vous référer
|
||||
directement à la section `Déroulement du tirage`_.
|
||||
|
||||
Composition des poules
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Le principe du tirage au sort est détaillé dans la fiche pratique dédiée.
|
||||
|
||||
Chaque équipe commence par désigner un⋅e capitaine d'équipe qui s'occupera
|
||||
de réaliser les tirages et de prendre les décisions.
|
||||
|
||||
Les poules sont triées de la plus grande à la plus petite, en terme de
|
||||
nombre d'équipes.
|
||||
|
||||
Les capitaines d'équipe lancent un dé entre 1 et 100. S'il y a des égalités,
|
||||
alors les équipes concernées relancent leur dé jusqu'à ne plus avoir d'égalité.
|
||||
Ce score détermine les compositions des poules pour les deux tours (sauf pour
|
||||
la finale nationale ou ce n'est que le premier tour).
|
||||
|
||||
On trie ensuite les équipes par ordre croissant de score de dé. On remplit
|
||||
ensuite les poules une à une de façon gloutonne : si par exemple la
|
||||
première poule est une poule à 3 équipes, alors on prend les 3 premières
|
||||
équipes de la liste et on les place dans cette poule. On recommence ensuite
|
||||
avec la deuxième poule, etc.
|
||||
|
||||
Au sein d'une poule, l'ordre de passage des équipes est déterminé par
|
||||
l'ordre des restes modulo 100 des scores de dé multipliés par 27.
|
||||
Par exemple, si les scores de dé sont 12, 34 et 56, alors si on
|
||||
multiplie par 27 et qu'on prend le reste modulo 100, on obtient
|
||||
respectivement 24, 82 et 52. L'ordre de passage sera donc l'équipe 1,
|
||||
l'équipe 3, puis l'équipe 2. Ce choix est réalisé pour avoir un semblant
|
||||
déterministe de mélange.
|
||||
|
||||
Pour le second tour, on considère à nouveau les scores de dé, où l'équipe
|
||||
qui a eu le score le plus faible sera dans la première poule, celle qui
|
||||
a eu le second score le plus faible dans la deuxième poule, etc. L'ordre
|
||||
de passage est cette fois-ci plus simple, puisqu'il est directement croissant
|
||||
avec les scores de dé. Exception : pour les poules à 5, l'équipe avec le
|
||||
score le plus gros sera la dernière de la première poule, et il ne peut
|
||||
y avoir qu'une seule poule à 5 équipes.
|
||||
|
||||
Considérons par exemple un tournoi fictif composé de 11 équipes, avec
|
||||
une poule de 5 équipes et deux poules de 3 équipes. Les équipes sont
|
||||
AAA, BBB, CCC, DDD, EEE, FFF, GGG, HHH, III, JJJ et KKK. Les scores
|
||||
de dés sont :
|
||||
|
||||
.. table:: Exemple de tirage de dés et de répartition dans les poules
|
||||
|
||||
+-----------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
| Équipe | AAA | BBB | CCC | DDD | EEE | FFF | GGG | HHH | III | JJJ | KKK |
|
||||
+=============================+=====+=====+=====+=====+=====+=====+=====+=====+=====+=====+=====+
|
||||
| Score de dé | 45 | 17 | 64 | 3 | 98 | 41 | 34 | 63 | 86 | 23 | 70 |
|
||||
+-----------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
| Poule au 1\ :sup:`er` tour | B | A | B | A | C | A | A | B | C | A | C |
|
||||
+-----------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
| Poule au 2\ :sup:`ème` tour | C | B | B | A | A | B | A | A | A | C | C |
|
||||
+-----------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
|
||||
Explication : les équipes **DDD**, **BBB**, **JJJ**, **GGG** et **FFF** sont les
|
||||
équipes ayant réalisé le plus bas score (respectivement 3, 17, 23, 34 et, 41) et
|
||||
sont alors placées dans la poule **A**. Les équipes **AAA**, **HHH** et **CCC**
|
||||
sont les trois suivantes (avec pour scores 45, 63 et 64) et composeront alors
|
||||
la poule **B** tandis que les équipes **KKK**, **III** et **EEE** (avec pour
|
||||
scores 70, 86 et 98) seront dans la poule **C** au premier tour.
|
||||
|
||||
Ainsi pour le second tour, l'équipe **DDD** ira dans la poule **A**, **BBB**
|
||||
dans la poule **B**, **JJJ** dans la poule **C**, **GGG** dans la poule **A**,
|
||||
**FFF** dans la poule **B**, **AAA** dans la poule **C**, **HHH** dans la poule
|
||||
**A**, **CCC** dans la poule **B**, **KKK** dans la poule **C**, **III** dans
|
||||
la poule **A** et enfin **EEE** également dans la poule **A** puisqu'elle y est
|
||||
forcée.
|
||||
|
||||
Pour ce qui est de l'ordre de passage :
|
||||
|
||||
.. table:: Exemple d'ordre de passage pour le premier tour
|
||||
|
||||
+--------------------------------+-----------------------------+-----------------+-----------------+
|
||||
| Poule | Poule A | Poule B | Poule C |
|
||||
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
| Équipe | DDD | BBB | JJJ | GGG | FFF | AAA | HHH | CCC | KKK | III | EEE |
|
||||
+================================+=====+=====+=====+=====+=====+=====+=====+=====+=====+=====+=====+
|
||||
| Score de dé | 3 | 17 | 23 | 34 | 41 | 45 | 63 | 64 | 70 | 86 | 98 |
|
||||
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
| Score de dé fois 27 modulo 100 | 81 | 59 | 21 | 18 | 7 | 15 | 1 | 28 | 90 | 22 | 46 |
|
||||
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
| Ordre de passage dans la poule | 5 | 4 | 3 | 2 | 1 | 2 | 1 | 3 | 3 | 1 | 2 |
|
||||
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
|
||||
.. table:: Exemple d'ordre de passage pour le second tour
|
||||
|
||||
+--------------------------------+-----------------------------+-----------------+-----------------+
|
||||
| Poule | Poule A | Poule B | Poule C |
|
||||
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
| Équipe | DDD | GGG | HHH | III | EEE | BBB | FFF | CCC | JJJ | AAA | KKK |
|
||||
+================================+=====+=====+=====+=====+=====+=====+=====+=====+=====+=====+=====+
|
||||
| Score de dé | 3 | 34 | 63 | 86 | 98 | 17 | 41 | 64 | 23 | 45 | 70 |
|
||||
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
| Ordre de passage dans la poule | 1 | 2 | 3 | 4 | 5 | 1 | 2 | 3 | 1 | 2 | 3 |
|
||||
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
|
||||
|
||||
Pour la finale nationale, les ordres de passage et les répartitions dans les poules
|
||||
du second tour sont décidé⋅es dans l'ordre décroissant des résultats obtenus au
|
||||
premier tour. Si par exemple l'équipe **AAA** a fini en tête du premier tour, elle
|
||||
sera alors la première à passer dans la poule **A** du second tour.
|
||||
|
||||
|
||||
Tirage des problèmes
|
||||
~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Une fois les équipes réparties dans les poules, on tire au sort les problèmes. Ce
|
||||
tirage se déroule poule par poule.
|
||||
|
||||
Au début du tirage au sort des problèmes d'une poule, les équipes de la poule
|
||||
commencent par tirer un nouveau dé à 100 faces. S'il y a des égalités, alors
|
||||
les équipes concernées relancent leur dé jusqu'à ne plus avoir d'égalité.
|
||||
Les équipes sont triées dans l'ordre décroissant de leur score de dé. L'équipe
|
||||
ayant tiré le plus gros score est invitée à tirer son problème en première.
|
||||
|
||||
Il y a deux contraintes de tirage :
|
||||
|
||||
* Il n'est pas possible de choisir le même problème qu'une autre équipe de la poule ;
|
||||
* Il n'est pas possible de défendre le même problème pour les deux jours.
|
||||
|
||||
À noter qu'il n'est pas impossible de tirer et de choisir un problème qui n'a
|
||||
pas été traité par l'équipe, bien que cela est fortement non recommandé.
|
||||
|
||||
Une fois l'ordre de tirage établi, les équipes tirent tour à tour leur problème,
|
||||
tant qu'elle ne l'ont pas encore choisi. Pour cela, l'équipe active tire au sort
|
||||
un problème. Il est attendu que le problème tiré garantisse les deux contraintes,
|
||||
et que donc il n'est pas possible de tirer un problème déjà choisi par une autre
|
||||
équipe, ou bien le problème défendu lors du tour 1 si on est au tour 2.
|
||||
|
||||
L'équipe a désormais deux choix :
|
||||
|
||||
* Accepter le problème. Dans ce cas, ce sera ce problème qu'elle défendra, et
|
||||
son tirage est termé.
|
||||
* Refuser le problème. Dans ce cas, la main passe à l'équipe suivante, selon le
|
||||
score des dés. Exception : si le problème tiré est un problème qui a déjà été
|
||||
refusé auparavant, alors dans ce cas l'équipe peut immédiatement tirer un
|
||||
nouveau problème (mais elle a tout de même le choix de l'accepter ou de le
|
||||
refuser).
|
||||
|
||||
**Attention :** si une équipe refuse trop de problèmes, alors elle pourra être
|
||||
pénalisée. Chaque équipe a droit à ``P - 5`` refus sans pénalités, où ``P``
|
||||
est le nombre de problèmes disponibles cette année. Par exemple, s'il y a
|
||||
8 problèmes cette année, alors les équipes ont droit à 3 refus sans pénalités.
|
||||
Seuls les refus distincts comptent : refuser une deuxième fois un problème
|
||||
déjà refusé ne compte pas. Au-delà de ces refus gratuits, l'équipe se verra
|
||||
dotée d'une pénalité de 25 % sur le coefficient de l'oral de défense, par
|
||||
refus. Par exemple, si une équipe refuse 4 problèmes avec un coefficient
|
||||
sur l'oral de défense normalement à ``1.5``, son coefficient passera à ``1.125``.
|
||||
|
||||
Une fois que toutes les équipes de la poule ont tiré leur problème, on passe
|
||||
à la poule suivante. Une fois que toutes les poules ont vu leurs problèmes
|
||||
tirés, on recommence pour le second tour, à partir de la première poule (déjà
|
||||
définie), sauf pour la finale nationale. Le tirage au sort est terminé lorsque
|
||||
toutes les poules ont vu leurs problèmes tirés pour tous les tours.
|
||||
|
||||
|
||||
Récupération des solutions
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Les solutions défendues pour le premier tour dans une poule sont immédiatement
|
||||
accessibles après le tirage au sort aux équipes qui doivent opposer ou rapporter
|
||||
ces solutions. Pour le second tour, elles ne seront disponibles qu'après la fin
|
||||
du premier tour.
|
||||
|
||||
Elles seront également envoyées par les organisateurices localaux, dans les mêmes
|
||||
délais.
|
||||
|
||||
|
||||
Tirage au sort sur la plateforme
|
||||
--------------------------------
|
||||
|
||||
Le tirage est sort est géré directement sur la plateforme, de façon intuitive,
|
||||
ergonomique et dynamique.
|
||||
|
||||
Il est accessible à l'adresse `<https://inscription.tfjm.org/draw/>`_, ou bien
|
||||
en accédant à l'onglet « Tirage au sort » sur le bandeau de navigation, pourvu
|
||||
d'être organisateurice ou bien d'être dans une équipe validée.
|
||||
|
||||
|
||||
Présentation de l'interface
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
L'interface est divisée en 5 sections :
|
||||
|
||||
* Le nom du tournoi dans lequel se passe le tirage au sort ;
|
||||
* Les derniers résultats de dés par équipe ;
|
||||
* Le récapitulatif du tirage au sort en cours ;
|
||||
* L'étape actuelle ;
|
||||
* Les tableaux de passage par poule.
|
||||
|
||||
.. figure:: _static/img/draw_general.png
|
||||
:alt: Interface générale du tirage au sort
|
||||
|
||||
Interface générale du tirage au sort
|
||||
|
||||
Sur cette capture d'écran, on voit par exemple que l'on est actuellement dans le
|
||||
tournoi de Strasbourg. Le tirage du tour 1 est déjà terminé, et on est en train
|
||||
de tirer la poule B2.
|
||||
|
||||
Nom du tournoi
|
||||
..............
|
||||
|
||||
Les différents onglets représentent les différents tournois.
|
||||
|
||||
Pour une équipe participante ou un⋅e organisateurice local⋅e, il peut n'y avoir
|
||||
qu'un seul tournoi, celui qui concerne l'utilisateurice.
|
||||
|
||||
Les administrateurices ont accès à tous les onglets, ce qui peut permettre de
|
||||
passer d'un tirage au sort à un autre facilement, en un clic et sans délai.
|
||||
|
||||
.. figure:: _static/img/draw_tournament_tabs.png
|
||||
:alt: Onglets des différents tirages au sort par tournoi
|
||||
|
||||
Le tournoi de Strasbourg est le tournoi sélectionné dans cet exemple.
|
||||
|
||||
|
||||
Derniers résultats de dés
|
||||
.........................
|
||||
|
||||
Les derniers jets de dés sont affichés dans cette section, avec le trigramme de
|
||||
chaque équipe et son score de dé s'il existe. Le score est vert si l'équipe a
|
||||
déjà tiré son dé, sinon il est jaune.
|
||||
|
||||
Les organisateurices peuvent cliquer sur le score de dé pour jeter un dé à la place
|
||||
de l'équipe, ce qui est notamment utile à des fins de débuggage ou de souci technique
|
||||
avec l'équipe en question.
|
||||
|
||||
.. figure:: _static/img/draw_last_rolls.png
|
||||
:alt: Derniers jets de dés
|
||||
|
||||
Ici, les équipes BBB, DDD, EEE, GGG et HHH ont déjà tiré leur dé, avec pour scores
|
||||
respectifs 57, 66, 58, 41 et 68, tandis que les équipes AAA, CCC, FFF et III n'ont
|
||||
pas encore tiré leur dé.
|
||||
|
||||
Récapitulatif du tirage au sort
|
||||
...............................
|
||||
|
||||
La section de gauche affiche le récapitulatif du tirage au sort en cours.
|
||||
Elle n'est là qu'à des fins d'affichage, il n'est pas possible d'interagir avec.
|
||||
|
||||
La colonne de gauche concerne le premier tour, et la colonne de droite le second tour.
|
||||
Chaque colonne est divisée en plusieurs parties, une pour chaque poule. Enfin, chaque
|
||||
poule contient la liste des équipes membres, triées par ordre de passage. Elles ne sont
|
||||
affichées que lorsque l'ordre de passage est déterminé.
|
||||
|
||||
Le tour actuel de tirage est mis en surbrillance. La poule actuelle qui réalise son
|
||||
tirage est sur fond vert, l'équipe qui doit tirer son problème est sur fond bleu.
|
||||
|
||||
La cellule d'une équipe affiche son trigramme, son problème sélectionné pour ce tour
|
||||
(s'il est déjà choisi), l'ensemble des problèmes qu'elle a refusé, et le cas échéant
|
||||
ses pénalités.
|
||||
|
||||
.. figure:: _static/img/draw_recap.png
|
||||
:alt: Récapitulatif du tirage au sort
|
||||
|
||||
Récapitulatif du tirage au sort en cours
|
||||
|
||||
La poule A1 est composée des équipes **GGG**, **AAA** et **III**, qui défendront
|
||||
dans cet ordre. L'équipe **GGG** a tiré le problème 7, et l'a accepté. L'équipe
|
||||
**AAA** a tiré le problème 3, et l'a accepté, après avoir refusé le problème 2.
|
||||
L'équipe **III** a tiré le problème 1, et l'a accepté, après avoir refusé les
|
||||
problèmes 3, 4, 2 et 6, et a donc une pénalité de 25 % sur son coefficient de
|
||||
l'oral de défense. Notez qu'en poule B1, l'équipe **BBB** a bien pu accepter le
|
||||
problème 8 après l'avoir refusé une première fois.
|
||||
|
||||
Dans la poule B2, actuellement en tirage, l'équipe **EEE** a déjà accepté le
|
||||
problème 6, tandis que l'équipe **CCC** a refusé les problèmes 5 et 2 et que
|
||||
l'équipe **AAA** a refusé le problème 2. C'est actuellement au tour de
|
||||
l'équipe **AAA**, qui doit choisir d'accepter ou de refuser le problème 7
|
||||
qu'elle vient de tirer.
|
||||
|
||||
Notez la présence du bouton « Annuler la dernière étape », visible uniquement
|
||||
pour les organisateurices. Il permet d'annuler la dernière action qui vient
|
||||
d'avoir lieu. Il est utile en cas de souci technique ou de mauvaise manipulation.
|
||||
|
||||
En cas de besoin majeur, un bouton « Annuler » est disponible en bas de l'interface
|
||||
afin de réinitialiser le tirage.
|
||||
|
||||
|
||||
Étape actuelle
|
||||
..............
|
||||
|
||||
La partie de droite de l'écran est dédiée à l'explication de l'étape en cours du tirage
|
||||
au sort. Tout est expliqué dans un encadré bleu.
|
||||
|
||||
À noter qu'un bouton « Exporter » peut être disponible pour les organisateurices à la
|
||||
suite du tirage d'une poule. Il est utile pour valider le tirage et transmettre les
|
||||
données au reste de la plateforme, débloquant notamment l'accès aux différentes solutions
|
||||
pour les équipes.
|
||||
|
||||
Le tirage au sort est découpé en 4 phases majeures :
|
||||
|
||||
Composition des poules
|
||||
''''''''''''''''''''''
|
||||
|
||||
La première phase est la composition des poules. Elle est détaillée dans la section
|
||||
« Composition des poules ». Le texte affiché :
|
||||
|
||||
.. note::
|
||||
Nous allons commencer le tirage des problèmes.
|
||||
Vous pouvez à tout moment poser toute question si quelque chose n'est pas clair ou ne va pas.
|
||||
|
||||
Nous allons d'abord tirer les poules et l'ordre de passage pour le premier tour avec toutes les équipes puis pour chaque poule, nous tirerons l'ordre de tirage pour le tour et les problèmes.
|
||||
|
||||
Les capitaines, vous pouvez désormais toustes lancer un dé 100, en cliquant sur le gros bouton. Les poules et l'ordre de passage lors du premier tour sera l'ordre croissant des dés, c'est-à-dire que le plus petit lancer sera le premier à passer dans la poule A.
|
||||
|
||||
Pour plus de détails sur le déroulement du tirage au sort, le règlement est accessible sur https://tfjm.org/reglement.
|
||||
|
||||
Un gros émoji « dé » 🎲 est affiché pour les participant⋅es, leur permettant de lancer
|
||||
leur dé. Le résultat sera affiché dans la section « Derniers jets de dés », et le
|
||||
bouton disparaîtra.
|
||||
|
||||
Les organisateurices peuvent également appuyer sur le bouton, ce qui aura pour effet
|
||||
de lancer un dé pour une équipe qui n'a pas encore lancé son dé. Cela peut être utile
|
||||
pour débugger ou pour aider une équipe qui a un souci technique. Rappelons toutefois
|
||||
qu'il suffit de cliquer sur le score de dé d'une équipe pour lancer son dé à sa place.
|
||||
|
||||
.. figure:: _static/img/draw_waiting_passage_order.png
|
||||
:alt: Étape de composition des poules
|
||||
|
||||
Le tirage au sort est en attente de la composition des poules.
|
||||
|
||||
|
||||
Ordre de tirage des problèmes
|
||||
'''''''''''''''''''''''''''''
|
||||
|
||||
Une fois les poules constituées, il faut déterminer dans quel ordre les équipes
|
||||
tireront leur problème, par le biais d'un nouveau jet de dé. Un texte par exemple
|
||||
peut être :
|
||||
|
||||
.. note::
|
||||
Nous passons au tirage des problèmes pour la poule Poule A1, entre les équipes GGG, AAA, III. Les capitaines peuvent lancer un dé 100 en cliquant sur le gros bouton pour déterminer l'ordre de tirage. L'équipe réalisant le plus gros score pourra tirer en premier.
|
||||
|
||||
Pour plus de détails sur le déroulement du tirage au sort, le règlement est accessible sur https://tfjm.org/reglement.
|
||||
|
||||
L'émoji « dé » 🎲 n'est affiché que pour les équipes membre de la poule concernée.
|
||||
Encore une fois, les organisateurices peuvent lancer le dé à la place d'une équipe
|
||||
en cliquant sur son score de dé, ou bien en cliquant sur l'émoji.
|
||||
|
||||
.. figure:: _static/img/draw_waiting_choose_problem_order.png
|
||||
:alt: Étape de tirage de l'ordre de tirage des problèmes
|
||||
|
||||
Le tirage au sort est en attente du tirage l'ordre de tirage pour la poule A1.
|
||||
|
||||
|
||||
Tirage des problèmes
|
||||
''''''''''''''''''''
|
||||
|
||||
Une fois l'ordre de tirage déterminé, les équipes sont invitées à tirer leurs
|
||||
problèmes. Le texte affiché est par exemple :
|
||||
|
||||
.. note::
|
||||
C'est au tour de l'équipe GGG de choisir son problème. Cliquez sur l'urne au milieu pour tirer un problème au sort.
|
||||
|
||||
Pour plus de détails sur le déroulement du tirage au sort, le règlement est accessible sur https://tfjm.org/reglement.
|
||||
|
||||
Un émoji « urne » 🗳️ est visible uniquement pour l'équipe active, qui doit tirer
|
||||
son problème. Elle est invitée à cliquer dessus. À nouveau, les organisateurices
|
||||
peuvent cliquer dessus à la place de l'équipe.
|
||||
|
||||
.. figure:: _static/img/draw_waiting_problem_draw.png
|
||||
:alt: Étape de tirage des problèmes
|
||||
|
||||
Le tirage au sort est en attente du tirage du problème de l'équipe GGG.
|
||||
|
||||
|
||||
Choix du problème
|
||||
'''''''''''''''''
|
||||
|
||||
Une fois le problème tiré, l'équipe peut alors choisir de l'accepter ou de le
|
||||
refuser. Le texte affiché est par exemple :
|
||||
|
||||
.. note::
|
||||
L'équipe GGG a tiré le problème 7 : Drôles de cookies. Elle peut décider d'accepter ou de refuser ce problème. Il reste 3 refus sans pénalité.
|
||||
|
||||
Pour plus de détails sur le déroulement du tirage au sort, le règlement est accessible sur https://tfjm.org/reglement.
|
||||
|
||||
Deux boutons sont affichés sur la page, un bouton « Accepter », en vert,
|
||||
et un bouton « Refuser », en rouge. L'équipe peut cliquer sur l'un ou l'autre
|
||||
pour faire son choix. Les organisateurices peuvent également cliquer sur l'un
|
||||
ou l'autre pour faire le choix à la place de l'équipe. Ces boutons sont
|
||||
invisibles pour les autres équipes.
|
||||
|
||||
.. figure:: _static/img/draw_choose_problem.png
|
||||
:alt: Étape de choix du problème
|
||||
|
||||
L'équipe GGG a tiré le problème 7. Elle peut choisir de l'accepter ou de le refuser.
|
||||
|
||||
.. TODO
|
||||
.. note::
|
||||
Cette section sera mise à jour plus tard.
|
||||
|
||||
|
||||
Tableaux de passage par poule
|
||||
.............................
|
||||
|
||||
Ces tableaux, actualisés en temps réel, permettent d'afficher quels seront les
|
||||
différents passages lors d'une poule. En particulier, quelle équipe défendra
|
||||
quel problème, et quelle équipe opposera et laquelle rapportera.
|
||||
|
||||
.. figure:: _static/img/draw_passage_tables.png
|
||||
:alt: Tableaux de passage par poule
|
||||
|
||||
Tableaux de passage par poule
|
||||
|
||||
|
||||
Déroulement du tirage
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Cette section décrit le déroulement du tirage au sort sur la plateforme, sans
|
||||
rentrer dans les détails théoriques. Si la partie théorique vous intéresse,
|
||||
rendez-vous à la section `Principe`_.
|
||||
|
||||
Démarrage du tirage au sort
|
||||
...........................
|
||||
|
||||
Si le tirage n'a pas encore commencé, un message d'alerte s'affiche.
|
||||
|
||||
Les organisateurices peuvent lancer le tirage au sort en cliquant sur le bouton
|
||||
« Démarrer ! ». Iels doivent d'abord paramétrer le format du tirage, en indiquant
|
||||
le nombre d'équipe par poule, en séparant les nombres par des plus. Par exemple,
|
||||
pour un tournoi de 9 équipes avec 3 poules de 3 équipes, on écrira ``3+3+3``.
|
||||
|
||||
.. figure:: _static/img/draw_start.png
|
||||
:alt: Création du tirage au sort
|
||||
|
||||
Le formulaire de lancement du tirage au sort. Ici, on souhaite un tirage
|
||||
au sort avec 3 poules de 3 équipes, pour le tournoi de Strasbourg.
|
||||
|
||||
Attention : si toutes les poules n'ont pas la même capacité, il est essentiel de
|
||||
mettre les poules dans l'ordre décroissant de capacité. Par exemple, pour un tournoi
|
||||
de 8 équipes avec une poule de 5 équipes et une poules de 3 équipes, il faut
|
||||
écrire ``5+3``. De plus, il n'est pas possible d'avoir 2 poules de 5 équipes.
|
||||
|
||||
|
||||
Composition des poules
|
||||
......................
|
||||
|
||||
Les capitaines d'équipe sont invités à lancer un dé à 100 faces. Pour cela, ils
|
||||
peuvent cliquer sur le gros bouton avec l'émoji « dé » 🎲 sur la droite de l'écran.
|
||||
Le résultat est affiché dans la section « Derniers jets de dés ».
|
||||
|
||||
.. figure:: _static/img/draw_waiting_passage_order_full.png
|
||||
:alt: Étape de composition des poules
|
||||
|
||||
Le tirage au sort est en attente de la composition des poules.
|
||||
Les équipes **AAA**, **CCC** et **EEE** ont déjà lancé leur dé, avec pour
|
||||
scores 66, 34 et 97.
|
||||
|
||||
Une fois le bouton cliqué, il disparaît.
|
||||
|
||||
Les organisateurices peuvent également cliquer sur le score de dé d'une équipe
|
||||
pour lancer le dé à sa place. Cela peut être utile pour débugger ou pour aider
|
||||
une équipe qui a un souci technique. Il est possible de cliquer sur le dé, ce
|
||||
qui a pour effet de lancer le dé pour l'équipe qui n'a pas encore tiré son dé.
|
||||
|
||||
Les poules sont constituées dès que toutes les équipes ont tiré leur dé, selon
|
||||
le principe énoncé dans la partie `Principe`_. S'il y a des égalités, alors les
|
||||
équipes concernées relancent leur dé jusqu'à ne plus avoir d'égalité.
|
||||
|
||||
|
||||
Ordre de tirage des problèmes
|
||||
.............................
|
||||
|
||||
Une fois les poules constituées, il faut déterminer dans quel ordre les équipes
|
||||
tireront leur problème, par le biais d'un nouveau jet de dé. Les équipes sont
|
||||
invitées à lancer un dé à 100 faces. Pour cela, elles peuvent cliquer sur le
|
||||
gros bouton avec l'émoji « dé » 🎲 sur la droite de l'écran. Le résultat est
|
||||
affiché dans la section « Derniers jets de dés ».
|
||||
|
||||
.. figure:: _static/img/draw_waiting_choose_problem_order_full.png
|
||||
:alt: Étape de tirage de l'ordre de tirage des problèmes
|
||||
|
||||
Le tirage au sort est en attente du tirage l'ordre de tirage pour la poule A1.
|
||||
L'équipe **BBB** a déjà tiré son dé, avec pour score 56. Les équipes **CCC**
|
||||
et **DDD** sont alors attendues.
|
||||
|
||||
Une fois le bouton cliqué, il disparaît.
|
||||
|
||||
À nouveau, les organisateurices peuvent cliquer sur le score de dé d'une équipe
|
||||
pour lancer le dé à sa place. Cela peut être utile pour débugger ou pour aider
|
||||
une équipe qui a un souci technique. Il est possible de cliquer sur le dé, ce
|
||||
qui a pour effet de lancer le dé pour l'équipe qui n'a pas encore tiré son dé.
|
||||
|
||||
Si deux équipes réalisent le même score, alors les équipes concernées relancent
|
||||
leur dé jusqu'à ne plus avoir d'égalité.
|
||||
|
||||
|
||||
Tirage des problèmes
|
||||
....................
|
||||
|
||||
Les équipes membre de la poule active sont invitées à tirer leur problème, dans
|
||||
l'ordre déterminé par les dés précédents.
|
||||
|
||||
L'équipe active est invitée à cliquer sur l'urne 🗳️ sur la droite de l'écran pour
|
||||
tirer un problème au sort.
|
||||
|
||||
.. figure:: _static/img/draw_waiting_problem_draw_full.png
|
||||
:alt: Étape de tirage des problèmes
|
||||
|
||||
L'équipe **DDD** est la première à tirer son problème, puisqu'elle a réalisé
|
||||
le plus gros score. Elle est mise en surbrillance.
|
||||
|
||||
Les organisateurices peuvent également cliquer sur l'urne pour lancer le dé à la
|
||||
place de l'équipe. Cela peut être utile pour débugger ou pour aider une équipe qui
|
||||
a un souci technique.
|
||||
|
||||
Le problème tiré ne peut pas être le même que celui d'une autre équipe de la poule,
|
||||
ni le même que celui défendu par l'équipe lors du premier tour si on est au second
|
||||
tour. Il peut en revanche être un problème déjà refusé auparavant.
|
||||
|
||||
|
||||
Choix du problème
|
||||
.................
|
||||
|
||||
Une fois le problème tiré, l'équipe peut alors choisir de l'accepter ou de le
|
||||
refuser. Elle est invitée à cliquer sur le bouton « Accepter » ou « Refuser »
|
||||
pour faire son choix.
|
||||
|
||||
.. figure:: _static/img/draw_choose_problem_full.png
|
||||
:alt: Étape de choix du problème
|
||||
|
||||
L'équipe **DDD** a tiré le problème 3. Elle peut choisir de l'accepter ou de le refuser.
|
||||
|
||||
Si elle accepte le problème, alors elle a terminé, et la main passe à l'équipe
|
||||
suivante. Ce sera ce problème qu'elle défendra.
|
||||
|
||||
Si elle refuse le problème, alors :
|
||||
|
||||
* Soit le problème n'avait pas encore été refusé. Dans ce cas, la main passe à
|
||||
l'équipe suivante, selon l'ordre de tirage des problèmes. Si le nombre de refus
|
||||
dépasse ``P - 3`` où ``P`` est le nombre de problèmes, alors l'équipe se verra
|
||||
dotée d'une pénalité de 25 % sur son coefficient de l'oral de défense. Ces
|
||||
pénalités sont cumulables.
|
||||
* Soit le problème avait déjà été refusé. Dans ce cas, l'équipe peut revenir sur
|
||||
sa décision et accepter le problème, ou bien le rejeter gratuitement et tirer
|
||||
immédiatement un nouveau problème. Cela ne compte pas comme un refus
|
||||
supplémentaire et ne peut ajouter de pénalité.
|
||||
|
||||
Les organisateurices peuvent à nouveau cliquer sur les boutons à la place de
|
||||
l'équipe. Cela peut être utile pour débugger ou pour aider une équipe qui a un
|
||||
souci technique.
|
||||
|
||||
.. figure:: _static/img/draw_example.png
|
||||
:alt: Exemple de tirage de la poule A1
|
||||
|
||||
Dans cet exemple, l'équipe **DDD** a tiré le problème 3, et l'a accepté.
|
||||
L'équipe **BBB** a d'abord refusé le problème 6, et a accepté plus tard
|
||||
le problème 5. L'équipe **CCC**, à qui c'est le tour, a refusé les problèmes
|
||||
8, 4, 2 et 6. Puisqu'il y a 8 problèmes, elle a une pénalité de 25 % sur son
|
||||
coefficient de l'oral de défense. Elle vient de tirer le problème 2, qu'elle
|
||||
avait déjà refusé. Elle est donc libre de le refuser à nouveau sans pénalité
|
||||
supplémentaire, ou bien de l'accepter, comme indiqué dans l'encadré à droite.
|
||||
|
||||
Lorsque toutes les équipes de la poule ont accepté leur problème, on passe à la
|
||||
poule suivante, en revenant à l'étape de tirage au sort de l'ordre des problèmes.
|
||||
Lorsqu'un tour est terminé, on passe au tour suivant, à la première poule.
|
||||
|
||||
Exception : pour la finale, on ne tire au sort que le premier tour.
|
||||
|
||||
.. figure:: _static/img/draw_end_round_1.png
|
||||
:alt: Fin du tirage au sort du premier tour
|
||||
|
||||
Toutes les équipes ont tiré leur problème pour le premier tour. On passe
|
||||
au second tour, à la poule A2.
|
||||
|
||||
|
||||
Fin du tirage au sort
|
||||
.....................
|
||||
|
||||
Le tirage se termine lorsque toutes les équipes se sont vues attribuer un problème
|
||||
pour chacun des tours.
|
||||
|
||||
Les organisateurices peuvent alors cliquer sur le bouton « Exporter » pour valider
|
||||
le tirage et transmettre les données au reste de la plateforme, débloquant notamment
|
||||
l'accès aux différentes solutions pour les équipes. Attention : cette opération n'est
|
||||
pas réversible facilement.
|
||||
|
||||
Spécificité de la finale
|
||||
........................
|
||||
|
||||
Pour la finale, le tirage au sort est légèrement différent. Seul le premier tour
|
||||
est tiré au sort initialement. Pour le second tour, les poules et ordres de passage
|
||||
sont déterminés selon le classement du premier tour.
|
||||
|
||||
Avant de reprendre le tirage au sort du second tour, il est essentiel que les notes
|
||||
du premier tour soient rentrées correctement. En effet, ce sont ces scores qui
|
||||
détermineront les poules et l'ordre de passage pour le second tour.
|
||||
|
||||
Pour lancer le second tour, un⋅e organisateurice doit cliquer sur le bouton
|
||||
« Continuer ». Le tirage reprend ensuite normalement, à partir de la poule **A2**.
|
||||
|
||||
.. danger::
|
||||
À terme, il sera possible de réaliser ce second tirage au sort IRL, et de rentrer
|
||||
facilement les données au fur et à mesure du tirage.
|
||||
|
||||
|
||||
Annulation d'une étape
|
||||
......................
|
||||
|
||||
Il est possible d'annuler la dernière étape du tirage au sort. Cela peut être utile
|
||||
en cas de souci technique ou de mauvaise manipulation. Cette option est uniquement
|
||||
réservée aux organisateurices.
|
||||
|
||||
Pour cela, il suffit de cliquer sur le bouton « Annuler la dernière étape » dans
|
||||
la partie « Récapitulatif ». Cela annule immédiatement la dernière action. Il est
|
||||
possible de continuer à remonter le temps ainsi.
|
||||
|
||||
En cas de plus gros problème, il est possible de cliquer sur le bouton « Annuler »
|
||||
en bas de page. Cela réinitialise le tirage au sort, et permet de recommencer
|
||||
depuis le début. Cette suppression est irréversible, soyez sûr⋅es de ce que vous
|
||||
faites avant de cliquer dessus.
|
@ -12,6 +12,7 @@ administrateur⋅rice.
|
||||
|
||||
user
|
||||
orga
|
||||
draw
|
||||
|
||||
|
||||
.. toctree::
|
||||
@ -19,3 +20,5 @@ administrateur⋅rice.
|
||||
:caption: Développer
|
||||
|
||||
dev/index
|
||||
dev/install
|
||||
dev/transition
|
||||
|
@ -1,2 +1,3 @@
|
||||
sphinx>=3.3
|
||||
sphinx-rtd-theme>=0.5
|
||||
sphinx-rtd-theme>=2.0
|
||||
sphinx_rtd_dark_mode>=1.3.0
|
||||
|
136
docs/user.rst
@ -32,16 +32,17 @@ Les informations suivantes sont requises pour tout le monde :
|
||||
* Genre
|
||||
* Adresse postale
|
||||
* Numéro de téléphone de contact
|
||||
* Problèmes de santé à déclarer
|
||||
* Problèmes de santé à déclarer (allergies,…)
|
||||
* Contraintes de logement à déclarer (problèmes médicaux, contraintes horaires, questions de genre,…)
|
||||
* Donne son consentement pour se faire recontacter par Animath
|
||||
|
||||
Informations demandées exclusivement aux élèves :
|
||||
|
||||
* Classe (seconde ou avant/première/terminale)
|
||||
* Établissement scolaire
|
||||
* Nom du responsable légal
|
||||
* Numéro de téléphone du responsable légal
|
||||
* Adresse e-mail du responsable légal
|
||||
* Nom d'un⋅e responsable légal⋅e
|
||||
* Numéro de téléphone d'un⋅e responsable légal⋅e
|
||||
* Adresse e-mail d'un⋅e responsable légal⋅e
|
||||
|
||||
Informations exclusivement demandées aux encadrant⋅es :
|
||||
|
||||
@ -136,8 +137,8 @@ Pour valider votre inscription, vous devez :
|
||||
* Transmettre une lettre de motivation.
|
||||
|
||||
La lettre de motivation doit être envoyée une seule fois pour toute l'équipe, peut être envoyée
|
||||
depuis l'interface « Mon équipe », au format PDF, dont le contenu est défini dans l'article 5.3
|
||||
du guide de læ participant⋅e : https://tfjm.org/reglement/.
|
||||
depuis l'interface « Mon équipe », au format PDF, dont le contenu est défini dans le
|
||||
règlement : https://tfjm.org/reglement/.
|
||||
|
||||
Concernant les documents personnels, ils peuvent être envoyés depuis le menu « Mon compte », qui
|
||||
peut être trouvé en haut à droite dans la barre de navigation. Chaque fichier doit être envoyé
|
||||
@ -177,17 +178,126 @@ Vous recevrez par mail une réponse des organisateur⋅rices locaux⋅ales. En c
|
||||
Payer son inscription
|
||||
---------------------
|
||||
|
||||
Une fois votre inscription validée, il vous faudra payer votre inscription. Les frais s'élèvent à
|
||||
23 € par élève, sauf pour les élèves boursièr⋅es qui en sont exonéré⋅es. Les encadrant⋅es n'ont pas
|
||||
à payer.
|
||||
Une fois votre inscription validée, il vous faudra payer votre participation. Les frais s'élèvent à
|
||||
21 € par élève, sauf pour les élèves boursièr⋅es qui en sont exonéré⋅es. Les encadrant⋅es n'ont pas
|
||||
à payer. Pour la finale, les frais sont de 35 € par élève.
|
||||
|
||||
.. note::
|
||||
Ces frais couvrent une partie des frais de restauration et d'hébergement. L'organisation reste
|
||||
bénévole.
|
||||
|
||||
.. TODO
|
||||
Il est possible de payer par carte bancaire ou virement bancaire. Pour d'autres types de paiement,
|
||||
merci de nous contacter.
|
||||
|
||||
Pour payer, si votre équipe est bien validée, vous pouvez vous rendre sur la page de votre compte
|
||||
ou celle de votre équipe, et cliquer sur le bouton « Modifier le paiement », qui devrais désormais
|
||||
apparaître. Vous pouvez également utiliser le lien présent dans le volet « Informations ».
|
||||
|
||||
.. image:: /_static/img/payment_index.png
|
||||
:alt: Page de paiement
|
||||
|
||||
.. note::
|
||||
Cette section sera mise à jour plus tard.
|
||||
|
||||
Vous recevrez un mail de rappel chaque semaine. Le paiement doit être effectué avant le début du
|
||||
tournoi, sans quoi votre participation pourrait être refusée. En cas de difficultés de paiement,
|
||||
merci de nous contacter.
|
||||
|
||||
Carte bancaire
|
||||
""""""""""""""
|
||||
|
||||
La façon la plus simple de payer son inscription est de payer par carte bancaire. Animath utilise
|
||||
`Hello Asso <https://helloasso.com/>`_ en guise de solution de paiements en ligne.
|
||||
|
||||
Il vous suffit de cliquer sur le bouton « Aller à la page Hello Asso ». Vous serez redirigé⋅e ensuite
|
||||
vers la page de paiement.
|
||||
|
||||
.. warning::
|
||||
|
||||
Pour procéder au paiement, si vous êtes mineur⋅e, vous devrez demander à un⋅e adulte de payer à
|
||||
votre place. Il est important dans la suite de bien mettre les coordonnées du payeur ou de la payeuse,
|
||||
majeur⋅e, et non celles de l'élève.
|
||||
|
||||
.. image:: /_static/img/payment_hello_asso_step_1.png
|
||||
:alt: Formulaire de paiement Hello Asso
|
||||
|
||||
La personne qui paie peut rentrer ses informations demandées (nom, prénom, e-mail, date de naissance).
|
||||
|
||||
Notez que, par défaut, Hello Asso ajoute automatiquement une participation à ses frais de fonctionnement,
|
||||
d'environ 15 à 20 % du prix payé. Ces frais ne sont pas obligatoires, ne sont pas versés à Animath et
|
||||
représentent la seule source de revenus à Hello Asso. En effet : Animath ne verse aucune commission lors
|
||||
de ses transactions, et seules les contributions volontaires financent leur service.
|
||||
|
||||
Sur la page suivante, vous pouvez indiquer vos coordonnées bancaires :
|
||||
|
||||
.. image:: /_static/img/payment_hello_asso_step_2.png
|
||||
:alt: Formulaire de paiement Hello Asso - coordonnées bancaires
|
||||
|
||||
Vous devez ensuite éventuellement confirmer votre paiement auprès de votre banque.
|
||||
|
||||
Une fois ceci fait, vous êtes automatiquement redirigé⋅es vers la plateforme du TFJM² :
|
||||
|
||||
.. image:: /_static/img/payment_hello_asso_confirmation.png
|
||||
:alt: Confirmation de paiement Hello Asso
|
||||
|
||||
Il se peut que la validation ne soit pas instantanée. Elle peut prendre au plus quelques minutes.
|
||||
Si le délai est plus long, merci de nous contacter.
|
||||
|
||||
Vous recevrez ensuite un mail de confirmation de la plateforme, ainsi qu'un justificatif de paiement
|
||||
de la part de Hello Asso.
|
||||
|
||||
|
||||
Carte bancaire - paiement par un tiers
|
||||
""""""""""""""""""""""""""""""""""""""
|
||||
|
||||
Il est possible, si nécessaire, de faire payer l'inscription par carte bancaire par un tiers. Pour cela,
|
||||
vous pouvez lui transmettre le lien de paiement qui apparaît au centre de l'écran. Cela est notamment
|
||||
utile pour faire payer l'inscription par un établissement scolaire, ou par des parents.
|
||||
|
||||
L'interface de paiement sera ensuite identique.
|
||||
|
||||
|
||||
Virement bancaire
|
||||
"""""""""""""""""
|
||||
|
||||
Il est possible de payer par virement bancaire. Pour cela, vous pouvez ouvrir l'onglet virement bancaire :
|
||||
|
||||
.. image:: /_static/img/payment_bank_transfer.png
|
||||
:alt: Formulaire de paiement par virement bancaire
|
||||
|
||||
Pour effectuer le virement, merci de mettre en référence du virement « TFJMpu » suivi du nom et du prénom de l'élève.
|
||||
|
||||
Les coordonnées bancaires sont :
|
||||
|
||||
* IBAN : FR76 1027 8065 0000 0206 4290 127
|
||||
* BIC : CMCIFR2A
|
||||
|
||||
Une fois le paiment effectué, vous pouvez envoyer une preuve de virement via le formulaire ci-dessus. Le paiement
|
||||
sera ensuite validé manuellement par les organisateur⋅rices après réception.
|
||||
|
||||
Si vous avez besoin d'une facture, merci de nous contacter.
|
||||
|
||||
|
||||
Exonération - boursièr⋅es
|
||||
"""""""""""""""""""""""""
|
||||
|
||||
Si vous bénéficiez d'une bourse, vous pouvez être exonéré⋅es des frais de participation. Pour cela, il vous suffit
|
||||
de nous envoyer une copie de votre notification de bourse, ou tout autre document justifiant de votre situation.
|
||||
Vous pouvez envoyer ce document en vous rendant sur l'onglet dédié :
|
||||
|
||||
.. image:: /_static/img/payment_scholarship.png
|
||||
:alt: Formulaire de soumission de notification de bourse
|
||||
|
||||
|
||||
Paiements groupés
|
||||
"""""""""""""""""
|
||||
|
||||
Il est possible de payer en une seule fois pour toute l'équipe. Cela est notamment utile si l'inscription est
|
||||
payée par l'établissement. Pour cela, il suffit de cliquer sur le bouton « Regrouper les paiements de mon équipe ».
|
||||
Cela a pour effet d'unifier les paiements de l'équipe, et de ne pas demander à chaque membre de payer individuellement.
|
||||
Attention : cette fonction n'est possible que si aucun membre de l'équipe n'a encore payé son inscription.
|
||||
|
||||
.. image:: /_static/img/payment_grouped.png
|
||||
:alt: Page de paiement groupé
|
||||
|
||||
|
||||
Envoyer ses solutions
|
||||
@ -201,9 +311,7 @@ Envoyer ses solutions
|
||||
Participer au tirage au sort
|
||||
----------------------------
|
||||
|
||||
.. TODO
|
||||
.. note::
|
||||
Cette section sera mise à jour plus tard.
|
||||
La documentation des tirages au sort est disponible sur `la page dédiée <draw.html>`_.
|
||||
|
||||
|
||||
Envoyer ses notes de synthèse
|
||||
|
@ -7,11 +7,34 @@ from django.utils.translation import gettext_lazy as _
|
||||
from .models import Draw, Pool, Round, TeamDraw
|
||||
|
||||
|
||||
class RoundInline(admin.TabularInline):
|
||||
model = Round
|
||||
extra = 0
|
||||
autocomplete_fields = ('draw', 'current_pool',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class PoolInline(admin.TabularInline):
|
||||
model = Pool
|
||||
extra = 0
|
||||
autocomplete_fields = ('round', 'current_team', 'associated_pool',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class TeamDrawInline(admin.TabularInline):
|
||||
model = TeamDraw
|
||||
extra = 0
|
||||
autocomplete_fields = ('participation', 'round', 'pool',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
@admin.register(Draw)
|
||||
class DrawAdmin(admin.ModelAdmin):
|
||||
list_display = ('tournament', 'teams', 'current_round', 'get_state',)
|
||||
list_filter = ('tournament', 'current_round',)
|
||||
list_filter = ('tournament', 'current_round__number',)
|
||||
search_fields = ('tournament__name', 'tournament__participation__team__trigram',)
|
||||
autocomplete_fields = ('tournament',)
|
||||
inlines = (RoundInline,)
|
||||
|
||||
@admin.display(description=_("teams"))
|
||||
def teams(self, record: Draw):
|
||||
@ -20,10 +43,16 @@ class DrawAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Round)
|
||||
class RoundAdmin(admin.ModelAdmin):
|
||||
list_display = ('draw', 'number', 'teams',)
|
||||
list_display = ('draw', 'tournament', 'number', 'teams',)
|
||||
list_filter = ('draw__tournament', 'number',)
|
||||
search_fields = ('draw__tournament__name', 'pool__teamdraw__participation__team__trigram')
|
||||
ordering = ('draw__tournament__name', 'number')
|
||||
autocomplete_fields = ('draw', 'current_pool',)
|
||||
inlines = (PoolInline,)
|
||||
|
||||
@admin.display(description=_("tournament"), ordering='draw__tournament__name')
|
||||
def tournament(self, record):
|
||||
return record.draw.tournament
|
||||
|
||||
@admin.display(description=_("teams"))
|
||||
def teams(self, record: Round):
|
||||
@ -36,6 +65,8 @@ class PoolAdmin(admin.ModelAdmin):
|
||||
list_filter = ('round__draw__tournament', 'round__number', 'letter')
|
||||
ordering = ('round__draw__tournament__name', 'round', 'letter')
|
||||
search_fields = ('round__draw__tournament__name', 'teamdraw__participation__team__trigram',)
|
||||
autocomplete_fields = ('round', 'current_team', 'associated_pool',)
|
||||
inlines = (TeamDrawInline,)
|
||||
|
||||
@admin.display(ordering='round__draw__tournament__name', description=_("tournament"))
|
||||
def tournament(self, record):
|
||||
@ -52,6 +83,7 @@ class TeamDrawAdmin(admin.ModelAdmin):
|
||||
'passage_index', 'choose_index', 'passage_dice', 'choice_dice',)
|
||||
list_filter = ('round__draw__tournament', 'round__number', 'pool__letter',)
|
||||
search_fields = ('round__draw__tournament__name', 'participation__team__trigram',)
|
||||
autocomplete_fields = ('participation', 'round', 'pool',)
|
||||
|
||||
@admin.display(ordering='round__draw__tournament__name', description=_("tournament"))
|
||||
def tournament(self, record):
|
||||
|
@ -3,10 +3,13 @@
|
||||
|
||||
from collections import OrderedDict
|
||||
import json
|
||||
import os
|
||||
from random import randint, shuffle
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils import translation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@ -42,10 +45,17 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
We accept only if this is a user of a team of the associated tournament, or a volunteer
|
||||
of the tournament.
|
||||
"""
|
||||
if '_fake_user_id' in self.scope['session']:
|
||||
self.scope['user'] = await User.objects.aget(pk=self.scope['session']['_fake_user_id'])
|
||||
|
||||
# Fetch the registration of the current user
|
||||
user = self.scope['user']
|
||||
reg = await Registration.objects.aget(user=user)
|
||||
if user.is_anonymous:
|
||||
# User is not authenticated
|
||||
await self.close()
|
||||
return
|
||||
|
||||
reg = await Registration.objects.aget(user_id=user.id)
|
||||
self.registration = reg
|
||||
|
||||
# Accept the connection
|
||||
@ -69,6 +79,10 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
Called when the websocket got disconnected, for any reason.
|
||||
:param close_code: The error code.
|
||||
"""
|
||||
if self.scope['user'].is_anonymous:
|
||||
# User is not authenticated
|
||||
return
|
||||
|
||||
# Unregister from channel layers
|
||||
if not self.registration.is_volunteer:
|
||||
await self.channel_layer.group_discard(f"team-{self.registration.team.trigram}", self.channel_name)
|
||||
@ -108,6 +122,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
self.tournament = await Tournament.objects.filter(pk=self.tournament_id)\
|
||||
.prefetch_related('draw__current_round__current_pool__current_team__participation__team').aget()
|
||||
|
||||
translation.activate(settings.PREFERRED_LANGUAGE_CODE)
|
||||
|
||||
match content['type']:
|
||||
case 'set_language':
|
||||
# Update the translation language
|
||||
@ -152,7 +168,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
try:
|
||||
# Parse format from string
|
||||
fmt: list[int] = sorted(map(int, fmt.split('+')), reverse=True)
|
||||
fmt: list[int] = sorted(map(int, fmt.split('+')))
|
||||
except ValueError:
|
||||
return await self.alert(_("Invalid format"), 'danger')
|
||||
|
||||
@ -169,7 +185,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
# Create the draw
|
||||
draw = await Draw.objects.acreate(tournament=self.tournament)
|
||||
r1 = None
|
||||
for i in [1, 2]:
|
||||
for i in range(1, settings.NB_ROUNDS + 1):
|
||||
# Create the round
|
||||
r = await Round.objects.acreate(draw=draw, number=i)
|
||||
if i == 1:
|
||||
@ -208,7 +224,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
# Update user interface
|
||||
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.start', 'fmt': fmt, 'draw': draw})
|
||||
{'tid': self.tournament_id, 'type': 'draw.start', 'fmt': fmt})
|
||||
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.set_info',
|
||||
'info': await self.tournament.draw.ainformation()})
|
||||
@ -219,8 +235,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.notify',
|
||||
'title': 'Tirage au sort du TFJM²',
|
||||
'body': "Le tirage au sort du tournoi de "
|
||||
f"{self.tournament.name} a commencé !"})
|
||||
'body': str(_("The draw of tournament {tournament} started!"))
|
||||
.format(tournament=self.tournament.name)})
|
||||
|
||||
async def draw_start(self, content) -> None:
|
||||
"""
|
||||
@ -389,21 +405,21 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
await self.channel_layer.group_send(
|
||||
f"team-{dup.participation.team.trigram}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.notify', 'title': 'Tirage au sort du TFJM²',
|
||||
'body': 'Votre score de dé est identique à celui de une ou plusieurs équipes. '
|
||||
'Veuillez le relancer.'}
|
||||
'body': str(_("Your dice score is identical to the one of one or multiple teams. "
|
||||
"Please relaunch it."))}
|
||||
)
|
||||
# Alert the tournament
|
||||
await self.channel_layer.group_send(
|
||||
f"tournament-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.alert',
|
||||
'message': _('Dices from teams {teams} are identical. Please relaunch your dices.').format(
|
||||
teams=', '.join(td.participation.team.trigram for td in dups)),
|
||||
'message': str(_('Dices from teams {teams} are identical. Please relaunch your dices.').format(
|
||||
teams=', '.join(td.participation.team.trigram for td in dups))),
|
||||
'alert_type': 'warning'})
|
||||
error = True
|
||||
|
||||
return error
|
||||
|
||||
async def process_dice_select_poules(self):
|
||||
async def process_dice_select_poules(self): # noqa: C901
|
||||
"""
|
||||
Called when all teams launched their dice.
|
||||
Place teams into pools and order their passage.
|
||||
@ -416,10 +432,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
# For each pool of size N, put the N next teams into this pool
|
||||
async for p in Pool.objects.filter(round_id=self.tournament.draw.current_round_id).order_by('letter').all():
|
||||
# Fetch the N teams, then order them in a new order for the passages inside the pool
|
||||
# We multiply the dice scores by 27 mod 100 (which order is 20 mod 100) for this new order
|
||||
# This simulates a deterministic shuffle
|
||||
pool_tds = sorted(tds_copy[:p.size], key=lambda td: (td.passage_dice * 27) % 100)
|
||||
# Fetch the N teams
|
||||
pool_tds = tds_copy[:p.size].copy()
|
||||
# Remove the head
|
||||
tds_copy = tds_copy[p.size:]
|
||||
for i, td in enumerate(pool_tds):
|
||||
@ -428,45 +442,74 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
td.passage_index = i
|
||||
await td.asave()
|
||||
|
||||
# The passages of the second round are determined from the scores of the dices
|
||||
# The team that has the lowest dice score goes to the first pool, then the team
|
||||
# that has the second-lowest score goes to the second pool, etc.
|
||||
# This also determines the passage order, in the natural order this time.
|
||||
# If there is a 5-teams pool, we force the last team to be in the first pool,
|
||||
# which is this specific pool since they are ordered by decreasing size.
|
||||
# This is not true for the final tournament, which considers the scores of the
|
||||
# first round.
|
||||
if not self.tournament.final:
|
||||
tds_copy = tds.copy()
|
||||
# The passages of the second round are determined from the order of the passages of the first round.
|
||||
# We order teams by increasing passage index, and then by decreasing pool number.
|
||||
# We keep teams that were at the last position in a 5-teams pool apart, as "jokers".
|
||||
# Then, we fill pools one team by one team.
|
||||
# As we fill one pool for the second round, we check if we can place a joker in it.
|
||||
# We can add a joker team if there is not already a team in the pool that was in the same pool
|
||||
# in the first round, and such that the number of such jokers is exactly the free space of the current pool.
|
||||
# Exception: if there is one only pool with 5 teams, we exchange the first and the last teams of the pool.
|
||||
if not self.tournament.final and settings.TFJM_APP == "TFJM":
|
||||
tds_copy = sorted(tds, key=lambda td: (td.passage_index, -td.pool.letter,))
|
||||
jokers = [td for td in tds if td.passage_index == 4]
|
||||
round2 = await self.tournament.draw.round_set.filter(number=2).aget()
|
||||
round2_pools = [p async for p in Pool.objects.filter(round__draw__tournament=self.tournament, round=round2)
|
||||
.order_by('letter').all()]
|
||||
current_pool_id, current_passage_index = 0, 0
|
||||
for i, td in enumerate(tds_copy):
|
||||
if i == len(tds) - 1 and round2_pools[0].size == 5:
|
||||
current_pool_id = 0
|
||||
current_passage_index = 4
|
||||
|
||||
td2 = await TeamDraw.objects.filter(participation=td.participation, round=round2).aget()
|
||||
td2.pool = round2_pools[current_pool_id]
|
||||
td2.passage_index = current_passage_index
|
||||
current_pool_id += 1
|
||||
if current_pool_id == len(round2_pools):
|
||||
current_pool_id = 0
|
||||
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()
|
||||
|
||||
valid_jokers = []
|
||||
# A joker is valid if it was not in the same pool in the first round
|
||||
# as a team that is already in the current pool in the second round
|
||||
for joker in jokers:
|
||||
async for td2 in round2_pools[current_pool_id].teamdraw_set.all():
|
||||
if await joker.pool.teamdraw_set.filter(participation_id=td2.participation_id).aexists():
|
||||
break
|
||||
else:
|
||||
valid_jokers.append(joker)
|
||||
|
||||
# We can add a joker if there is exactly enough free space in the current pool
|
||||
if valid_jokers and current_passage_index + len(valid_jokers) == td2.pool.size:
|
||||
for joker in valid_jokers:
|
||||
tds_copy.remove(joker)
|
||||
jokers.remove(joker)
|
||||
td2_joker = await TeamDraw.objects.filter(participation_id=joker.participation_id,
|
||||
round=round2).aget()
|
||||
td2_joker.pool = round2_pools[current_pool_id]
|
||||
td2_joker.passage_index = current_passage_index
|
||||
current_passage_index += 1
|
||||
await td2_joker.asave()
|
||||
jokers = []
|
||||
|
||||
current_passage_index = 0
|
||||
current_pool_id += 1
|
||||
|
||||
if current_passage_index == round2_pools[current_pool_id].size:
|
||||
current_passage_index = 0
|
||||
current_pool_id += 1
|
||||
|
||||
# The current pool is the first pool of the current (first) round
|
||||
pool = await Pool.objects.filter(round=self.tournament.draw.current_round, letter=1).aget()
|
||||
self.tournament.draw.current_round.current_pool = pool
|
||||
await self.tournament.draw.current_round.asave()
|
||||
|
||||
# Display dice result in the header of the information alert
|
||||
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."
|
||||
trigrams = ", ".join(f"<strong>{td.participation.team.trigram}</strong> ({td.passage_dice})" for td in tds)
|
||||
msg = _("The dice results are the following: {trigrams}. "
|
||||
"The passage order and the compositions of the different pools are displayed on the side. "
|
||||
"The passage orders for the first round are determined from the dice scores, in increasing order. "
|
||||
"For the second round, the passage orders are determined from the passage orders of the first round.") \
|
||||
.format(trigrams=trigrams)
|
||||
self.tournament.draw.last_message = msg
|
||||
await self.tournament.draw.asave()
|
||||
|
||||
@ -490,17 +533,17 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
{'tid': self.tournament_id, 'type': 'draw.dice_visibility',
|
||||
'visible': True})
|
||||
|
||||
# First send the second pool to have the good team order
|
||||
r2 = await self.tournament.draw.round_set.filter(number=2).aget()
|
||||
# First send the pools of next rounds to have the good team order
|
||||
async for next_round in self.tournament.draw.round_set.filter(number__gte=2).all():
|
||||
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.send_poules',
|
||||
'round': r2.number,
|
||||
'round': next_round.number,
|
||||
'poules': [
|
||||
{
|
||||
'letter': pool.get_letter_display(),
|
||||
'teams': await pool.atrigrams(),
|
||||
}
|
||||
async for pool in r2.pool_set.order_by('letter').all()
|
||||
async for pool in next_round.pool_set.order_by('letter').all()
|
||||
]})
|
||||
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.send_poules',
|
||||
@ -569,8 +612,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
# Notify the team that it can draw a problem
|
||||
await self.channel_layer.group_send(f"team-{tds[0].participation.team.trigram}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.notify',
|
||||
'title': "À votre tour !",
|
||||
'body': "C'est à vous de tirer un nouveau problème !"})
|
||||
'title': str(_("Your turn!")),
|
||||
'body': str(_("It's your turn to draw a problem!"))})
|
||||
|
||||
async def select_problem(self, **kwargs):
|
||||
"""
|
||||
@ -590,7 +633,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
.prefetch_related('team').aget()
|
||||
# Ensure that the user can draws a problem at this time
|
||||
if participation.id != td.participation_id:
|
||||
return await self.alert("This is not your turn.", 'danger')
|
||||
return await self.alert(_("This is not your turn."), 'danger')
|
||||
|
||||
while True:
|
||||
# Choose a random problem
|
||||
@ -599,6 +642,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
|
||||
@ -660,19 +704,20 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
.prefetch_related('team').aget()
|
||||
# Ensure that the user can accept a problem at this time
|
||||
if participation.id != td.participation_id:
|
||||
return await self.alert("This is not your turn.", 'danger')
|
||||
return await self.alert(_("This is not your turn."), 'danger')
|
||||
|
||||
td.accepted = td.purposed
|
||||
td.purposed = None
|
||||
await td.asave()
|
||||
|
||||
trigram = td.participation.team.trigram
|
||||
msg = f"L'équipe <strong>{trigram}</strong> a accepté le problème <strong>{td.accepted} : " \
|
||||
f"{settings.PROBLEMS[td.accepted - 1]}</strong>. "
|
||||
msg = _("The team <strong>{trigram}</strong> accepted the problem <string>{problem}</strong>: "
|
||||
"{problem_name}. ").format(trigram=trigram, problem=td.accepted,
|
||||
problem_name=settings.PROBLEMS[td.accepted - 1])
|
||||
if pool.size == 5 and await pool.teamdraw_set.filter(accepted=td.accepted).acount() < 2:
|
||||
msg += "Une équipe peut encore l'accepter."
|
||||
msg += _("One team more can accept this problem.")
|
||||
else:
|
||||
msg += "Plus personne ne peut l'accepter."
|
||||
msg += _("No team can accept this problem anymore.")
|
||||
self.tournament.draw.last_message = msg
|
||||
await self.tournament.draw.asave()
|
||||
|
||||
@ -707,8 +752,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
# Notify the team that it can draw a problem
|
||||
await self.channel_layer.group_send(f"team-{new_trigram}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.notify',
|
||||
'title': "À votre tour !",
|
||||
'body': "C'est à vous de tirer un nouveau problème !"})
|
||||
'title': str(_("Your turn!")),
|
||||
'body': str(_("It's your turn to draw a problem!"))})
|
||||
else:
|
||||
# Pool is ended
|
||||
await self.end_pool(pool)
|
||||
@ -766,8 +811,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
'problems': [td.accepted async for td in pool.team_draws],
|
||||
})
|
||||
|
||||
msg += f"<br><br>Le tirage de la poule {pool.get_letter_display()}{r.number} est terminé. " \
|
||||
f"Le tableau récapitulatif est en bas."
|
||||
msg += "<br><br>" + _("The draw of the pool {pool} is ended. The summary is below.") \
|
||||
.format(pool=f"{pool.get_letter_display()}{r.number}")
|
||||
self.tournament.draw.last_message = msg
|
||||
await self.tournament.draw.asave()
|
||||
|
||||
@ -784,8 +829,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
# Notify the team that it can draw a dice
|
||||
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.notify',
|
||||
'title': "À votre tour !",
|
||||
'body': "C'est à vous de lancer le dé !"})
|
||||
'title': str(_("Your turn!")),
|
||||
'body': str(_("It's your turn to launch the dice!"))})
|
||||
|
||||
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.dice_visibility',
|
||||
@ -801,11 +846,11 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
"""
|
||||
msg = self.tournament.draw.last_message
|
||||
|
||||
if r.number == 1 and not self.tournament.final:
|
||||
if r.number < settings.NB_ROUNDS and not self.tournament.final and settings.TFJM_APP == "TFJM":
|
||||
# Next round
|
||||
r2 = await self.tournament.draw.round_set.filter(number=2).aget()
|
||||
self.tournament.draw.current_round = r2
|
||||
msg += "<br><br>Le tirage au sort du tour 1 est terminé."
|
||||
next_round = await self.tournament.draw.round_set.filter(number=r.number + 1).aget()
|
||||
self.tournament.draw.current_round = next_round
|
||||
msg += "<br><br>" + _("The draw of the round {round} is ended.").format(round=r.number)
|
||||
self.tournament.draw.last_message = msg
|
||||
await self.tournament.draw.asave()
|
||||
|
||||
@ -818,26 +863,26 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
# Notify the team that it can draw a dice
|
||||
await self.channel_layer.group_send(f"team-{participation.team.trigram}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.notify',
|
||||
'title': "À votre tour !",
|
||||
'body': "C'est à vous de lancer le dé !"})
|
||||
'title': str(_("Your turn!")),
|
||||
'body': str(_("It's your turn to launch the dice!"))})
|
||||
|
||||
# Reorder dices
|
||||
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.send_poules',
|
||||
'round': r2.number,
|
||||
'round': next_round.number,
|
||||
'poules': [
|
||||
{
|
||||
'letter': pool.get_letter_display(),
|
||||
'teams': await pool.atrigrams(),
|
||||
}
|
||||
async for pool in r2.pool_set.order_by('letter').all()
|
||||
async for pool in next_round.pool_set.order_by('letter').all()
|
||||
]})
|
||||
|
||||
# The passage order for the second round is already determined by the first round
|
||||
# Start the first pool of the second round
|
||||
p1: Pool = await r2.pool_set.filter(letter=1).aget()
|
||||
r2.current_pool = p1
|
||||
await r2.asave()
|
||||
p1: Pool = await next_round.pool_set.filter(letter=1).aget()
|
||||
next_round.current_pool = p1
|
||||
await next_round.asave()
|
||||
|
||||
async for td in p1.teamdraw_set.prefetch_related('participation__team').all():
|
||||
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
|
||||
@ -846,9 +891,9 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.dice_visibility',
|
||||
'visible': True})
|
||||
elif r.number == 1 and self.tournament.final:
|
||||
elif r.number == 1 and (self.tournament.final or not settings.HAS_FINAL):
|
||||
# For the final tournament, we wait for a manual update between the two rounds.
|
||||
msg += "<br><br>Le tirage au sort du tour 1 est terminé."
|
||||
msg += "<br><br>" + _("The draw of the first round is ended.")
|
||||
self.tournament.draw.last_message = msg
|
||||
await self.tournament.draw.asave()
|
||||
|
||||
@ -877,7 +922,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
.prefetch_related('team').aget()
|
||||
# Ensure that the user can reject a problem at this time
|
||||
if participation.id != td.participation_id:
|
||||
return await self.alert("This is not your turn.", 'danger')
|
||||
return await self.alert(_("This is not your turn."), 'danger')
|
||||
|
||||
# Add the problem to the rejected problems list
|
||||
problem = td.purposed
|
||||
@ -887,19 +932,20 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
td.purposed = None
|
||||
await td.asave()
|
||||
|
||||
remaining = len(settings.PROBLEMS) - 5 - len(td.rejected)
|
||||
remaining = len(settings.PROBLEMS) - settings.RECOMMENDED_SOLUTIONS_COUNT - len(td.rejected)
|
||||
|
||||
# Update messages
|
||||
trigram = td.participation.team.trigram
|
||||
msg = f"L'équipe <strong>{trigram}</strong> a refusé le problème <strong>{problem} : " \
|
||||
f"{settings.PROBLEMS[problem - 1]}</strong>. "
|
||||
msg = _("The team <strong>{trigram}</strong> refused the problem <strong>{problem}</strong>: "
|
||||
"{problem_name}.").format(trigram=trigram, problem=problem,
|
||||
problem_name=settings.PROBLEMS[problem - 1]) + " "
|
||||
if remaining >= 0:
|
||||
msg += f"Il lui reste {remaining} refus sans pénalité."
|
||||
msg += _("It remains {remaining} refusals without penalty.").format(remaining=remaining)
|
||||
else:
|
||||
if already_refused:
|
||||
msg += "Cela n'ajoute pas de pénalité."
|
||||
msg += _("This problem was already refused by this team.")
|
||||
else:
|
||||
msg += "Cela ajoute une pénalité de 0.5 sur le coefficient de l'oral de læ défenseur⋅se."
|
||||
msg += _("It adds a 25% penalty on the coefficient of the oral defense.")
|
||||
self.tournament.draw.last_message = msg
|
||||
await self.tournament.draw.asave()
|
||||
|
||||
@ -942,8 +988,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
# Notify the team that it can draw a problem
|
||||
await self.channel_layer.group_send(f"team-{new_trigram}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.notify',
|
||||
'title': "À votre tour !",
|
||||
'body': "C'est à vous de tirer un nouveau problème !"})
|
||||
'title': str(_("Your turn!")),
|
||||
'body': str(_("It's your turn to draw a problem!"))})
|
||||
|
||||
@ensure_orga
|
||||
async def export(self, **kwargs):
|
||||
@ -953,15 +999,19 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
if not await Draw.objects.filter(tournament=self.tournament).aexists():
|
||||
return await self.alert(_("The draw has not started yet."), 'danger')
|
||||
|
||||
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.export_visibility',
|
||||
'visible': False})
|
||||
|
||||
# Export each exportable pool
|
||||
async for r in self.tournament.draw.round_set.all():
|
||||
async for pool in r.pool_set.all():
|
||||
if await pool.is_exportable():
|
||||
await pool.export()
|
||||
|
||||
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.export_visibility',
|
||||
'visible': False})
|
||||
# Update Google Sheets final sheet
|
||||
if os.getenv('GOOGLE_PRIVATE_KEY_ID', None):
|
||||
await sync_to_async(self.tournament.update_ranking_spreadsheet)()
|
||||
|
||||
@ensure_orga
|
||||
async def continue_final(self, **kwargs):
|
||||
@ -971,22 +1021,28 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
if not await Draw.objects.filter(tournament=self.tournament).aexists():
|
||||
return await self.alert(_("The draw has not started yet."), 'danger')
|
||||
|
||||
if not self.tournament.final:
|
||||
if not self.tournament.final and settings.TFJM_APP == "TFJM":
|
||||
return await self.alert(_("This is only available for the final tournament."), 'danger')
|
||||
|
||||
r2 = await self.tournament.draw.round_set.filter(number=2).aget()
|
||||
r2 = await self.tournament.draw.round_set.filter(number=self.tournament.draw.current_round.number + 1).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."
|
||||
if settings.TFJM_APP == "TFJM":
|
||||
msg = str(_("The draw of the round {round} is starting. "
|
||||
"The passage order is determined from the ranking of the first round, "
|
||||
"in order to mix the teams between the two days.").format(round=r2.number))
|
||||
else:
|
||||
msg = str(_("The draw of the round {round} is starting. "
|
||||
"The passage order is another time randomly drawn.").format(round=r2.number))
|
||||
self.tournament.draw.last_message = msg
|
||||
await self.tournament.draw.asave()
|
||||
|
||||
# Send notification to everyone
|
||||
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.notify',
|
||||
'title': 'Tirage au sort du TFJM²',
|
||||
'body': "Le tirage au sort pour le second tour de la finale a commencé !"})
|
||||
'title': str(_("Draw")) + " " + settings.APP_NAME,
|
||||
'body': str(_("The draw of the second round is starting!"))})
|
||||
|
||||
if settings.TFJM_APP == "TFJM":
|
||||
# Set the first pool of the second round as the active pool
|
||||
pool = await Pool.objects.filter(round=self.tournament.draw.current_round, letter=1).aget()
|
||||
r2.current_pool = pool
|
||||
@ -1027,6 +1083,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
f"tournament-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.dice', 'team': participation.team.trigram, 'result': None})
|
||||
|
||||
if settings.TFJM_APP == "TFJM":
|
||||
async for td in r2.current_pool.team_draws.prefetch_related('participation__team'):
|
||||
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.dice_visibility',
|
||||
@ -1035,8 +1092,13 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
# Notify the team that it can draw a problem
|
||||
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.notify',
|
||||
'title': "À votre tour !",
|
||||
'body': "C'est à vous de tirer un nouveau problème !"})
|
||||
'title': str(_("Your turn!")),
|
||||
'body': str(_("It's your turn to draw a problem!"))})
|
||||
else:
|
||||
async for td in r2.team_draws.prefetch_related('participation__team'):
|
||||
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.dice_visibility',
|
||||
'visible': True})
|
||||
|
||||
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.dice_visibility',
|
||||
@ -1051,7 +1113,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.set_active',
|
||||
'round': r2.number,
|
||||
'pool': r2.current_pool.get_letter_display()})
|
||||
'pool': r2.current_pool.get_letter_display() if r2.current_pool else None})
|
||||
|
||||
@ensure_orga
|
||||
async def cancel_last_step(self, **kwargs):
|
||||
@ -1325,32 +1387,21 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
'round': r.number,
|
||||
'team': td.participation.team.trigram,
|
||||
'problem': td.accepted})
|
||||
elif r.number == 2:
|
||||
elif r.number >= 2 and settings.TFJM_APP == "TFJM":
|
||||
if not self.tournament.final:
|
||||
# Go to the previous round
|
||||
r1 = await self.tournament.draw.round_set \
|
||||
.prefetch_related('current_pool__current_team__participation__team').aget(number=1)
|
||||
self.tournament.draw.current_round = r1
|
||||
previous_round = await self.tournament.draw.round_set \
|
||||
.prefetch_related('current_pool__current_team__participation__team').aget(number=r.number - 1)
|
||||
self.tournament.draw.current_round = previous_round
|
||||
await self.tournament.draw.asave()
|
||||
|
||||
async for td in r1.team_draws.prefetch_related('participation__team').all():
|
||||
async for td in previous_round.team_draws.prefetch_related('participation__team').all():
|
||||
await self.channel_layer.group_send(
|
||||
f"tournament-{self.tournament.id}", {'tid': self.tournament_id, 'type': 'draw.dice',
|
||||
'team': td.participation.team.trigram,
|
||||
'result': td.choice_dice})
|
||||
|
||||
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.send_poules',
|
||||
'round': r1.number,
|
||||
'poules': [
|
||||
{
|
||||
'letter': pool.get_letter_display(),
|
||||
'teams': await pool.atrigrams(),
|
||||
}
|
||||
async for pool in r1.pool_set.order_by('letter').all()
|
||||
]})
|
||||
|
||||
previous_pool = r1.current_pool
|
||||
previous_pool = previous_round.current_pool
|
||||
|
||||
td = previous_pool.current_team
|
||||
td.purposed = td.accepted
|
||||
@ -1370,14 +1421,14 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
|
||||
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.set_problem',
|
||||
'round': r1.number,
|
||||
'round': previous_round.number,
|
||||
'team': td.participation.team.trigram,
|
||||
'problem': td.accepted})
|
||||
else:
|
||||
# Don't continue the final tournament
|
||||
r1 = await self.tournament.draw.round_set \
|
||||
previous_round = await self.tournament.draw.round_set \
|
||||
.prefetch_related('current_pool__current_team__participation__team').aget(number=1)
|
||||
self.tournament.draw.current_round = r1
|
||||
self.tournament.draw.current_round = previous_round
|
||||
await self.tournament.draw.asave()
|
||||
|
||||
async for td in r.teamdraw_set.all():
|
||||
@ -1399,7 +1450,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
]
|
||||
})
|
||||
|
||||
async for td in r1.team_draws.prefetch_related('participation__team').all():
|
||||
async for td in previous_round.team_draws.prefetch_related('participation__team').all():
|
||||
await self.channel_layer.group_send(
|
||||
f"tournament-{self.tournament.id}", {'tid': self.tournament_id, 'type': 'draw.dice',
|
||||
'team': td.participation.team.trigram,
|
||||
@ -1413,8 +1464,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
'visible': True})
|
||||
else:
|
||||
# Go to the dice order
|
||||
async for r0 in self.tournament.draw.round_set.all():
|
||||
async for td in r0.teamdraw_set.all():
|
||||
async for td in r.teamdraw_set.all():
|
||||
td.pool = None
|
||||
td.passage_index = None
|
||||
td.choose_index = None
|
||||
@ -1424,6 +1474,21 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
r.current_pool = None
|
||||
await r.asave()
|
||||
|
||||
await self.channel_layer.group_send(
|
||||
f"tournament-{self.tournament.id}",
|
||||
{
|
||||
'tid': self.tournament_id,
|
||||
'type': 'draw.send_poules',
|
||||
'round': r.number,
|
||||
'poules': [
|
||||
{
|
||||
'letter': pool.get_letter_display(),
|
||||
'teams': await pool.atrigrams(),
|
||||
}
|
||||
async for pool in r.pool_set.order_by('letter').all()
|
||||
]
|
||||
})
|
||||
|
||||
round_tds = {td.id: td async for td in r.team_draws.prefetch_related('participation__team')}
|
||||
|
||||
# Reset the last dice
|
||||
@ -1493,8 +1558,45 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
|
||||
'team': last_td.participation.team.trigram,
|
||||
'result': None})
|
||||
break
|
||||
else:
|
||||
elif r.number == 1:
|
||||
# Cancel the draw if it is the first round
|
||||
await self.abort()
|
||||
else:
|
||||
# Go back to the first round after resetting all
|
||||
previous_round = await self.tournament.draw.round_set \
|
||||
.prefetch_related('current_pool__current_team__participation__team').aget(number=r.number - 1)
|
||||
self.tournament.draw.current_round = previous_round
|
||||
await self.tournament.draw.asave()
|
||||
|
||||
async for td in previous_round.team_draws.prefetch_related('participation__team').all():
|
||||
await self.channel_layer.group_send(
|
||||
f"tournament-{self.tournament.id}", {'tid': self.tournament_id, 'type': 'draw.dice',
|
||||
'team': td.participation.team.trigram,
|
||||
'result': td.choice_dice})
|
||||
|
||||
previous_pool = previous_round.current_pool
|
||||
|
||||
td = previous_pool.current_team
|
||||
td.purposed = td.accepted
|
||||
td.accepted = None
|
||||
await td.asave()
|
||||
|
||||
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.dice_visibility',
|
||||
'visible': False})
|
||||
|
||||
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.buttons_visibility',
|
||||
'visible': True})
|
||||
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.buttons_visibility',
|
||||
'visible': True})
|
||||
|
||||
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
|
||||
{'tid': self.tournament_id, 'type': 'draw.set_problem',
|
||||
'round': previous_round.number,
|
||||
'team': td.participation.team.trigram,
|
||||
'problem': td.accepted})
|
||||
|
||||
async def draw_alert(self, content):
|
||||
"""
|
||||
|
28
draw/migrations/0003_alter_teamdraw_options.py
Normal file
@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-22 22:11
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("draw", "0002_alter_teamdraw_purposed"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="teamdraw",
|
||||
options={
|
||||
"ordering": (
|
||||
"round__draw__tournament__name",
|
||||
"round__number",
|
||||
"pool__letter",
|
||||
"passage_index",
|
||||
"choice_dice",
|
||||
"passage_dice",
|
||||
),
|
||||
"verbose_name": "team draw",
|
||||
"verbose_name_plural": "team draws",
|
||||
},
|
||||
),
|
||||
]
|
27
draw/migrations/0004_alter_round_number.py
Normal file
@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.0.6 on 2024-06-07 12:46
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("draw", "0003_alter_teamdraw_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="round",
|
||||
name="number",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[(1, "Round 1"), (2, "Round 2")],
|
||||
help_text="The number of the round, 1 or 2 (or 3 for ETEAM)",
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(2),
|
||||
],
|
||||
verbose_name="number",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,69 @@
|
||||
# Generated by Django 5.0.6 on 2024-06-13 08:53
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("draw", "0004_alter_round_number"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="round",
|
||||
name="number",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[(1, "Round 1"), (2, "Round 2"), (3, "Round 3")],
|
||||
help_text="The number of the round, 1 or 2 (or 3 for ETEAM)",
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(3),
|
||||
],
|
||||
verbose_name="number",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="teamdraw",
|
||||
name="accepted",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(1, "Problem #1"),
|
||||
(2, "Problem #2"),
|
||||
(3, "Problem #3"),
|
||||
(4, "Problem #4"),
|
||||
(5, "Problem #5"),
|
||||
(6, "Problem #6"),
|
||||
(7, "Problem #7"),
|
||||
(8, "Problem #8"),
|
||||
(9, "Problem #9"),
|
||||
(10, "Problem #10"),
|
||||
],
|
||||
default=None,
|
||||
null=True,
|
||||
verbose_name="accepted problem",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="teamdraw",
|
||||
name="purposed",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(1, "Problem #1"),
|
||||
(2, "Problem #2"),
|
||||
(3, "Problem #3"),
|
||||
(4, "Problem #4"),
|
||||
(5, "Problem #5"),
|
||||
(6, "Problem #6"),
|
||||
(7, "Problem #7"),
|
||||
(8, "Problem #8"),
|
||||
(9, "Problem #9"),
|
||||
(10, "Problem #10"),
|
||||
],
|
||||
default=None,
|
||||
null=True,
|
||||
verbose_name="purposed problem",
|
||||
),
|
||||
),
|
||||
]
|
27
draw/migrations/0006_alter_round_current_pool.py
Normal file
@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.0.6 on 2024-07-09 11:07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("draw", "0005_alter_round_number_alter_teamdraw_accepted_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="round",
|
||||
name="current_pool",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
help_text="The current pool where teams select their problems.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to="draw.pool",
|
||||
verbose_name="current pool",
|
||||
),
|
||||
),
|
||||
]
|
164
draw/models.py
@ -1,8 +1,11 @@
|
||||
# Copyright (C) 2023 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import os
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import QuerySet
|
||||
@ -79,7 +82,7 @@ class Draw(models.Model):
|
||||
elif self.current_round.current_pool.current_team is None:
|
||||
return 'DICE_ORDER_POULE'
|
||||
elif self.current_round.current_pool.current_team.accepted is not None:
|
||||
if self.current_round.number == 1:
|
||||
if self.current_round.number < settings.NB_ROUNDS:
|
||||
# The last step can be the last problem acceptation after the first round
|
||||
# only for the final between the two rounds
|
||||
return 'WAITING_FINAL'
|
||||
@ -89,6 +92,7 @@ class Draw(models.Model):
|
||||
return 'WAITING_DRAW_PROBLEM'
|
||||
else:
|
||||
return 'WAITING_CHOOSE_PROBLEM'
|
||||
get_state.short_description = _('State')
|
||||
|
||||
@property
|
||||
def information(self):
|
||||
@ -107,58 +111,61 @@ class Draw(models.Model):
|
||||
# Waiting for dices to determine pools and passage order
|
||||
if self.current_round.number == 1:
|
||||
# Specific information for the first round
|
||||
s += """Nous allons commencer le tirage des problèmes.<br>
|
||||
Vous pouvez à tout moment poser toute question si quelque chose
|
||||
n'est pas clair ou ne va pas.<br><br>
|
||||
Nous allons d'abord tirer les poules et l'ordre de passage
|
||||
pour le premier tour avec toutes les équipes puis pour chaque poule,
|
||||
nous tirerons l'ordre de tirage pour le tour et les problèmes.<br><br>"""
|
||||
s += """
|
||||
Les capitaines, vous pouvez désormais toustes lancer un dé 100,
|
||||
en cliquant sur le gros bouton. Les poules et l'ordre de passage
|
||||
lors du premier tour sera l'ordre croissant des dés, c'est-à-dire
|
||||
que le plus petit lancer sera le premier à passer dans la poule A."""
|
||||
s += _("We are going to start the problem draw.<br>"
|
||||
"You can ask any question if something is not clear or wrong.<br><br>"
|
||||
"We are going to first draw the pools and the passage order for the first round "
|
||||
"with all the teams, then for each pool, we will draw the draw order and the problems.")
|
||||
s += "<br><br>"
|
||||
s += _("The captains, you can now all throw a 100-sided dice, by clicking on the big dice button. "
|
||||
"The pools and the passage order during the first round will be the increasing order "
|
||||
"of the dices, ie. the smallest dice will be the first to pass in pool A.")
|
||||
case 'DICE_ORDER_POULE':
|
||||
# Waiting for dices to determine the choice order
|
||||
s += f"""Nous passons au tirage des problèmes pour la poule
|
||||
<strong>{self.current_round.current_pool}</strong>, entre les équipes
|
||||
<strong>{', '.join(td.participation.team.trigram
|
||||
for td in self.current_round.current_pool.teamdraw_set.all())}</strong>.
|
||||
Les capitaines peuvent lancer un dé 100 en cliquant sur le gros bouton
|
||||
pour déterminer l'ordre de tirage. L'équipe réalisant le plus gros score pourra
|
||||
tirer en premier."""
|
||||
s += _("We are going to start the problem draw for the pool <strong>{pool}</strong>, "
|
||||
"between the teams <strong>{teams}</strong>. "
|
||||
"The captains can throw a 100-sided dice by clicking on the big dice button "
|
||||
"to determine the order of draw. The team with the highest score will draw first.") \
|
||||
.format(pool=self.current_round.current_pool,
|
||||
teams=', '.join(td.participation.team.trigram
|
||||
for td in self.current_round.current_pool.teamdraw_set.all()))
|
||||
case 'WAITING_DRAW_PROBLEM':
|
||||
# Waiting for a problem draw
|
||||
td = self.current_round.current_pool.current_team
|
||||
s += f"""C'est au tour de l'équipe <strong>{td.participation.team.trigram}</strong>
|
||||
de choisir son problème. Cliquez sur l'urne au milieu pour tirer un problème au sort."""
|
||||
s += _("The team <strong>{trigram}</strong> is going to draw a problem. "
|
||||
"Click on the urn in the middle to draw a problem.") \
|
||||
.format(trigram=td.participation.team.trigram)
|
||||
case 'WAITING_CHOOSE_PROBLEM':
|
||||
# Waiting for the team that can accept or reject the problem
|
||||
td = self.current_round.current_pool.current_team
|
||||
s += f"""L'équipe <strong>{td.participation.team.trigram}</strong> a tiré le problème
|
||||
<strong>{td.purposed} : {settings.PROBLEMS[td.purposed - 1]}</strong>. """
|
||||
s += _("The team <strong>{trigram}</strong> drew the problem <strong>{problem}: "
|
||||
"{problem_name}</strong>.") \
|
||||
.format(trigram=td.participation.team.trigram,
|
||||
problem=td.purposed, problem_name=settings.PROBLEMS[td.purposed - 1]) + " "
|
||||
if td.purposed in td.rejected:
|
||||
# The problem was previously rejected
|
||||
s += """Elle a déjà refusé ce problème auparavant, elle peut donc le refuser sans pénalité et
|
||||
tirer un nouveau problème immédiatement, ou bien revenir sur son choix."""
|
||||
s += _("It already refused this problem before, so it can refuse it without penalty and "
|
||||
"draw a new problem immediately, or change its mind.")
|
||||
else:
|
||||
# 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 læ défenseur⋅se."
|
||||
s += _("It can decide to accept or refuse this problem.") + " "
|
||||
if len(td.rejected) >= len(settings.PROBLEMS) - settings.RECOMMENDED_SOLUTIONS_COUNT:
|
||||
s += _("Refusing this problem will add a new 25% penalty "
|
||||
"on the coefficient of the oral defense.")
|
||||
else:
|
||||
s += f"Il reste {len(settings.PROBLEMS) - 5 - len(td.rejected)} refus sans pénalité."
|
||||
s += _("There are still {remaining} refusals without penalty.").format(
|
||||
remaining=len(settings.PROBLEMS) - settings.RECOMMENDED_SOLUTIONS_COUNT - len(td.rejected))
|
||||
case 'WAITING_FINAL':
|
||||
# We are between the two rounds of the final tournament
|
||||
s += "Le tirage au sort pour le tour 2 aura lieu à la fin du premier tour. Bon courage !"
|
||||
s += _("The draw for the second round will take place at the end of the first round. Good luck!")
|
||||
case 'DRAW_ENDED':
|
||||
# The draw is ended
|
||||
s += "Le tirage au sort est terminé. Les solutions des autres équipes peuvent être trouvées dans l'onglet « Ma participation »."
|
||||
s += _("The draw is ended. The solutions of the other teams can be found in the tab "
|
||||
"\"My participation\".")
|
||||
|
||||
s += "<br><br>" if s else ""
|
||||
s += """Pour plus de détails sur le déroulement du tirage au sort,
|
||||
le règlement est accessible sur
|
||||
<a class="alert-link" href="https://tfjm.org/reglement">https://tfjm.org/reglement</a>."""
|
||||
rules_link = settings.RULES_LINK
|
||||
s += _("For more details on the draw, the rules are available on "
|
||||
"<a class=\"alert-link\" href=\"{link}\">{link}</a>.").format(link=rules_link)
|
||||
return s
|
||||
|
||||
async def ainformation(self) -> str:
|
||||
@ -190,15 +197,15 @@ class Round(models.Model):
|
||||
choices=[
|
||||
(1, _('Round 1')),
|
||||
(2, _('Round 2')),
|
||||
],
|
||||
(3, _('Round 3'))],
|
||||
verbose_name=_('number'),
|
||||
help_text=_("The number of the round, 1 or 2"),
|
||||
validators=[MinValueValidator(1), MaxValueValidator(2)],
|
||||
help_text=_("The number of the round, 1 or 2 (or 3 for ETEAM)"),
|
||||
validators=[MinValueValidator(1), MaxValueValidator(3)],
|
||||
)
|
||||
|
||||
current_pool = models.ForeignKey(
|
||||
'Pool',
|
||||
on_delete=models.CASCADE,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
default=None,
|
||||
related_name='+',
|
||||
@ -227,6 +234,13 @@ class Round(models.Model):
|
||||
def __str__(self):
|
||||
return self.get_number_display()
|
||||
|
||||
def clean(self):
|
||||
if self.number is not None and self.number > settings.NB_ROUNDS:
|
||||
raise ValidationError({'number': _("The number of the round must be between 1 and {nb}.")
|
||||
.format(nb=settings.NB_ROUNDS)})
|
||||
|
||||
return super().clean()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('round')
|
||||
verbose_name_plural = _('rounds')
|
||||
@ -289,7 +303,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]:
|
||||
@ -346,7 +360,7 @@ class Pool(models.Model):
|
||||
Translates this Pool instance in a :model:`participation.Pool` instance, with the passage orders.
|
||||
"""
|
||||
# Create the pool
|
||||
self.associated_pool = await PPool.objects.acreate(
|
||||
self.associated_pool, _created = await PPool.objects.aget_or_create(
|
||||
tournament=self.round.draw.tournament,
|
||||
round=self.round.number,
|
||||
letter=self.letter,
|
||||
@ -358,6 +372,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:
|
||||
@ -375,28 +400,42 @@ class Pool(models.Model):
|
||||
]
|
||||
elif self.size == 5:
|
||||
table = [
|
||||
[0, 2, 3],
|
||||
[1, 3, 4],
|
||||
[2, 0, 1],
|
||||
[3, 4, 0],
|
||||
[4, 1, 2],
|
||||
[0, 2, 3, 4],
|
||||
[1, 3, 4, 0],
|
||||
[2, 4, 0, 1],
|
||||
[3, 0, 1, 2],
|
||||
[4, 1, 2, 3],
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
reporter = tds[line[0]].participation
|
||||
opponent = tds[line[1]].participation
|
||||
reviewer = tds[line[2]].participation
|
||||
observer = tds[line[3]].participation if self.size >= 4 and settings.HAS_OBSERVER else None
|
||||
|
||||
# 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,
|
||||
reporter=reporter,
|
||||
opponent=opponent,
|
||||
reviewer=reviewer,
|
||||
observer=observer,
|
||||
reporter_penalties=tds[line[0]].penalty_int,
|
||||
)
|
||||
if self.size == 4:
|
||||
# Add observer for 4-teams pools
|
||||
passage.observer = tds[line[3]].participation
|
||||
await passage.asave()
|
||||
|
||||
# Update Google Sheets
|
||||
if os.getenv('GOOGLE_PRIVATE_KEY_ID', None):
|
||||
await sync_to_async(self.associated_pool.update_spreadsheet)()
|
||||
|
||||
return self.associated_pool
|
||||
|
||||
@ -502,17 +541,17 @@ class TeamDraw(models.Model):
|
||||
@property
|
||||
def penalty_int(self):
|
||||
"""
|
||||
The number of penalties, which is the number of rejected problems after the P - 5 free rejects,
|
||||
where P is the number of problems.
|
||||
The number of penalties, which is the number of rejected problems after the P - 5 free rejects
|
||||
(P - 6 for ETEAM), where P is the number of problems.
|
||||
"""
|
||||
return max(0, len(self.rejected) - (len(settings.PROBLEMS) - 5))
|
||||
return max(0, len(self.rejected) - (len(settings.PROBLEMS) - settings.RECOMMENDED_SOLUTIONS_COUNT))
|
||||
|
||||
@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 reporter 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}"),
|
||||
@ -523,4 +562,5 @@ class TeamDraw(models.Model):
|
||||
class Meta:
|
||||
verbose_name = _('team draw')
|
||||
verbose_name_plural = _('team draws')
|
||||
ordering = ('round__draw__tournament__name', 'round__number', 'pool__letter', 'passage_index',)
|
||||
ordering = ('round__draw__tournament__name', 'round__number', 'pool__letter', 'passage_index',
|
||||
'choice_dice', 'passage_dice',)
|
||||
|
@ -1,10 +0,0 @@
|
||||
# Copyright (C) 2023 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from . import consumers
|
||||
|
||||
websocket_urlpatterns = [
|
||||
path("ws/draw/", consumers.DrawConsumer.as_asgi()),
|
||||
]
|
@ -4,6 +4,9 @@
|
||||
await Notification.requestPermission()
|
||||
})()
|
||||
|
||||
const TFJM = JSON.parse(document.getElementById('TFJM_settings').textContent)
|
||||
const RECOMMENDED_SOLUTIONS_COUNT = TFJM.RECOMMENDED_SOLUTIONS_COUNT
|
||||
|
||||
const problems_count = JSON.parse(document.getElementById('problems_count').textContent)
|
||||
|
||||
const tournaments = JSON.parse(document.getElementById('tournaments_list').textContent)
|
||||
@ -40,6 +43,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 +220,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
elem.classList.add('text-bg-success')
|
||||
elem.innerText = `${trigram} 🎲 ${result}`
|
||||
}
|
||||
|
||||
let nextTeamDiv = document.querySelector(` div[id="dices-${tid}"] > div > div[class*="text-bg-warning"]`)
|
||||
if (nextTeamDiv) {
|
||||
// If there is one team that does not have launched its dice, then we update the debug section
|
||||
let nextTeam = nextTeamDiv.getAttribute("data-team")
|
||||
let debugSpan = document.getElementById(`debug-dice-${tid}-team`)
|
||||
if (debugSpan)
|
||||
debugSpan.innerText = nextTeam
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -212,10 +238,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 +256,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')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -276,7 +312,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
/**
|
||||
* Set the different pools for the given round, and update the interface.
|
||||
* @param tid The tournament id
|
||||
* @param round The round number, as integer (1 or 2)
|
||||
* @param round The round number, as integer (1 or 2, or 3 for ETEAM)
|
||||
* @param poules The list of poules, which are represented with their letters and trigrams,
|
||||
* [{'letter': 'A', 'teams': ['ABC', 'DEF', 'GHI']}]
|
||||
*/
|
||||
@ -398,7 +434,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
/**
|
||||
* Update the table for the given round and the given pool, where there will be the chosen problems.
|
||||
* @param tid The tournament id
|
||||
* @param round The round number, as integer (1 or 2)
|
||||
* @param round The round number, as integer (1 or 2, or 3 for ETEAM)
|
||||
* @param poule The current pool, which id represented with its letter and trigrams,
|
||||
* {'letter': 'A', 'teams': ['ABC', 'DEF', 'GHI']}
|
||||
*/
|
||||
@ -486,45 +522,45 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
teamTd.innerText = team
|
||||
teamTr.append(teamTd)
|
||||
|
||||
let defenderTd = document.createElement('td')
|
||||
defenderTd.classList.add('text-center')
|
||||
defenderTd.innerText = 'Déf'
|
||||
let reporterTd = document.createElement('td')
|
||||
reporterTd.classList.add('text-center')
|
||||
reporterTd.innerText = 'Déf'
|
||||
|
||||
let opponentTd = document.createElement('td')
|
||||
opponentTd.classList.add('text-center')
|
||||
opponentTd.innerText = 'Opp'
|
||||
|
||||
let reporterTd = document.createElement('td')
|
||||
reporterTd.classList.add('text-center')
|
||||
reporterTd.innerText = 'Rap'
|
||||
let reviewerTd = document.createElement('td')
|
||||
reviewerTd.classList.add('text-center')
|
||||
reviewerTd.innerText = 'Rap'
|
||||
|
||||
// Put the cells in their right places, according to the pool size and the row number.
|
||||
if (poule.teams.length === 3) {
|
||||
switch (i) {
|
||||
case 0:
|
||||
teamTr.append(defenderTd, reporterTd, opponentTd)
|
||||
teamTr.append(reporterTd, reviewerTd, opponentTd)
|
||||
break
|
||||
case 1:
|
||||
teamTr.append(opponentTd, defenderTd, reporterTd)
|
||||
teamTr.append(opponentTd, reporterTd, reviewerTd)
|
||||
break
|
||||
case 2:
|
||||
teamTr.append(reporterTd, opponentTd, defenderTd)
|
||||
teamTr.append(reviewerTd, opponentTd, reporterTd)
|
||||
break
|
||||
}
|
||||
} else if (poule.teams.length === 4) {
|
||||
let emptyTd = document.createElement('td')
|
||||
switch (i) {
|
||||
case 0:
|
||||
teamTr.append(defenderTd, emptyTd, reporterTd, opponentTd)
|
||||
teamTr.append(reporterTd, emptyTd, reviewerTd, opponentTd)
|
||||
break
|
||||
case 1:
|
||||
teamTr.append(opponentTd, defenderTd, emptyTd, reporterTd)
|
||||
teamTr.append(opponentTd, reporterTd, emptyTd, reviewerTd)
|
||||
break
|
||||
case 2:
|
||||
teamTr.append(reporterTd, opponentTd, defenderTd, emptyTd)
|
||||
teamTr.append(reviewerTd, opponentTd, reporterTd, emptyTd)
|
||||
break
|
||||
case 3:
|
||||
teamTr.append(emptyTd, reporterTd, opponentTd, defenderTd)
|
||||
teamTr.append(emptyTd, reviewerTd, opponentTd, reporterTd)
|
||||
break
|
||||
}
|
||||
} else if (poule.teams.length === 5) {
|
||||
@ -532,19 +568,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
let emptyTd2 = document.createElement('td')
|
||||
switch (i) {
|
||||
case 0:
|
||||
teamTr.append(defenderTd, emptyTd, opponentTd, reporterTd, emptyTd2)
|
||||
teamTr.append(reporterTd, emptyTd, opponentTd, reviewerTd, emptyTd2)
|
||||
break
|
||||
case 1:
|
||||
teamTr.append(emptyTd, defenderTd, reporterTd, emptyTd2, opponentTd)
|
||||
teamTr.append(emptyTd, reporterTd, reviewerTd, emptyTd2, opponentTd)
|
||||
break
|
||||
case 2:
|
||||
teamTr.append(opponentTd, emptyTd, defenderTd, emptyTd2, reporterTd)
|
||||
teamTr.append(opponentTd, emptyTd, reporterTd, emptyTd2, reviewerTd)
|
||||
break
|
||||
case 3:
|
||||
teamTr.append(reporterTd, opponentTd, emptyTd, defenderTd, emptyTd2)
|
||||
teamTr.append(reviewerTd, opponentTd, emptyTd, reporterTd, emptyTd2)
|
||||
break
|
||||
case 4:
|
||||
teamTr.append(emptyTd, reporterTd, emptyTd2, opponentTd, defenderTd)
|
||||
teamTr.append(emptyTd, reviewerTd, emptyTd2, opponentTd, reporterTd)
|
||||
break
|
||||
}
|
||||
}
|
||||
@ -555,7 +591,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
/**
|
||||
* Highlight the team that is currently choosing its problem.
|
||||
* @param tid The tournament id
|
||||
* @param round The current round number, as integer (1 or 2)
|
||||
* @param round The current round number, as integer (1 or 2, or 3 for ETEAM)
|
||||
* @param pool The current pool letter (A, B, C or D) (null if non-relevant)
|
||||
* @param team The current team trigram (null if non-relevant)
|
||||
*/
|
||||
@ -582,12 +618,17 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the recap and the table when a team accepts a problem.
|
||||
* @param tid The tournament id
|
||||
* @param round The current round, as integer (1 or 2)
|
||||
* @param round The current round, as integer (1 or 2, or 3 for ETEAM)
|
||||
* @param team The current team trigram
|
||||
* @param problem The accepted problem, as integer
|
||||
*/
|
||||
@ -611,7 +652,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
/**
|
||||
* Update the recap when a team rejects a problem.
|
||||
* @param tid The tournament id
|
||||
* @param round The current round, as integer (1 or 2)
|
||||
* @param round The current round, as integer (1 or 2, or 3 for ETEAM)
|
||||
* @param team The current team trigram
|
||||
* @param rejected The full list of rejected problems
|
||||
*/
|
||||
@ -621,15 +662,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
recapDiv.textContent = `🗑️ ${rejected.join(', ')}`
|
||||
|
||||
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 (rejected.length > problems_count - RECOMMENDED_SOLUTIONS_COUNT) {
|
||||
// If more than P - 5 problems were rejected, add a penalty of 25% of the coefficient of the oral reporter
|
||||
// This is P - 6 for the ETEAM
|
||||
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 - RECOMMENDED_SOLUTIONS_COUNT))} %`
|
||||
} else {
|
||||
// Eventually remove this div
|
||||
if (penaltyDiv !== null)
|
||||
@ -641,7 +683,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
* For a 5-teams pool, we may reorder the pool if two teams select the same problem.
|
||||
* Then, we redraw the table and set the accepted problems.
|
||||
* @param tid The tournament id
|
||||
* @param round The current round, as integer (1 or 2)
|
||||
* @param round The current round, as integer (1 or 2, or 3 for ETEAM)
|
||||
* @param poule The pool represented by its letter
|
||||
* @param teams The teams list represented by their trigrams, ["ABC", "DEF", "GHI", "JKL", "MNO"]
|
||||
* @param problems The accepted problems in the same order than the teams, [1, 1, 2, 2, 3]
|
||||
@ -659,6 +701,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
let problem = problems[i]
|
||||
|
||||
setProblemAccepted(tid, round, team, problem)
|
||||
|
||||
let recapTeam = document.getElementById(`recap-${tid}-round-${round}-team-${team}`)
|
||||
recapTeam.style.order = i.toString()
|
||||
}
|
||||
}
|
||||
|
||||
@ -736,7 +781,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function setupSocket() {
|
||||
function setupSocket(nextDelay = 1000) {
|
||||
// Open a global websocket
|
||||
socket = new WebSocket(
|
||||
(document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/ws/draw/'
|
||||
@ -753,7 +798,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Manage errors
|
||||
socket.addEventListener('close', e => {
|
||||
console.error('Chat socket closed unexpectedly, restarting…')
|
||||
setupSocket()
|
||||
setTimeout(() => setupSocket(2 * nextDelay), nextDelay)
|
||||
})
|
||||
|
||||
// When the socket is opened, set the language in order to receive alerts in the good language
|
@ -2,6 +2,7 @@
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load pipeline %}
|
||||
|
||||
{% block content %}
|
||||
{# The navbar to select the tournament #}
|
||||
@ -40,5 +41,5 @@
|
||||
{{ problems|length|json_script:'problems_count' }}
|
||||
|
||||
{# This script contains all data for the draw management #}
|
||||
<script src="{% static 'draw.js' %}"></script>
|
||||
{% javascript 'draw' %}
|
||||
{% endblock %}
|
||||
|
@ -37,6 +37,7 @@
|
||||
{% for td in tournament.draw.current_round.team_draws %}
|
||||
<div class="col-md-1" style="order: {{ forloop.counter }};">
|
||||
<div id="dice-{{ tournament.id }}-{{ td.participation.team.trigram }}"
|
||||
data-team="{{ td.participation.team.trigram }}"
|
||||
class="badge rounded-pill text-bg-{% if td.last_dice %}success{% else %}warning{% endif %}"
|
||||
{% if request.user.registration.is_volunteer %}
|
||||
{# Volunteers can click on dices to launch the dice of a team #}
|
||||
@ -99,7 +100,7 @@
|
||||
{# If needed, add the penalty of the team #}
|
||||
<div id="recap-{{ tournament.id }}-round-{{ round.number }}-team-{{ td.participation.team.trigram }}-penalty"
|
||||
class="badge rounded-pill text-bg-info">
|
||||
❌ {{ td.penalty }}
|
||||
❌ {{ td.penalty }} %
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
@ -175,7 +176,7 @@
|
||||
📁 {% trans "Export" %}
|
||||
</button>
|
||||
</div>
|
||||
{% if tournament.final %}
|
||||
{% if tournament.final or not TFJM.HAS_FINAL %}
|
||||
{# Volunteers can continue the second round for the final tournament #}
|
||||
<div id="continue-{{ tournament.id }}"
|
||||
class="card-footer text-center{% if tournament.draw.get_state != 'WAITING_FINAL' %} d-none{% endif %}">
|
||||
@ -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>
|
||||
|
||||
@ -246,72 +307,71 @@
|
||||
<td class="text-center">{{ td.participation.team.trigram }}</td>
|
||||
{% if pool.size == 3 %}
|
||||
{% if forloop.counter == 1 %}
|
||||
<td class="text-center">Déf</td>
|
||||
<td class="text-center">Rap</td>
|
||||
<td class="text-center">Opp</td>
|
||||
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
|
||||
{% elif forloop.counter == 2 %}
|
||||
<td class="text-center">Opp</td>
|
||||
<td class="text-center">Déf</td>
|
||||
<td class="text-center">Rap</td>
|
||||
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
|
||||
{% elif forloop.counter == 3 %}
|
||||
<td class="text-center">Rap</td>
|
||||
<td class="text-center">Opp</td>
|
||||
<td class="text-center">Déf</td>
|
||||
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
|
||||
{% endif %}
|
||||
{% elif pool.size == 4 %}
|
||||
{% if forloop.counter == 1 %}
|
||||
<td class="text-center">Déf</td>
|
||||
<td></td>
|
||||
<td class="text-center">Rap</td>
|
||||
<td class="text-center">Opp</td>
|
||||
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
|
||||
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
|
||||
{% elif forloop.counter == 2 %}
|
||||
<td class="text-center">Opp</td>
|
||||
<td class="text-center">Déf</td>
|
||||
<td></td>
|
||||
<td class="text-center">Rap</td>
|
||||
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
|
||||
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
|
||||
{% elif forloop.counter == 3 %}
|
||||
<td class="text-center">Rap</td>
|
||||
<td class="text-center">Opp</td>
|
||||
<td class="text-center">Déf</td>
|
||||
<td></td>
|
||||
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
|
||||
{% elif forloop.counter == 4 %}
|
||||
<td></td>
|
||||
<td class="text-center">Rap</td>
|
||||
<td class="text-center">Opp</td>
|
||||
<td class="text-center">Déf</td>
|
||||
<td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
|
||||
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
|
||||
{% endif %}
|
||||
{% elif pool.size == 5 %}
|
||||
{% if forloop.counter == 1 %}
|
||||
<td class="text-center">Déf</td>
|
||||
<td></td>
|
||||
<td class="text-center">Opp</td>
|
||||
<td class="text-center">Rap</td>
|
||||
<td></td>
|
||||
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
|
||||
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
|
||||
<td class="text-center"></td>
|
||||
{% elif forloop.counter == 2 %}
|
||||
<td></td>
|
||||
<td class="text-center">Déf</td>
|
||||
<td class="text-center">Rap</td>
|
||||
<td></td>
|
||||
<td class="text-center">Opp</td>
|
||||
<td class="text-center"></td>
|
||||
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
|
||||
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
|
||||
{% elif forloop.counter == 3 %}
|
||||
<td class="text-center">Opp</td>
|
||||
<td></td>
|
||||
<td class="text-center">Déf</td>
|
||||
<td></td>
|
||||
<td class="text-center">Rap</td>
|
||||
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
|
||||
<td class="text-center"></td>
|
||||
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
|
||||
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
|
||||
{% elif forloop.counter == 4 %}
|
||||
<td class="text-center">Rap</td>
|
||||
<td class="text-center">Opp</td>
|
||||
<td></td>
|
||||
<td class="text-center">Déf</td>
|
||||
<td></td>
|
||||
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
|
||||
<td class="text-center"></td>
|
||||
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
|
||||
{% elif forloop.counter == 5 %}
|
||||
<td></td>
|
||||
<td class="text-center">Rap</td>
|
||||
<td></td>
|
||||
<td class="text-center">Opp</td>
|
||||
<td class="text-center">Déf</td>
|
||||
<td></td>
|
||||
<td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
|
||||
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
|
||||
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
|
||||
<td class="text-center"></td>
|
||||
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</tr>
|
||||
|
@ -14,8 +14,8 @@ from django.contrib.sites.models import Site
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from participation.models import Team, Tournament
|
||||
from tfjm import routing as websocket_routing
|
||||
|
||||
from . import routing
|
||||
from .models import Draw, Pool, Round, TeamDraw
|
||||
|
||||
|
||||
@ -55,7 +55,7 @@ class TestDraw(TestCase):
|
||||
|
||||
# Connect to Websocket
|
||||
headers = [(b'cookie', self.async_client.cookies.output(header='', sep='; ').encode())]
|
||||
communicator = WebsocketCommunicator(AuthMiddlewareStack(URLRouter(routing.websocket_urlpatterns)),
|
||||
communicator = WebsocketCommunicator(AuthMiddlewareStack(URLRouter(websocket_routing.websocket_urlpatterns)),
|
||||
"/ws/draw/", headers)
|
||||
connected, subprotocol = await communicator.connect()
|
||||
self.assertTrue(connected)
|
||||
@ -71,11 +71,11 @@ class TestDraw(TestCase):
|
||||
resp = await communicator.receive_json_from()
|
||||
self.assertEqual(resp['type'], 'alert')
|
||||
self.assertEqual(resp['alert_type'], 'danger')
|
||||
self.assertEqual(resp['message'], "The sum must be equal to the number of teams: expected 12, got 3")
|
||||
self.assertEqual(resp['message'], "La somme doit être égale au nombre d'équipes : attendu 12, obtenu 3")
|
||||
self.assertFalse(await Draw.objects.filter(tournament=self.tournament).aexists())
|
||||
|
||||
# Now start the draw
|
||||
await communicator.send_json_to({'tid': tid, 'type': 'start_draw', 'fmt': '3+4+5'})
|
||||
await communicator.send_json_to({'tid': tid, 'type': 'start_draw', 'fmt': '4+5+3'})
|
||||
|
||||
# Receive data after the start
|
||||
self.assertEqual((await communicator.receive_json_from())['type'], 'alert')
|
||||
@ -93,7 +93,7 @@ class TestDraw(TestCase):
|
||||
{'tid': tid, 'type': 'dice_visibility', 'visible': True})
|
||||
self.assertEqual((await communicator.receive_json_from())['type'], 'alert')
|
||||
self.assertEqual(await communicator.receive_json_from(),
|
||||
{'tid': tid, 'type': 'draw_start', 'fmt': [5, 4, 3],
|
||||
{'tid': tid, 'type': 'draw_start', 'fmt': [3, 4, 5],
|
||||
'trigrams': ['AAA', 'BBB', 'CCC', 'DDD', 'EEE', 'FFF',
|
||||
'GGG', 'HHH', 'III', 'JJJ', 'KKK', 'LLL']})
|
||||
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
|
||||
@ -113,7 +113,7 @@ class TestDraw(TestCase):
|
||||
resp = await communicator.receive_json_from()
|
||||
self.assertEqual(resp['type'], 'alert')
|
||||
self.assertEqual(resp['alert_type'], 'danger')
|
||||
self.assertEqual(resp['message'], "The draw is already started.")
|
||||
self.assertEqual(resp['message'], "Le tirage a déjà commencé.")
|
||||
|
||||
draw: Draw = await Draw.objects.prefetch_related(
|
||||
'current_round__current_pool__current_team__participation__team').aget(tournament=self.tournament)
|
||||
@ -135,7 +135,7 @@ class TestDraw(TestCase):
|
||||
await communicator.send_json_to({'tid': tid, 'type': "dice", 'trigram': team.trigram})
|
||||
resp = await communicator.receive_json_from()
|
||||
self.assertEqual(resp['type'], 'alert')
|
||||
self.assertEqual(resp['message'], "You've already launched the dice.")
|
||||
self.assertEqual(resp['message'], "Vous avez déjà lancé le dé.")
|
||||
|
||||
# Force exactly one duplicate
|
||||
await td.arefresh_from_db()
|
||||
@ -181,8 +181,8 @@ class TestDraw(TestCase):
|
||||
.aget(number=1, draw=draw)
|
||||
p = r.current_pool
|
||||
self.assertEqual(p.letter, 1)
|
||||
self.assertEqual(p.size, 5)
|
||||
self.assertEqual(await p.teamdraw_set.acount(), 5)
|
||||
self.assertEqual(p.size, 3)
|
||||
self.assertEqual(await p.teamdraw_set.acount(), 3)
|
||||
self.assertEqual(p.current_team, None)
|
||||
|
||||
# Render page
|
||||
@ -207,7 +207,7 @@ class TestDraw(TestCase):
|
||||
await communicator.send_json_to({'tid': tid, 'type': "dice", 'trigram': trigram})
|
||||
resp = await communicator.receive_json_from()
|
||||
self.assertEqual(resp['type'], 'alert')
|
||||
self.assertEqual(resp['message'], "You've already launched the dice.")
|
||||
self.assertEqual(resp['message'], "Vous avez déjà lancé le dé.")
|
||||
|
||||
# Force exactly one duplicate
|
||||
await td.arefresh_from_db()
|
||||
@ -254,7 +254,7 @@ class TestDraw(TestCase):
|
||||
await communicator.send_json_to({'tid': tid, 'type': 'dice', 'trigram': None})
|
||||
resp = await communicator.receive_json_from()
|
||||
self.assertEqual(resp['type'], 'alert')
|
||||
self.assertEqual(resp['message'], "This is not the time for this.")
|
||||
self.assertEqual(resp['message'], "Ce n'est pas le moment pour cela.")
|
||||
|
||||
# Draw a problem
|
||||
await communicator.send_json_to({'tid': tid, 'type': 'draw_problem'})
|
||||
@ -277,7 +277,7 @@ class TestDraw(TestCase):
|
||||
await communicator.send_json_to({'tid': tid, 'type': 'draw_problem'})
|
||||
resp = await communicator.receive_json_from()
|
||||
self.assertEqual(resp['type'], 'alert')
|
||||
self.assertEqual(resp['message'], "This is not the time for this.")
|
||||
self.assertEqual(resp['message'], "Ce n'est pas le moment pour cela.")
|
||||
|
||||
# Reject the first problem
|
||||
await communicator.send_json_to({'tid': tid, 'type': 'reject'})
|
||||
@ -292,7 +292,7 @@ class TestDraw(TestCase):
|
||||
self.assertIsNone(td.purposed)
|
||||
self.assertEqual(td.rejected, [purposed])
|
||||
|
||||
for i in range(4):
|
||||
for i in range(2):
|
||||
# Next team
|
||||
p: Pool = await Pool.objects.prefetch_related('current_team__participation__team').aget(round=r, letter=1)
|
||||
td = p.current_team
|
||||
@ -411,8 +411,6 @@ class TestDraw(TestCase):
|
||||
td: TeamDraw = await TeamDraw.objects.prefetch_related('participation__team').aget(pk=td.pk)
|
||||
self.assertIsNone(td.purposed)
|
||||
|
||||
# Reorder the pool since there are 5 teams
|
||||
self.assertEqual((await communicator.receive_json_from())['type'], 'reorder_poule')
|
||||
self.assertEqual(await communicator.receive_json_from(),
|
||||
{'tid': tid, 'type': 'dice_visibility', 'visible': True})
|
||||
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
|
||||
@ -510,8 +508,8 @@ class TestDraw(TestCase):
|
||||
.aget(number=1, draw=draw)
|
||||
p = r.current_pool
|
||||
self.assertEqual(p.letter, 3)
|
||||
self.assertEqual(p.size, 3)
|
||||
self.assertEqual(await p.teamdraw_set.acount(), 3)
|
||||
self.assertEqual(p.size, 5)
|
||||
self.assertEqual(await p.teamdraw_set.acount(), 5)
|
||||
self.assertEqual(p.current_team, None)
|
||||
self.assertEqual(await communicator.receive_json_from(),
|
||||
{'tid': tid, 'type': 'set_active', 'round': 1, 'poule': 'C', 'team': None})
|
||||
@ -532,7 +530,7 @@ class TestDraw(TestCase):
|
||||
|
||||
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
|
||||
|
||||
for i in range(3):
|
||||
for i in range(5):
|
||||
# Next team
|
||||
p: Pool = await Pool.objects.prefetch_related('current_team__participation__team').aget(round=r, letter=3)
|
||||
td = p.current_team
|
||||
@ -562,10 +560,11 @@ class TestDraw(TestCase):
|
||||
self.assertIsNotNone(td.purposed)
|
||||
self.assertIn(td.purposed, range(1, len(settings.PROBLEMS) + 1))
|
||||
# Lower problems are already accepted
|
||||
self.assertGreaterEqual(td.purposed, i + 1)
|
||||
self.assertGreaterEqual(td.purposed, 1 + i // 2)
|
||||
|
||||
# Assume that this is the problem is i for the team i
|
||||
td.purposed = i + 1
|
||||
# Assume that this is the problem is i / 2 for the team i (there are 5 teams)
|
||||
# We force to have duplicates
|
||||
td.purposed = 1 + i // 2
|
||||
await td.asave()
|
||||
|
||||
# Render page
|
||||
@ -577,11 +576,11 @@ class TestDraw(TestCase):
|
||||
self.assertEqual(await communicator.receive_json_from(),
|
||||
{'tid': tid, 'type': 'buttons_visibility', 'visible': False})
|
||||
self.assertEqual(await communicator.receive_json_from(),
|
||||
{'tid': tid, 'type': 'set_problem', 'round': 1, 'team': trigram, 'problem': i + 1})
|
||||
{'tid': tid, 'type': 'set_problem', 'round': 1, 'team': trigram, 'problem': 1 + i // 2})
|
||||
td: TeamDraw = await TeamDraw.objects.prefetch_related('participation__team').aget(pk=td.pk)
|
||||
self.assertIsNone(td.purposed)
|
||||
self.assertEqual(td.accepted, i + 1)
|
||||
if i == 2:
|
||||
self.assertEqual(td.accepted, 1 + i // 2)
|
||||
if i == 4:
|
||||
break
|
||||
self.assertEqual(await communicator.receive_json_from(),
|
||||
{'tid': tid, 'type': 'box_visibility', 'visible': True})
|
||||
@ -591,6 +590,9 @@ class TestDraw(TestCase):
|
||||
resp = await self.async_client.get(reverse('draw:index'))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# Reorder the pool since there are 5 teams
|
||||
self.assertEqual((await communicator.receive_json_from())['type'], 'reorder_poule')
|
||||
|
||||
# Start round 2
|
||||
draw: Draw = await Draw.objects.prefetch_related(
|
||||
'current_round__current_pool__current_team__participation__team').aget(tournament=self.tournament)
|
||||
@ -624,7 +626,7 @@ class TestDraw(TestCase):
|
||||
.aget(draw=draw, number=2)
|
||||
p = r.current_pool
|
||||
self.assertEqual(p.letter, i + 1)
|
||||
self.assertEqual(p.size, 5 - i)
|
||||
self.assertEqual(p.size, i + 3)
|
||||
|
||||
self.assertEqual(await communicator.receive_json_from(),
|
||||
{'tid': tid, 'type': 'set_active', 'round': 2, 'poule': chr(65 + i), 'team': None})
|
||||
@ -642,7 +644,7 @@ class TestDraw(TestCase):
|
||||
resp = await communicator.receive_json_from()
|
||||
self.assertEqual(resp['type'], 'set_info')
|
||||
|
||||
for j in range(5 - i):
|
||||
for j in range(3 + i):
|
||||
# Next team
|
||||
p: Pool = await Pool.objects.prefetch_related('current_team__participation__team').aget(round=r,
|
||||
letter=i + 1)
|
||||
@ -685,13 +687,13 @@ class TestDraw(TestCase):
|
||||
self.assertEqual((await communicator.receive_json_from())['type'], 'set_problem')
|
||||
td: TeamDraw = await TeamDraw.objects.prefetch_related('participation__team').aget(pk=td.pk)
|
||||
self.assertIsNone(td.purposed)
|
||||
if j == 4 - i:
|
||||
if j == 2 + i:
|
||||
break
|
||||
self.assertEqual(await communicator.receive_json_from(),
|
||||
{'tid': tid, 'type': 'box_visibility', 'visible': True})
|
||||
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
|
||||
|
||||
if i == 0:
|
||||
if i == 2:
|
||||
# Reorder the pool since there are 5 teams
|
||||
self.assertEqual((await communicator.receive_json_from())['type'], 'reorder_poule')
|
||||
if i < 2:
|
||||
@ -738,20 +740,20 @@ class TestDraw(TestCase):
|
||||
draw = Draw.objects.create(tournament=self.tournament)
|
||||
r1 = Round.objects.create(draw=draw, number=1)
|
||||
r2 = Round.objects.create(draw=draw, number=2)
|
||||
p11 = Pool.objects.create(round=r1, letter=1, size=5)
|
||||
p11 = Pool.objects.create(round=r1, letter=1, size=3)
|
||||
p12 = Pool.objects.create(round=r1, letter=2, size=4)
|
||||
p13 = Pool.objects.create(round=r1, letter=3, size=3)
|
||||
p21 = Pool.objects.create(round=r2, letter=1, size=5)
|
||||
p13 = Pool.objects.create(round=r1, letter=3, size=5)
|
||||
p21 = Pool.objects.create(round=r2, letter=1, size=3)
|
||||
p22 = Pool.objects.create(round=r2, letter=2, size=4)
|
||||
p23 = Pool.objects.create(round=r2, letter=3, size=3)
|
||||
p23 = Pool.objects.create(round=r2, letter=3, size=5)
|
||||
tds = []
|
||||
for i, team in enumerate(self.teams):
|
||||
tds.append(TeamDraw.objects.create(participation=team.participation,
|
||||
round=r1,
|
||||
pool=p11 if i < 5 else p12 if i < 9 else p13))
|
||||
pool=p11 if i < 3 else p12 if i < 7 else p13))
|
||||
tds.append(TeamDraw.objects.create(participation=team.participation,
|
||||
round=r2,
|
||||
pool=p21) if i < 5 else p22 if i < 9 else p23)
|
||||
pool=p21) if i < 3 else p22 if i < 7 else p23)
|
||||
|
||||
p11.current_team = tds[0]
|
||||
p11.save()
|
||||
|
@ -40,4 +40,7 @@ class DisplayView(LoginRequiredMixin, TemplateView):
|
||||
context['tournaments_simplified'] = [{'id': t.id, 'name': t.name} for t in tournaments]
|
||||
context['problems'] = settings.PROBLEMS
|
||||
|
||||
context['range_100'] = range(0, 100, 10)
|
||||
context['range_10'] = range(0, 10, 1)
|
||||
|
||||
return context
|
||||
|
@ -3,8 +3,8 @@
|
||||
crond -l 0
|
||||
|
||||
python manage.py migrate
|
||||
python manage.py loaddata initial
|
||||
python manage.py update_index
|
||||
python manage.py runmailer_pg &
|
||||
|
||||
nginx
|
||||
|
||||
|
@ -1,10 +1,79 @@
|
||||
# Copyright (C) 2020 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament, Tweak
|
||||
from .models import Note, Participation, Passage, Pool, Solution, Team, Tournament, Tweak, WrittenReview
|
||||
|
||||
|
||||
class ParticipationInline(admin.StackedInline):
|
||||
model = Participation
|
||||
extra = 0
|
||||
autocomplete_fields = ('team', 'tournament',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class ParticipationTabularInline(admin.TabularInline):
|
||||
model = Participation
|
||||
extra = 0
|
||||
fields = ('team', 'valid', 'final',)
|
||||
readonly_fields = ('team',)
|
||||
ordering = ('final', 'valid', 'team__trigram',)
|
||||
autocomplete_fields = ('tournament',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class SolutionInline(admin.TabularInline):
|
||||
model = Solution
|
||||
extra = 0
|
||||
ordering = ('problem',)
|
||||
autocomplete_fields = ('participation',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class WrittenReviewInline(admin.TabularInline):
|
||||
model = WrittenReview
|
||||
extra = 0
|
||||
ordering = ('passage__solution_number', 'type',)
|
||||
autocomplete_fields = ('passage',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class PoolInline(admin.TabularInline):
|
||||
model = Pool
|
||||
extra = 0
|
||||
autocomplete_fields = ('tournament', 'participations', 'jury_president', 'juries',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class PassageInline(admin.TabularInline):
|
||||
model = Passage
|
||||
extra = 0
|
||||
ordering = ('position',)
|
||||
show_change_link = True
|
||||
|
||||
def get_autocomplete_fields(self, request: HttpRequest) -> tuple[str]:
|
||||
fields = ('reporter', 'opponent', 'reviewer',)
|
||||
if settings.HAS_OBSERVER:
|
||||
fields += ('observer',)
|
||||
return fields
|
||||
|
||||
|
||||
class NoteInline(admin.TabularInline):
|
||||
model = Note
|
||||
extra = 0
|
||||
autocomplete_fields = ('jury',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class TweakInline(admin.TabularInline):
|
||||
model = Tweak
|
||||
extra = 0
|
||||
autocomplete_fields = ('participation', 'pool',)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
@admin.register(Team)
|
||||
@ -12,6 +81,7 @@ class TeamAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'trigram', 'tournament', 'valid', 'final',)
|
||||
search_fields = ('name', 'trigram',)
|
||||
list_filter = ('participation__valid', 'participation__tournament', 'participation__final',)
|
||||
inlines = (ParticipationInline,)
|
||||
|
||||
@admin.display(description=_("tournament"))
|
||||
def tournament(self, record):
|
||||
@ -30,16 +100,18 @@ class TeamAdmin(admin.ModelAdmin):
|
||||
class ParticipationAdmin(admin.ModelAdmin):
|
||||
list_display = ('team', 'tournament', 'valid', 'final',)
|
||||
search_fields = ('team__name', 'team__trigram',)
|
||||
list_filter = ('valid',)
|
||||
list_filter = ('valid', 'tournament',)
|
||||
autocomplete_fields = ('team', 'tournament',)
|
||||
inlines = (SolutionInline, WrittenReviewInline,)
|
||||
|
||||
|
||||
@admin.register(Pool)
|
||||
class PoolAdmin(admin.ModelAdmin):
|
||||
list_display = ('__str__', 'tournament', 'round', 'letter', 'teams',)
|
||||
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', 'juries',)
|
||||
autocomplete_fields = ('tournament', 'participations', 'jury_president', 'juries',)
|
||||
inlines = (PassageInline, TweakInline,)
|
||||
|
||||
@admin.display(description=_("teams"))
|
||||
def teams(self, record: Pool):
|
||||
@ -48,46 +120,73 @@ class PoolAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Passage)
|
||||
class PassageAdmin(admin.ModelAdmin):
|
||||
list_display = ('__str__', 'defender_trigram', 'solution_number', 'opponent_trigram', 'reporter_trigram',
|
||||
'pool_abbr', 'tournament')
|
||||
list_filter = ('pool__tournament', 'pool__round', 'pool__letter', 'solution_number',)
|
||||
search_fields = ('pool__participations__team__name', 'pool__participations__team__trigram',)
|
||||
autocomplete_fields = ('pool', 'defender', 'opponent', 'reporter', 'observer',)
|
||||
ordering = ('pool__tournament', 'pool__round', 'pool__letter', 'position',)
|
||||
inlines = (NoteInline,)
|
||||
|
||||
@admin.display(description=_("defender"))
|
||||
def defender_trigram(self, record: Passage):
|
||||
return record.defender.team.trigram
|
||||
|
||||
@admin.display(description=_("opponent"))
|
||||
def opponent_trigram(self, record: Passage):
|
||||
return record.opponent.team.trigram
|
||||
|
||||
@admin.display(description=_("reporter"))
|
||||
@admin.display(description=_("reporter"), ordering='reporter__team__trigram')
|
||||
def reporter_trigram(self, record: Passage):
|
||||
return record.reporter.team.trigram
|
||||
|
||||
@admin.display(description=_("pool"))
|
||||
def pool_abbr(self, record):
|
||||
return f"{record.pool.get_letter_display()}{record.pool.round}"
|
||||
@admin.display(description=_("opponent"), ordering='opponent__team__trigram')
|
||||
def opponent_trigram(self, record: Passage):
|
||||
return record.opponent.team.trigram
|
||||
|
||||
@admin.display(description=_("tournament"))
|
||||
@admin.display(description=_("reviewer"), ordering='reviewer__team__trigram')
|
||||
def reviewer_trigram(self, record: Passage):
|
||||
return record.reviewer.team.trigram
|
||||
|
||||
@admin.display(description=_("observer"), ordering='observer__team__trigram')
|
||||
def observer_trigram(self, record: Passage):
|
||||
return record.observer.team.trigram if record.observer else None
|
||||
|
||||
@admin.display(description=_("pool"), ordering='pool__letter')
|
||||
def pool_abbr(self, record):
|
||||
return f"{record.pool.short_name}"
|
||||
|
||||
@admin.display(description=_("tournament"), ordering='pool__tournament__name')
|
||||
def tournament(self, record: Passage):
|
||||
return record.pool.tournament
|
||||
|
||||
def get_list_display(self, request: HttpRequest) -> tuple[str]:
|
||||
if settings.HAS_OBSERVER:
|
||||
return ('__str__', 'reporter_trigram', 'solution_number', 'opponent_trigram',
|
||||
'reviewer_trigram', 'observer_trigram', 'pool_abbr', 'position', 'tournament')
|
||||
else:
|
||||
return ('__str__', 'reporter_trigram', 'solution_number', 'opponent_trigram',
|
||||
'reviewer_trigram', 'pool_abbr', 'position', 'tournament')
|
||||
|
||||
def get_autocomplete_fields(self, request: HttpRequest) -> tuple[str]:
|
||||
fields = ('pool', 'reporter', 'opponent', 'reviewer',)
|
||||
if settings.HAS_OBSERVER:
|
||||
fields += ('observer',)
|
||||
return fields
|
||||
|
||||
|
||||
@admin.register(Note)
|
||||
class NoteAdmin(admin.ModelAdmin):
|
||||
list_display = ('passage', 'pool', 'jury', 'defender_writing', 'defender_oral',
|
||||
'opponent_writing', 'opponent_oral', 'reporter_writing', 'reporter_oral',)
|
||||
list_filter = ('passage__pool__letter', 'passage__solution_number', 'jury',
|
||||
'defender_writing', 'defender_oral', 'opponent_writing', 'opponent_oral',
|
||||
'reporter_writing', 'reporter_oral')
|
||||
search_fields = ('jury__user__last_name', 'jury__user__first_name', 'passage__defender__team__trigram',)
|
||||
search_fields = ('jury__user__last_name', 'jury__user__first_name', 'passage__reporter__team__trigram',)
|
||||
autocomplete_fields = ('jury', 'passage',)
|
||||
|
||||
@admin.display(description=_("pool"))
|
||||
def pool(self, record):
|
||||
return record.passage.pool.get_letter_display()
|
||||
return record.passage.pool.short_name
|
||||
|
||||
def get_list_display(self, request: HttpRequest) -> tuple[str]:
|
||||
fields = ('passage', 'pool', 'jury', 'reporter_writing', 'reporter_oral',
|
||||
'opponent_writing', 'opponent_oral', 'reviewer_writing', 'reviewer_oral',)
|
||||
if settings.HAS_OBSERVER:
|
||||
fields += ('observer_writing', 'observer_oral',)
|
||||
return fields
|
||||
|
||||
def get_list_filter(self, request: HttpRequest) -> tuple[str]:
|
||||
fields = ('passage__pool__letter', 'passage__solution_number', 'jury',
|
||||
'reporter_writing', 'reporter_oral', 'opponent_writing', 'opponent_oral',
|
||||
'reviewer_writing', 'reviewer_oral',)
|
||||
if settings.HAS_OBSERVER:
|
||||
fields += ('observer_writing', 'observer_oral',)
|
||||
return fields
|
||||
|
||||
|
||||
@admin.register(Solution)
|
||||
@ -106,30 +205,34 @@ class SolutionAdmin(admin.ModelAdmin):
|
||||
return Tournament.final_tournament() if record.final_solution else record.participation.tournament
|
||||
|
||||
|
||||
@admin.register(Synthesis)
|
||||
class SynthesisAdmin(admin.ModelAdmin):
|
||||
list_display = ('participation', 'type', 'defender', 'passage',)
|
||||
@admin.register(WrittenReview)
|
||||
class WrittenReviewAdmin(admin.ModelAdmin):
|
||||
list_display = ('participation', 'type', 'reporter', 'passage',)
|
||||
list_filter = ('participation__tournament', 'type', 'passage__solution_number',)
|
||||
search_fields = ('participation__team__name', 'participation__team__trigram',)
|
||||
autocomplete_fields = ('participation', 'passage',)
|
||||
|
||||
@admin.display(description=_("defender"))
|
||||
def defender(self, record: Synthesis):
|
||||
return record.passage.defender
|
||||
@admin.display(description=_("reporter"))
|
||||
def reporter(self, record: WrittenReview):
|
||||
return record.passage.reporter
|
||||
|
||||
@admin.display(description=_("problem"))
|
||||
def problem(self, record: Synthesis):
|
||||
def problem(self, record: WrittenReview):
|
||||
return record.passage.solution_number
|
||||
|
||||
|
||||
@admin.register(Tournament)
|
||||
class TournamentAdmin(admin.ModelAdmin):
|
||||
list_display = ('name',)
|
||||
list_display = ('name', 'date_start', 'date_end',)
|
||||
search_fields = ('name',)
|
||||
ordering = ('date_start', 'name',)
|
||||
autocomplete_fields = ('organizers',)
|
||||
inlines = (ParticipationTabularInline, PoolInline,)
|
||||
|
||||
|
||||
@admin.register(Tweak)
|
||||
class TweakAdmin(admin.ModelAdmin):
|
||||
list_display = ('participation', 'pool', 'diff',)
|
||||
list_filter = ('pool__tournament', 'pool__round',)
|
||||
search_fields = ('participation__team__name', 'participation__team__trigram',)
|
||||
autocomplete_fields = ('participation', 'pool',)
|
||||
|
@ -3,7 +3,7 @@
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from ..models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament
|
||||
from ..models import Note, Participation, Passage, Pool, Solution, Team, Tournament, WrittenReview
|
||||
|
||||
|
||||
class NoteSerializer(serializers.ModelSerializer):
|
||||
@ -38,9 +38,9 @@ class SolutionSerializer(serializers.ModelSerializer):
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class SynthesisSerializer(serializers.ModelSerializer):
|
||||
class WrittenReviewSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Synthesis
|
||||
model = WrittenReview
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
@ -58,6 +58,13 @@ class TournamentSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Tournament
|
||||
fields = ('id', 'pk', 'name', 'date_start', 'date_end', 'place', 'max_teams', 'price', 'remote',
|
||||
'inscription_limit', 'solution_limit', 'solutions_draw', 'syntheses_first_phase_limit',
|
||||
'solutions_available_second_phase', 'syntheses_second_phase_limit',
|
||||
'inscription_limit', 'solution_limit', 'solutions_draw', 'reviews_first_phase_limit',
|
||||
'solutions_available_second_phase', 'reviews_second_phase_limit',
|
||||
'solutions_available_third_phase', 'reviews_third_phase_limit',
|
||||
'description', 'organizers', 'final', 'participations',)
|
||||
|
||||
|
||||
class TweakSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = '__all__'
|
||||
|
@ -2,7 +2,7 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from .views import NoteViewSet, ParticipationViewSet, PassageViewSet, PoolViewSet, \
|
||||
SolutionViewSet, SynthesisViewSet, TeamViewSet, TournamentViewSet
|
||||
SolutionViewSet, TeamViewSet, TournamentViewSet, TweakViewSet, WrittenReviewViewSet
|
||||
|
||||
|
||||
def register_participation_urls(router, path):
|
||||
@ -13,7 +13,8 @@ def register_participation_urls(router, path):
|
||||
router.register(path + "/participation", ParticipationViewSet)
|
||||
router.register(path + "/passage", PassageViewSet)
|
||||
router.register(path + "/pool", PoolViewSet)
|
||||
router.register(path + "/review", WrittenReviewViewSet)
|
||||
router.register(path + "/solution", SolutionViewSet)
|
||||
router.register(path + "/synthesis", SynthesisViewSet)
|
||||
router.register(path + "/team", TeamViewSet)
|
||||
router.register(path + "/tournament", TournamentViewSet)
|
||||
router.register(path + "/tweak", TweakViewSet)
|
||||
|
@ -4,16 +4,16 @@ from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from .serializers import NoteSerializer, ParticipationSerializer, PassageSerializer, PoolSerializer, \
|
||||
SolutionSerializer, SynthesisSerializer, TeamSerializer, TournamentSerializer
|
||||
from ..models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament
|
||||
SolutionSerializer, TeamSerializer, TournamentSerializer, TweakSerializer, WrittenReviewSerializer
|
||||
from ..models import Note, Participation, Passage, Pool, Solution, Team, Tournament, Tweak, WrittenReview
|
||||
|
||||
|
||||
class NoteViewSet(ModelViewSet):
|
||||
queryset = Note.objects.all()
|
||||
serializer_class = NoteSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = ['jury', 'passage', 'defender_writing', 'defender_oral', 'opponent_writing',
|
||||
'opponent_oral', 'reporter_writing', 'reporter_oral', ]
|
||||
filterset_fields = ['jury', 'passage', 'reporter_writing', 'reporter_oral', 'opponent_writing',
|
||||
'opponent_oral', 'reviewer_writing', 'reviewer_oral', 'observer_writing', 'observer_oral', ]
|
||||
|
||||
|
||||
class ParticipationViewSet(ModelViewSet):
|
||||
@ -27,7 +27,7 @@ class PassageViewSet(ModelViewSet):
|
||||
queryset = Passage.objects.all()
|
||||
serializer_class = PassageSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = ['pool', 'solution_number', 'defender', 'opponent', 'reporter', 'pool_tournament', ]
|
||||
filterset_fields = ['pool', 'solution_number', 'reporter', 'opponent', 'reviewer', 'observer', 'pool_tournament', ]
|
||||
|
||||
|
||||
class PoolViewSet(ModelViewSet):
|
||||
@ -44,9 +44,9 @@ class SolutionViewSet(ModelViewSet):
|
||||
filterset_fields = ['participation', 'number', 'problem', 'final_solution', ]
|
||||
|
||||
|
||||
class SynthesisViewSet(ModelViewSet):
|
||||
queryset = Synthesis.objects.all()
|
||||
serializer_class = SynthesisSerializer
|
||||
class WrittenReviewViewSet(ModelViewSet):
|
||||
queryset = WrittenReview.objects.all()
|
||||
serializer_class = WrittenReviewSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = ['participation', 'number', 'passage', 'type', ]
|
||||
|
||||
@ -64,6 +64,15 @@ class TournamentViewSet(ModelViewSet):
|
||||
serializer_class = TournamentSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = ['name', 'date_start', 'date_end', 'place', 'max_teams', 'price', 'remote',
|
||||
'inscription_limit', 'solution_limit', 'solutions_draw', 'syntheses_first_phase_limit',
|
||||
'solutions_available_second_phase', 'syntheses_second_phase_limit',
|
||||
'inscription_limit', 'solution_limit', 'solutions_draw', 'reviews_first_phase_limit',
|
||||
'solutions_available_second_phase', 'reviews_second_phase_limit',
|
||||
'solutions_available_third_phase', 'reviews_third_phase_limit',
|
||||
'description', 'organizers', 'final', ]
|
||||
|
||||
|
||||
class TweakViewSet(ModelViewSet):
|
||||
queryset = Tweak.objects.all()
|
||||
serializer_class = TweakSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = ['pool', 'pool__tournament', 'pool__tournament__name', 'participation',
|
||||
'participation__team__trigram', 'diff', ]
|
||||
|
@ -3,6 +3,7 @@
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.db.models.signals import post_save, pre_save
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class ParticipationConfig(AppConfig):
|
||||
@ -10,10 +11,12 @@ class ParticipationConfig(AppConfig):
|
||||
The participation app contains the data about the teams, solutions, ...
|
||||
"""
|
||||
name = 'participation'
|
||||
verbose_name = _("participations")
|
||||
|
||||
def ready(self):
|
||||
from participation.signals import create_notes, create_team_participation, update_mailing_list
|
||||
pre_save.connect(update_mailing_list, "participation.Team")
|
||||
post_save.connect(create_team_participation, "participation.Team")
|
||||
post_save.connect(create_notes, "participation.Passage")
|
||||
post_save.connect(create_notes, "participation.Pool")
|
||||
from participation import signals
|
||||
pre_save.connect(signals.update_mailing_list, "participation.Team")
|
||||
post_save.connect(signals.create_team_participation, "participation.Team")
|
||||
post_save.connect(signals.create_payments, "participation.Participation")
|
||||
post_save.connect(signals.create_notes, "participation.Passage")
|
||||
post_save.connect(signals.create_notes, "participation.Pool")
|
||||
|
@ -1,24 +1,22 @@
|
||||
# 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.bootstrap import InlineField
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Div, Fieldset, Submit
|
||||
from crispy_forms.layout import Div, Field, HTML, Layout, Submit
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import FileExtensionValidator
|
||||
from django.db.models import CharField, Value
|
||||
from django.db.models.functions import Concat
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
import pandas
|
||||
from pypdf import PdfReader
|
||||
from registration.models import VolunteerRegistration
|
||||
|
||||
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament
|
||||
from .models import Note, Participation, Passage, Pool, Solution, Team, Tournament, WrittenReview
|
||||
|
||||
|
||||
class TeamForm(forms.ModelForm):
|
||||
@ -55,6 +53,10 @@ class JoinTeamForm(forms.ModelForm):
|
||||
access_code = self.cleaned_data["access_code"]
|
||||
if not Team.objects.filter(access_code=access_code).exists():
|
||||
raise ValidationError(_("No team was found with this access code."))
|
||||
else:
|
||||
team = Team.objects.get(access_code=access_code)
|
||||
if team.participation.valid is not None:
|
||||
raise ValidationError(_("The team is already validated or the validation is pending."))
|
||||
return access_code
|
||||
|
||||
def clean(self):
|
||||
@ -73,13 +75,40 @@ class ParticipationForm(forms.ModelForm):
|
||||
"""
|
||||
Form to update the problem of a team participation.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if settings.SINGLE_TOURNAMENT:
|
||||
del self.fields['tournament']
|
||||
self.helper = FormHelper()
|
||||
idf_warning_banner = f"""
|
||||
<div class=\"alert alert-warning\">
|
||||
<h5 class=\"alert-heading\">{_("IMPORTANT")}</h4>
|
||||
{_("""For the tournaments in the region "Île-de-France": registration is
|
||||
unified for each tournament. By choosing a tournament "Île-de-France",
|
||||
you're accepting that your team may be selected for one of these tournaments.
|
||||
In case of date conflict, please write them in your motivation letter.""")}
|
||||
</div>
|
||||
"""
|
||||
unified_registration_tournament_ids = ",".join(
|
||||
str(tournament.id) for tournament in Tournament.objects.filter(
|
||||
unified_registration=True).all())
|
||||
self.helper.layout = Layout(
|
||||
'tournament',
|
||||
Div(
|
||||
HTML(idf_warning_banner),
|
||||
css_id="idf_warning_banner",
|
||||
data_tid_unified=unified_registration_tournament_ids,
|
||||
),
|
||||
'final',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Participation
|
||||
fields = ('tournament', 'final',)
|
||||
|
||||
|
||||
class MotivationLetterForm(forms.ModelForm):
|
||||
def clean_file(self):
|
||||
def clean_motivation_letter(self):
|
||||
if "motivation_letter" in self.files:
|
||||
file = self.files["motivation_letter"]
|
||||
if file.size > 2e6:
|
||||
@ -103,7 +132,7 @@ class RequestValidationForm(forms.Form):
|
||||
)
|
||||
|
||||
engagement = forms.BooleanField(
|
||||
label=_("I engage myself to participate to the whole TFJM²."),
|
||||
label=_("I engage myself to participate to the whole tournament."),
|
||||
required=True,
|
||||
)
|
||||
|
||||
@ -124,20 +153,32 @@ class ValidateParticipationForm(forms.Form):
|
||||
|
||||
|
||||
class TournamentForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if settings.NB_ROUNDS < 3:
|
||||
del self.fields['date_third_phase']
|
||||
del self.fields['solutions_available_third_phase']
|
||||
del self.fields['reviews_third_phase_limit']
|
||||
if not settings.PAYMENT_MANAGEMENT:
|
||||
del self.fields['price']
|
||||
|
||||
class Meta:
|
||||
model = Tournament
|
||||
fields = '__all__'
|
||||
exclude = ('notes_sheet_id', )
|
||||
widgets = {
|
||||
'date_start': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
|
||||
'date_end': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
|
||||
'inscription_limit': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%d %H:%M'),
|
||||
'solution_limit': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%d %H:%M'),
|
||||
'solutions_draw': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%d %H:%M'),
|
||||
'syntheses_first_phase_limit': forms.DateTimeInput(attrs={'type': 'datetime-local'},
|
||||
'date_first_phase': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
|
||||
'reviews_first_phase_limit': forms.DateTimeInput(attrs={'type': 'datetime-local'},
|
||||
format='%Y-%m-%d %H:%M'),
|
||||
'solutions_available_second_phase': forms.DateTimeInput(attrs={'type': 'datetime-local'},
|
||||
'date_second_phase': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
|
||||
'reviews_second_phase_limit': forms.DateTimeInput(attrs={'type': 'datetime-local'},
|
||||
format='%Y-%m-%d %H:%M'),
|
||||
'syntheses_second_phase_limit': forms.DateTimeInput(attrs={'type': 'datetime-local'},
|
||||
'date_third_phase': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
|
||||
'reviews_third_phase_limit': forms.DateTimeInput(attrs={'type': 'datetime-local'},
|
||||
format='%Y-%m-%d %H:%M'),
|
||||
'organizers': forms.SelectMultiple(attrs={
|
||||
'class': 'selectpicker',
|
||||
@ -175,8 +216,13 @@ class SolutionForm(forms.ModelForm):
|
||||
class PoolForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Pool
|
||||
fields = ('tournament', 'round', 'letter', 'bbb_url', 'results_available', 'juries',)
|
||||
fields = ('tournament', 'round', 'letter', 'bbb_url', 'results_available', 'jury_president', 'juries',)
|
||||
widgets = {
|
||||
"jury_president": forms.Select(attrs={
|
||||
'class': 'selectpicker',
|
||||
'data-live-search': 'true',
|
||||
'data-live-search-normalize': 'true',
|
||||
}),
|
||||
"juries": forms.SelectMultiple(attrs={
|
||||
'class': 'selectpicker',
|
||||
'data-live-search': 'true',
|
||||
@ -185,47 +231,31 @@ class PoolForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class PoolTeamsForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["participations"].queryset = self.instance.tournament.participations.all()
|
||||
|
||||
class Meta:
|
||||
model = Pool
|
||||
fields = ('participations',)
|
||||
widgets = {
|
||||
"participations": forms.SelectMultiple(attrs={
|
||||
'class': 'selectpicker',
|
||||
'data-live-search': 'true',
|
||||
'data-live-search-normalize': 'true',
|
||||
'data-width': 'fit',
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
class AddJuryForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['first_name'].required = True
|
||||
self.fields['last_name'].required = True
|
||||
self.fields['email'].required = True
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_class = 'form-inline'
|
||||
self.helper.layout = Fieldset(
|
||||
_("Add new jury"),
|
||||
self.helper.layout = Div(
|
||||
Div(
|
||||
Div(
|
||||
InlineField('first_name', autofocus="autofocus"),
|
||||
css_class='col-xl-3',
|
||||
Field('email', autofocus="autofocus", list="juries-email"),
|
||||
css_class='col-md-5 px-1',
|
||||
),
|
||||
Div(
|
||||
InlineField('last_name'),
|
||||
css_class='col-xl-3',
|
||||
Field('first_name', list="juries-first-name"),
|
||||
css_class='col-md-3 px-1',
|
||||
),
|
||||
Div(
|
||||
InlineField('email'),
|
||||
css_class='col-xl-5',
|
||||
Field('last_name', list="juries-last-name"),
|
||||
css_class='col-md-3 px-1',
|
||||
),
|
||||
Div(
|
||||
Submit('submit', _("Add")),
|
||||
css_class='col-xl-1',
|
||||
css_class='col-md-1 py-md-4 px-1',
|
||||
),
|
||||
css_class='row',
|
||||
)
|
||||
@ -237,7 +267,10 @@ class AddJuryForm(forms.ModelForm):
|
||||
"""
|
||||
email = self.data["email"]
|
||||
if User.objects.filter(email=email).exists():
|
||||
self.add_error("email", _("This email address is already used."))
|
||||
self.instance = User.objects.get(email=email)
|
||||
if self.instance.registration.participates:
|
||||
self.add_error(None, _("This user already exists, but is a participant."))
|
||||
return
|
||||
return email
|
||||
|
||||
class Meta:
|
||||
@ -247,19 +280,20 @@ 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']
|
||||
if file.name.endswith('.csv'):
|
||||
with file:
|
||||
try:
|
||||
data: bytes = file.read()
|
||||
@ -268,58 +302,61 @@ class UploadNotesForm(forms.Form):
|
||||
except UnicodeDecodeError:
|
||||
# This is not UTF-8, grrrr
|
||||
content = data.decode('latin1')
|
||||
csvfile = csv.reader(StringIO(content))
|
||||
self.process(csvfile, cleaned_data)
|
||||
|
||||
table = pandas.read_csv(StringIO(content), sep=None, header=None)
|
||||
self.process(table, 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."))
|
||||
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 = [1 + 6 * 3, 1 + 7 * 4, 1 + 6 * 5] # Per pool sizes
|
||||
pool_size = 0
|
||||
line_length = 0
|
||||
for line in csvfile:
|
||||
line = [s.strip() for s in line if s]
|
||||
if line and line[0] == 'Problème':
|
||||
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] in ["Problème", "Problem"]:
|
||||
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 + (8 if df.iat[1, 8] == "Observer" else 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",
|
||||
"role", "juree", "average", "coefficient", "subtotal", "team"]:
|
||||
continue
|
||||
notes = line[1:line_length]
|
||||
notes = line[2:line_length]
|
||||
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))
|
||||
|
||||
max_notes = pool_size * ([20, 16, 9, 10, 9, 10] + ([4] if pool_size == 4 else []))
|
||||
max_notes = pool_size * [20 if settings.TFJM_APP == "TFJM" else 10,
|
||||
20 if settings.TFJM_APP == "TFJM" else 10,
|
||||
10, 10, 10, 10, 10, 10]
|
||||
for n, max_n in zip(notes, max_notes):
|
||||
if n > max_n:
|
||||
self.add_error('file',
|
||||
_("The following note is higher of the maximum expected value:")
|
||||
+ str(n) + " > " + str(max_n))
|
||||
|
||||
# Search by "{first_name} {last_name}"
|
||||
jury = User.objects.annotate(full_name=Concat('first_name', Value(' '), 'last_name',
|
||||
output_field=CharField())) \
|
||||
.filter(full_name=name.replace('’', '\''), registration__volunteerregistration__isnull=False)
|
||||
# Search by volunteer id
|
||||
jury = VolunteerRegistration.objects.filter(pk=int(float(line[1])))
|
||||
if jury.count() != 1:
|
||||
self.add_error('file', _("The following user was not found:") + " " + name)
|
||||
continue
|
||||
raise ValidationError({'file': _("The following user was not found:") + " " + name})
|
||||
jury = jury.get()
|
||||
parsed_notes[jury] = notes
|
||||
|
||||
vr = jury.registration
|
||||
parsed_notes[vr] = notes
|
||||
print(parsed_notes)
|
||||
|
||||
cleaned_data['parsed_notes'] = parsed_notes
|
||||
|
||||
@ -329,21 +366,21 @@ class UploadNotesForm(forms.Form):
|
||||
class PassageForm(forms.ModelForm):
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if "defender" in cleaned_data and "opponent" in cleaned_data and "reporter" in cleaned_data \
|
||||
and len({cleaned_data["defender"], cleaned_data["opponent"], cleaned_data["reporter"]}) < 3:
|
||||
self.add_error(None, _("The defender, the opponent and the reporter must be different."))
|
||||
if "defender" in self.cleaned_data and "solution_number" in self.cleaned_data \
|
||||
and not Solution.objects.filter(participation=cleaned_data["defender"],
|
||||
if "reporter" in cleaned_data and "opponent" in cleaned_data and "reviewer" in cleaned_data \
|
||||
and len({cleaned_data["reporter"], cleaned_data["opponent"], cleaned_data["reviewer"]}) < 3:
|
||||
self.add_error(None, _("The reporter, the opponent and the reviewer must be different."))
|
||||
if "reporter" in self.cleaned_data and "solution_number" in self.cleaned_data \
|
||||
and not Solution.objects.filter(participation=cleaned_data["reporter"],
|
||||
problem=cleaned_data["solution_number"]).exists():
|
||||
self.add_error("solution_number", _("This defender did not work on this problem."))
|
||||
self.add_error("solution_number", _("This reporter did not work on this problem."))
|
||||
return cleaned_data
|
||||
|
||||
class Meta:
|
||||
model = Passage
|
||||
fields = ('position', 'solution_number', 'defender', 'opponent', 'reporter', 'observer', 'defender_penalties',)
|
||||
fields = ('position', 'solution_number', 'reporter', 'opponent', 'reviewer', 'opponent', 'reporter_penalties',)
|
||||
|
||||
|
||||
class SynthesisForm(forms.ModelForm):
|
||||
class WrittenReviewForm(forms.ModelForm):
|
||||
def clean_file(self):
|
||||
if "file" in self.files:
|
||||
file = self.files["file"]
|
||||
@ -359,16 +396,22 @@ class SynthesisForm(forms.ModelForm):
|
||||
|
||||
def save(self, commit=True):
|
||||
"""
|
||||
Don't save a synthesis with this way. Use a view instead
|
||||
Don't save a written review with this way. Use a view instead
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = Synthesis
|
||||
model = WrittenReview
|
||||
fields = ('file',)
|
||||
|
||||
|
||||
class NoteForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not settings.HAS_OBSERVER:
|
||||
del self.fields['observer_writing']
|
||||
del self.fields['observer_oral']
|
||||
|
||||
class Meta:
|
||||
model = Note
|
||||
fields = ('defender_writing', 'defender_oral', 'opponent_writing',
|
||||
'opponent_oral', 'reporter_writing', 'reporter_oral', 'observer_oral', )
|
||||
fields = ('reporter_writing', 'reporter_oral', 'opponent_writing',
|
||||
'opponent_oral', 'reviewer_writing', 'reviewer_oral', 'observer_writing', 'observer_oral', )
|
||||
|
@ -1,87 +0,0 @@
|
||||
# Copyright (C) 2020 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import os
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.management import BaseCommand
|
||||
from django.db.models import Q
|
||||
import requests
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def handle(self, *args, **options): # noqa: C901
|
||||
# Get access token
|
||||
response = requests.post('https://api.helloasso.com/oauth2/token', headers={
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}, data={
|
||||
'client_id': os.getenv('HELLOASSO_CLIENT_ID', ''),
|
||||
'client_secret': os.getenv('HELLOASSO_CLIENT_SECRET', ''),
|
||||
'grant_type': 'client_credentials',
|
||||
}).json()
|
||||
|
||||
token = response['access_token']
|
||||
|
||||
organization = "animath"
|
||||
form_slug = "tfjm-2023-tournois-regionaux"
|
||||
from_date = "2000-01-01"
|
||||
url = f"https://api.helloasso.com/v5/organizations/{organization}/forms/Event/{form_slug}/payments" \
|
||||
f"?from={from_date}&pageIndex=1&pageSize=100&retrieveOfflineDonations=false"
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
}
|
||||
http_response = requests.get(url, headers=headers)
|
||||
response = http_response.json()
|
||||
|
||||
if http_response.status_code != 200:
|
||||
message = response["message"]
|
||||
self.stderr.write(f"Error while querying Hello Asso: {message}")
|
||||
return
|
||||
|
||||
for payment in response["data"]:
|
||||
if payment["state"] != "Authorized":
|
||||
continue
|
||||
|
||||
payer = payment["payer"]
|
||||
email = payer["email"]
|
||||
last_name = payer["lastName"]
|
||||
first_name = payer["firstName"]
|
||||
base_filter = Q(
|
||||
registration__participantregistration__isnull=False,
|
||||
registration__participantregistration__team__isnull=False,
|
||||
registration__participantregistration__team__participation__valid=True,
|
||||
)
|
||||
qs = User.objects.filter(
|
||||
base_filter,
|
||||
email=email,
|
||||
)
|
||||
if not qs.exists():
|
||||
qs = User.objects.filter(
|
||||
base_filter,
|
||||
last_name__icontains=last_name,
|
||||
)
|
||||
if qs.count() >= 2:
|
||||
qs = qs.filter(first_name__icontains=first_name)
|
||||
if not qs.exists():
|
||||
self.stderr.write(f"Warning: a payment was found by {first_name} {last_name} ({email}), "
|
||||
"but this user is unknown.")
|
||||
continue
|
||||
if qs.count() > 1:
|
||||
self.stderr.write(f"Warning: a payment was found by {first_name} {last_name} ({email}), "
|
||||
f"but there are {qs.count()} matching users.")
|
||||
continue
|
||||
user = qs.get()
|
||||
if not user.registration.participates:
|
||||
self.stderr.write(f"Warning: a payment was found by the email address {email}, "
|
||||
"but this user is not a participant.")
|
||||
continue
|
||||
payment_obj = user.registration.payment
|
||||
payment_obj.valid = True
|
||||
payment_obj.type = "helloasso"
|
||||
payment_obj.additional_information = f"Identifiant de transation : {payment['id']}\n" \
|
||||
f"Date : {payment['date']}\n" \
|
||||
f"Reçu : {payment['paymentReceiptUrl']}\n" \
|
||||
f"Montant : {payment['amount'] / 100:.2f} €"
|
||||
payment_obj.save()
|
||||
self.stdout.write(f"{payment_obj} is validated")
|
@ -1,6 +1,7 @@
|
||||
# Copyright (C) 2021 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import BaseCommand
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import activate
|
||||
@ -9,7 +10,7 @@ from participation.models import Tournament
|
||||
|
||||
class Command(BaseCommand):
|
||||
def handle(self, *args, **kwargs):
|
||||
activate('fr')
|
||||
activate(settings.PREFERRED_LANGUAGE_CODE)
|
||||
|
||||
tournaments = Tournament.objects.order_by('-date_start', 'name')
|
||||
for tournament in tournaments:
|
||||
@ -17,8 +18,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 +41,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 +53,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>")
|
||||
|
@ -11,10 +11,12 @@ from participation.models import Solution, Tournament
|
||||
|
||||
class Command(BaseCommand):
|
||||
def handle(self, *args, **kwargs):
|
||||
activate('fr')
|
||||
|
||||
activate(settings.PREFERRED_LANGUAGE_CODE)
|
||||
base_dir = Path(__file__).parent.parent.parent.parent
|
||||
base_dir /= "output"
|
||||
if not base_dir.is_dir():
|
||||
base_dir.mkdir()
|
||||
base_dir /= "solutions"
|
||||
if not base_dir.is_dir():
|
||||
base_dir.mkdir()
|
||||
base_dir /= "Par équipe"
|
||||
|
@ -1,8 +1,9 @@
|
||||
# Copyright (C) 2020 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import BaseCommand
|
||||
from django.db.models import Q
|
||||
from django.template.defaultfilters import slugify
|
||||
from participation.models import Team, Tournament
|
||||
from registration.models import ParticipantRegistration, VolunteerRegistration
|
||||
from tfjm.lists import get_sympa_client
|
||||
@ -13,6 +14,9 @@ class Command(BaseCommand):
|
||||
"""
|
||||
Create Sympa mailing lists and register teams.
|
||||
"""
|
||||
if not settings.ML_MANAGEMENT:
|
||||
return
|
||||
|
||||
sympa = get_sympa_client()
|
||||
|
||||
sympa.create_list("equipes", "Equipes du TFJM2", "hotline",
|
||||
@ -22,18 +26,18 @@ class Command(BaseCommand):
|
||||
"Liste de diffusion pour contacter toutes les equipes non validees du TFJM2.",
|
||||
"education", raise_error=False)
|
||||
|
||||
sympa.create_list("admins", "Administrateurs du TFJM2", "hotline",
|
||||
"Liste de diffusion pour contacter tous les administrateurs du TFJM2.",
|
||||
sympa.create_list("admins", "Administrateur⋅rices du TFJM2", "hotline",
|
||||
"Liste de diffusion pour contacter toustes les administrateur.rices du TFJM2.",
|
||||
"education", raise_error=False)
|
||||
sympa.create_list("organisateurs", "Organisateurs du TFJM2", "hotline",
|
||||
"Liste de diffusion pour contacter tous les organisateurs du TFJM2.",
|
||||
"Liste de diffusion pour contacter toustes les organisateur.rices du TFJM2.",
|
||||
"education", raise_error=False)
|
||||
sympa.create_list("jurys", "Jurys du TFJM2", "hotline",
|
||||
"Liste de diffusion pour contacter tous les jurys du TFJM2.",
|
||||
"education", raise_error=False)
|
||||
|
||||
for tournament in Tournament.objects.all():
|
||||
slug = tournament.name.lower().replace(" ", "-")
|
||||
slug = slugify(tournament.name)
|
||||
sympa.create_list(f"equipes-{slug}", f"Equipes du tournoi {tournament.name}", "hotline",
|
||||
f"Liste de diffusion pour contacter toutes les equipes du tournoi {tournament.name}"
|
||||
" du TFJM2.", "education", raise_error=False)
|
||||
@ -51,7 +55,7 @@ class Command(BaseCommand):
|
||||
for team in Team.objects.filter(participation__valid=True).all():
|
||||
team.create_mailing_list()
|
||||
sympa.unsubscribe(team.email, "equipes-non-valides", True)
|
||||
sympa.subscribe(team.email, f"equipes-{team.participation.tournament.name.lower().replace(' ', '-')}",
|
||||
sympa.subscribe(team.email, f"equipes-{slugify(team.participation.tournament.name)}",
|
||||
True, f"Equipe {team.name}")
|
||||
|
||||
for team in Team.objects.filter(Q(participation__valid=False) | Q(participation__valid__isnull=True)).all():
|
||||
@ -59,16 +63,16 @@ class Command(BaseCommand):
|
||||
sympa.subscribe(team.email, "equipes-non-valides", True, f"Equipe {team.name}")
|
||||
|
||||
for participant in ParticipantRegistration.objects.filter(team__isnull=False).all():
|
||||
sympa.subscribe(participant.user.email, f"equipe-{participant.team.trigram.lower()}",
|
||||
sympa.subscribe(participant.user.email, f"equipe-{slugify(participant.team.trigram)}",
|
||||
True, f"{participant}")
|
||||
|
||||
for volunteer in VolunteerRegistration.objects.all():
|
||||
for organized_tournament in volunteer.organized_tournaments.all():
|
||||
slug = organized_tournament.name.lower().replace(" ", "-")
|
||||
slug = slugify(organized_tournament.name)
|
||||
sympa.subscribe(volunteer.user.email, f"organisateurs-{slug}", True)
|
||||
|
||||
for jury_in in volunteer.jury_in.all():
|
||||
slug = jury_in.tournament.name.lower().replace(" ", "-")
|
||||
slug = slugify(jury_in.tournament.name)
|
||||
sympa.subscribe(volunteer.user.email, f"jurys-{slug}", True)
|
||||
|
||||
for admin in VolunteerRegistration.objects.filter(admin=True).all():
|
||||
|
149
participation/management/commands/generate_seconds_sheet.py
Normal file
@ -0,0 +1,149 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import BaseCommand
|
||||
from django.utils.translation import activate
|
||||
import gspread
|
||||
from gspread.utils import a1_range_to_grid_range, MergeType
|
||||
|
||||
from ...models import Passage, Tournament
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
activate(settings.PREFERRED_LANGUAGE_CODE)
|
||||
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()
|
||||
reporter_passage_1 = Passage.objects.get(pool__tournament=tournament, pool__round=1, reporter=team2)
|
||||
opponent_passage_1 = Passage.objects.get(pool__tournament=tournament, pool__round=1, opponent=team2)
|
||||
reviewer_passage_1 = Passage.objects.get(pool__tournament=tournament, pool__round=1, reviewer=team2)
|
||||
pool2 = tournament.pools.filter(round=2, participations=team2).first()
|
||||
reporter_passage_2 = Passage.objects.get(pool__tournament=tournament, pool__round=2, reporter=team2)
|
||||
opponent_passage_2 = Passage.objects.get(pool__tournament=tournament, pool__round=2, opponent=team2)
|
||||
reviewer_passage_2 = Passage.objects.get(pool__tournament=tournament, pool__round=2, reviewer=team2)
|
||||
|
||||
line.append(team2.team.trigram)
|
||||
line.append(str(pool1.jury_president or ""))
|
||||
line.append(f"Pb. {reporter_passage_1.solution_number}")
|
||||
line.extend([reporter_passage_1.average_reporter_writing, reporter_passage_1.average_reporter_oral,
|
||||
opponent_passage_1.average_opponent_writing, opponent_passage_1.average_opponent_oral,
|
||||
reviewer_passage_1.average_reviewer_writing, reviewer_passage_1.average_reviewer_oral])
|
||||
line.append(str(pool2.jury_president or ""))
|
||||
line.append(f"Pb. {reporter_passage_2.solution_number}")
|
||||
line.extend([reporter_passage_2.average_reporter_writing, reporter_passage_2.average_reporter_oral,
|
||||
opponent_passage_2.average_opponent_writing, opponent_passage_2.average_opponent_oral,
|
||||
reviewer_passage_2.average_reviewer_writing, reviewer_passage_2.average_reviewer_oral])
|
||||
line.extend([score2, f"{score1:.1f} ({team1.team.trigram})",
|
||||
f"{score3:.1f} ({team3.team.trigram})"])
|
||||
|
||||
sheet.update(lines)
|
||||
|
||||
format_requests = []
|
||||
merge_cells = ["A1:A3", "B1:B3", "C1:J1", "K1:R1", "E2:F2", "G2:H2", "I2:J2", "M2:N2", "O2:P2", "Q2:R2",
|
||||
"C2:C3", "D2:D3", "K2:K3", "L2:L3", "S1:S3", "T1:T3", "U1:U3"]
|
||||
format_requests.append({"unmergeCells": {"range": a1_range_to_grid_range("A1:AF", sheet.id)}})
|
||||
for name in merge_cells:
|
||||
grid_range = a1_range_to_grid_range(name, sheet.id)
|
||||
format_requests.append({"mergeCells": {"mergeType": MergeType.merge_all, "range": grid_range}})
|
||||
|
||||
bold_ranges = [("A1:AF", False), ("A1:U3", True), (f"A4:A{3 + nb_tournaments}", True)]
|
||||
for bold_range, bold in bold_ranges:
|
||||
format_requests.append({
|
||||
"repeatCell": {
|
||||
"range": a1_range_to_grid_range(bold_range, sheet.id),
|
||||
"cell": {"userEnteredFormat": {"textFormat": {"bold": bold}}},
|
||||
"fields": "userEnteredFormat(textFormat)",
|
||||
}
|
||||
})
|
||||
|
||||
border_ranges = [("A1:AF", "0000"),
|
||||
(f"A1:U{3 + nb_tournaments}", "1111")]
|
||||
sides_names = ['top', 'bottom', 'left', 'right']
|
||||
styles = ["NONE", "SOLID", "SOLID_MEDIUM", "SOLID_THICK", "DOUBLE"]
|
||||
for border_range, sides in border_ranges:
|
||||
borders = {}
|
||||
for side_name, side in zip(sides_names, sides):
|
||||
borders[side_name] = {"style": styles[int(side)]}
|
||||
format_requests.append({
|
||||
"repeatCell": {
|
||||
"range": a1_range_to_grid_range(border_range, sheet.id),
|
||||
"cell": {
|
||||
"userEnteredFormat": {
|
||||
"borders": borders,
|
||||
"horizontalAlignment": "CENTER",
|
||||
},
|
||||
},
|
||||
"fields": "userEnteredFormat(borders,horizontalAlignment)",
|
||||
}
|
||||
})
|
||||
|
||||
column_widths = [("A", 120), ("B", 80), ("C", 180), ("D", 80)] + [(chr(ord("E") + i), 60) for i in range(6)]
|
||||
column_widths += [("K", 180), ("L", 80)] + [(chr(ord("M") + i), 60) for i in range(6)]
|
||||
column_widths += [("S", 100), ("T", 120), ("U", 120)]
|
||||
for column, width in column_widths:
|
||||
grid_range = a1_range_to_grid_range(column, sheet.id)
|
||||
format_requests.append({
|
||||
"updateDimensionProperties": {
|
||||
"range": {
|
||||
"sheetId": sheet.id,
|
||||
"dimension": "COLUMNS",
|
||||
"startIndex": grid_range['startColumnIndex'],
|
||||
"endIndex": grid_range['endColumnIndex'],
|
||||
},
|
||||
"properties": {
|
||||
"pixelSize": width,
|
||||
},
|
||||
"fields": "pixelSize",
|
||||
}
|
||||
})
|
||||
|
||||
# Set number format, display only one decimal
|
||||
number_format_ranges = [f"E4:J{3 + nb_tournaments}", f"M4:S{3 + nb_tournaments}"]
|
||||
for number_format_range in number_format_ranges:
|
||||
format_requests.append({
|
||||
"repeatCell": {
|
||||
"range": a1_range_to_grid_range(number_format_range, sheet.id),
|
||||
"cell": {"userEnteredFormat": {"numberFormat": {"type": "NUMBER", "pattern": "0.0"}}},
|
||||
"fields": "userEnteredFormat.numberFormat",
|
||||
}
|
||||
})
|
||||
|
||||
body = {"requests": format_requests}
|
||||
sheet.client.batch_update(spreadsheet.id, body)
|
56
participation/management/commands/parse_notation_sheets.py
Normal file
@ -0,0 +1,56 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
from time import sleep
|
||||
|
||||
from django.core.management import BaseCommand
|
||||
from participation.models import Tournament
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--tournament', '-t', help="Tournament name to update (if not set, all tournaments will be updated)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--round', '-r', type=int, help="Round number to update (if not set, all rounds will be updated)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--letter', '-l', help="Letter of the pool to update (if not set, all pools will be updated)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
tournaments = Tournament.objects.all() if not options['tournament'] \
|
||||
else Tournament.objects.filter(name=options['tournament']).all()
|
||||
|
||||
for tournament in tournaments:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(f"Parsing notation sheet for {tournament}")
|
||||
|
||||
if not tournament.notes_sheet_id:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f"No spreadsheet found for {tournament}. Please create it first"))
|
||||
continue
|
||||
|
||||
pools = tournament.pools.all()
|
||||
if options['round']:
|
||||
pools = pools.filter(round=options['round'])
|
||||
if options['letter']:
|
||||
pools = pools.filter(letter=ord(options['letter']) - 64)
|
||||
for pool in pools.all():
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(f"Parsing notation sheet for pool {pool.short_name} for {tournament}")
|
||||
try:
|
||||
pool.parse_spreadsheet()
|
||||
except Exception as e:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stderr.write(
|
||||
self.style.ERROR(f"Error while parsing pool {pool.short_name} for {tournament.name}: {e}"))
|
||||
finally:
|
||||
sleep(3) # Three calls = 3s sleep
|
||||
|
||||
try:
|
||||
tournament.parse_tweaks_spreadsheets()
|
||||
except Exception as e:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stderr.write(self.style.ERROR(f"Error while parsing tweaks for {tournament.name}: {e}"))
|
@ -0,0 +1,67 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from hashlib import sha1
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.management import BaseCommand
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import localtime
|
||||
import gspread
|
||||
|
||||
from ...models import Tournament
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Création de notifications Google Drive pour récupérer les modifications sur les tableurs de notes.
|
||||
|
||||
Documentation de l'API : https://developers.google.com/calendar/api/guides/push?hl=fr
|
||||
"""
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--tournament', '-t', help="Tournament name to update (if not set, all tournaments will be updated)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
tournaments = Tournament.objects.all() if not options['tournament'] \
|
||||
else Tournament.objects.filter(name=options['tournament']).all()
|
||||
|
||||
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
|
||||
http_client = gc.http_client
|
||||
http_client.login()
|
||||
|
||||
site = Site.objects.get(pk=settings.SITE_ID)
|
||||
|
||||
now = localtime(timezone.now())
|
||||
tomorrow = now + timezone.timedelta(days=1)
|
||||
tomorrow -= timezone.timedelta(hours=now.hour, minutes=now.minute, seconds=now.second,
|
||||
microseconds=now.microsecond)
|
||||
|
||||
for tournament in tournaments:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(f"Renewing Google Drive notifications for {tournament}")
|
||||
|
||||
if not tournament.notes_sheet_id:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f"No spreadsheet found for {tournament}. Please create it first"))
|
||||
continue
|
||||
|
||||
channel_id = sha1(f"{tournament.name}-{now.date()}-{site.domain}".encode()).hexdigest()
|
||||
url = f"https://www.googleapis.com/drive/v3/files/{tournament.notes_sheet_id}/watch?supportsAllDrives=true"
|
||||
notif_path = reverse('participation:tournament_gsheet_notifications', args=[tournament.pk])
|
||||
notif_url = f"https://{site.domain}{notif_path}"
|
||||
body = {
|
||||
"id": channel_id,
|
||||
"type": "web_hook",
|
||||
"address": notif_url,
|
||||
"expiration": str(int(1000 * tomorrow.timestamp())),
|
||||
}
|
||||
try:
|
||||
http_client.request(method="POST", endpoint=url, json=body).raise_for_status()
|
||||
except Exception as e:
|
||||
self.stderr.write(self.style.ERROR(f"Error while renewing notifications for {tournament.name}: {e}"))
|
39
participation/management/commands/update_notation_sheets.py
Normal file
@ -0,0 +1,39 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.core.management import BaseCommand
|
||||
from participation.models import Tournament
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--tournament', '-t', help="Tournament name to update (if not set, all tournaments will be updated)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--round', '-r', type=int, help="Round number to update (if not set, all rounds will be updated)",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--letter', '-l', help="Letter of the pool to update (if not set, all pools will be updated)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
tournaments = Tournament.objects.all() if not options['tournament'] \
|
||||
else Tournament.objects.filter(name=options['tournament']).all()
|
||||
|
||||
for tournament in tournaments:
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(f"Updating notation sheet for {tournament}")
|
||||
tournament.create_spreadsheet()
|
||||
|
||||
pools = tournament.pools.all()
|
||||
if options['round']:
|
||||
pools = pools.filter(round=options['round'])
|
||||
if options['letter']:
|
||||
pools = pools.filter(letter=ord(options['letter']) - 64)
|
||||
for pool in pools.all():
|
||||
if options['verbosity'] >= 1:
|
||||
self.stdout.write(f"Updating notation sheet for pool {pool.short_name} for {tournament}")
|
||||
pool.update_spreadsheet()
|
||||
|
||||
tournament.update_ranking_spreadsheet()
|
27
participation/migrations/0009_pool_jury_president.py
Normal file
@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.0.2 on 2024-03-24 14:31
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("participation", "0008_alter_participation_options"),
|
||||
("registration", "0012_payment_token_alter_payment_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="pool",
|
||||
name="jury_president",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="pools_presided",
|
||||
to="registration.volunteerregistration",
|
||||
verbose_name="president of the jury",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,93 @@
|
||||
# Generated by Django 5.0.3 on 2024-03-29 22:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("participation", "0009_pool_jury_president"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="tournament",
|
||||
name="notes_sheet_id",
|
||||
field=models.CharField(
|
||||
blank=True, default="", max_length=64, verbose_name="Google Sheet ID"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="note",
|
||||
name="defender_oral",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(2, 2),
|
||||
(3, 3),
|
||||
(4, 4),
|
||||
(5, 5),
|
||||
(6, 6),
|
||||
(7, 7),
|
||||
(8, 8),
|
||||
(9, 9),
|
||||
(10, 10),
|
||||
(11, 11),
|
||||
(12, 12),
|
||||
(13, 13),
|
||||
(14, 14),
|
||||
(15, 15),
|
||||
(16, 16),
|
||||
(17, 17),
|
||||
(18, 18),
|
||||
(19, 19),
|
||||
(20, 20),
|
||||
],
|
||||
default=0,
|
||||
verbose_name="defender oral note",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="note",
|
||||
name="opponent_writing",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(2, 2),
|
||||
(3, 3),
|
||||
(4, 4),
|
||||
(5, 5),
|
||||
(6, 6),
|
||||
(7, 7),
|
||||
(8, 8),
|
||||
(9, 9),
|
||||
(10, 10),
|
||||
],
|
||||
default=0,
|
||||
verbose_name="opponent writing note",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="note",
|
||||
name="reporter_writing",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(2, 2),
|
||||
(3, 3),
|
||||
(4, 4),
|
||||
(5, 5),
|
||||
(6, 6),
|
||||
(7, 7),
|
||||
(8, 8),
|
||||
(9, 9),
|
||||
(10, 10),
|
||||
],
|
||||
default=0,
|
||||
verbose_name="reporter writing note",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-16 21:59
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"participation",
|
||||
"0010_tournament_notes_sheet_id_alter_note_defender_oral_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="note",
|
||||
name="observer_oral",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="passage",
|
||||
name="observer",
|
||||
),
|
||||
]
|
@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-16 22:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("participation", "0011_remove_note_observer_oral_remove_passage_observer"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="participation",
|
||||
name="mention",
|
||||
field=models.CharField(
|
||||
blank=True, default="", max_length=255, verbose_name="mention"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="participation",
|
||||
name="mention_final",
|
||||
field=models.CharField(
|
||||
blank=True, default="", max_length=255, verbose_name="mention (final)"
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-17 20:50
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("participation", "0012_participation_mention_participation_mention_final"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="pool",
|
||||
options={
|
||||
"ordering": ("round", "letter", "room"),
|
||||
"verbose_name": "pool",
|
||||
"verbose_name_plural": "pools",
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="pool",
|
||||
name="room",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[(1, "Room 1"), (2, "Room 2")],
|
||||
default=1,
|
||||
help_text="For 5-teams pools only",
|
||||
verbose_name="room",
|
||||
),
|
||||
),
|
||||
]
|
31
participation/migrations/0014_alter_team_trigram.py
Normal file
@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.0.6 on 2024-06-07 12:46
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("participation", "0013_alter_pool_options_pool_room"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="team",
|
||||
name="trigram",
|
||||
field=models.CharField(
|
||||
help_text="The code must be composed of 3 uppercase letters.",
|
||||
max_length=3,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator("^[A-Z]{3}$"),
|
||||
django.core.validators.RegexValidator(
|
||||
"^(?!BIT$|CNO$|CRO$|CUL$|FTG$|FCK$|FUC$|FUK$|FYS$|HIV$|IST$|MST$|KKK$|KYS$|SEX$)",
|
||||
message="This team code is forbidden.",
|
||||
),
|
||||
],
|
||||
verbose_name="code",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,42 @@
|
||||
# Generated by Django 5.0.6 on 2024-06-07 13:51
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("participation", "0014_alter_team_trigram"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="tournament",
|
||||
name="solutions_available_second_phase",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="tournament",
|
||||
name="solutions_available_second_phase",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="check this case when solutions for the second round become available",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="tournament",
|
||||
name="solutions_available_third_phase",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="check this case when solutions for the third round become available",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="tournament",
|
||||
name="syntheses_third_phase_limit",
|
||||
field=models.DateTimeField(
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="limit date to upload the syntheses for the third phase",
|
||||
),
|
||||
)
|
||||
]
|
@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.0.6 on 2024-06-07 14:01
|
||||
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("participation", "0015_tournament_solutions_available_third_phase_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="tournament",
|
||||
name="date_first_phase",
|
||||
field=models.DateField(
|
||||
default=datetime.date.today, verbose_name="first phase date"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="tournament",
|
||||
name="date_second_phase",
|
||||
field=models.DateField(
|
||||
default=datetime.date.today, verbose_name="first second date"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="tournament",
|
||||
name="date_third_phase",
|
||||
field=models.DateField(
|
||||
default=datetime.date.today, verbose_name="third phase date"
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,77 @@
|
||||
# Generated by Django 5.0.6 on 2024-06-13 08:53
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("participation", "0016_tournament_date_first_phase_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="passage",
|
||||
name="solution_number",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(1, "Problem #1"),
|
||||
(2, "Problem #2"),
|
||||
(3, "Problem #3"),
|
||||
(4, "Problem #4"),
|
||||
(5, "Problem #5"),
|
||||
(6, "Problem #6"),
|
||||
(7, "Problem #7"),
|
||||
(8, "Problem #8"),
|
||||
(9, "Problem #9"),
|
||||
(10, "Problem #10"),
|
||||
],
|
||||
verbose_name="defended solution",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="pool",
|
||||
name="round",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[(1, "Round 1"), (2, "Round 2"), (3, "Round 3")],
|
||||
verbose_name="round",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="solution",
|
||||
name="problem",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(1, "Problem #1"),
|
||||
(2, "Problem #2"),
|
||||
(3, "Problem #3"),
|
||||
(4, "Problem #4"),
|
||||
(5, "Problem #5"),
|
||||
(6, "Problem #6"),
|
||||
(7, "Problem #7"),
|
||||
(8, "Problem #8"),
|
||||
(9, "Problem #9"),
|
||||
(10, "Problem #10"),
|
||||
],
|
||||
verbose_name="problem",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="team",
|
||||
name="trigram",
|
||||
field=models.CharField(
|
||||
help_text="The code must be composed of 4 uppercase letters.",
|
||||
max_length=4,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator("^[A-Z]{3}[A-Z]*$"),
|
||||
django.core.validators.RegexValidator(
|
||||
"^(?!BIT$|CNO$|CRO$|CUL$|FTG$|FCK$|FUC$|FUK$|FYS$|HIV$|IST$|MST$|KKK$|KYS$|SEX$)",
|
||||
message="This team code is forbidden.",
|
||||
),
|
||||
],
|
||||
verbose_name="code",
|
||||
),
|
||||
),
|
||||
]
|
91
participation/migrations/0018_rename_reporter_to_reviewer.py
Normal file
@ -0,0 +1,91 @@
|
||||
# Generated by Django 5.0.6 on 2024-07-05 08:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"participation",
|
||||
"0017_alter_passage_solution_number_alter_pool_round_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="note",
|
||||
old_name="reporter_oral",
|
||||
new_name="reviewer_oral",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="note",
|
||||
old_name="reporter_writing",
|
||||
new_name="reviewer_writing",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="passage",
|
||||
old_name="reporter",
|
||||
new_name="reviewer",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="note",
|
||||
name="reviewer_oral",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(2, 2),
|
||||
(3, 3),
|
||||
(4, 4),
|
||||
(5, 5),
|
||||
(6, 6),
|
||||
(7, 7),
|
||||
(8, 8),
|
||||
(9, 9),
|
||||
(10, 10),
|
||||
],
|
||||
default=0,
|
||||
verbose_name="reviewer oral note",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="note",
|
||||
name="reviewer_writing",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(2, 2),
|
||||
(3, 3),
|
||||
(4, 4),
|
||||
(5, 5),
|
||||
(6, 6),
|
||||
(7, 7),
|
||||
(8, 8),
|
||||
(9, 9),
|
||||
(10, 10),
|
||||
],
|
||||
default=0,
|
||||
verbose_name="reviewer writing note",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="passage",
|
||||
name="reviewer",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="+",
|
||||
to="participation.participation",
|
||||
verbose_name="reviewer",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="synthesis",
|
||||
name="type",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[(1, "opponent"), (2, "reviewer")]
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,86 @@
|
||||
# Generated by Django 5.0.6 on 2024-07-05 09:47
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("participation", "0018_rename_reporter_to_reviewer"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="note",
|
||||
name="observer_oral",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(-10, -10),
|
||||
(-9, -9),
|
||||
(-8, -8),
|
||||
(-7, -7),
|
||||
(-6, -6),
|
||||
(-5, -5),
|
||||
(-4, -4),
|
||||
(-3, -3),
|
||||
(-2, -2),
|
||||
(-1, -1),
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(2, 2),
|
||||
(3, 3),
|
||||
(4, 4),
|
||||
(5, 5),
|
||||
(6, 6),
|
||||
(7, 7),
|
||||
(8, 8),
|
||||
(9, 9),
|
||||
(10, 10),
|
||||
],
|
||||
default=0,
|
||||
verbose_name="observer oral note",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="note",
|
||||
name="observer_writing",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(2, 2),
|
||||
(3, 3),
|
||||
(4, 4),
|
||||
(5, 5),
|
||||
(6, 6),
|
||||
(7, 7),
|
||||
(8, 8),
|
||||
(9, 9),
|
||||
(10, 10),
|
||||
],
|
||||
default=0,
|
||||
verbose_name="observer writing note",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="passage",
|
||||
name="observer",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to="participation.participation",
|
||||
verbose_name="observer",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="synthesis",
|
||||
name="type",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[(1, "opponent"), (2, "reviewer"), (3, "observer")]
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,75 @@
|
||||
# Generated by Django 5.0.6 on 2024-07-06 19:19
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("participation", "0019_note_observer_oral_note_observer_writing_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameModel(
|
||||
old_name="Synthesis",
|
||||
new_name="WrittenReview",
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="writtenreview",
|
||||
options={
|
||||
"ordering": ("passage__pool__round", "type"),
|
||||
"verbose_name": "written review",
|
||||
"verbose_name_plural": "written reviews",
|
||||
},
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="tournament",
|
||||
old_name="syntheses_first_phase_limit",
|
||||
new_name="reviews_first_phase_limit",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="tournament",
|
||||
old_name="syntheses_second_phase_limit",
|
||||
new_name="reviews_second_phase_limit",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="tournament",
|
||||
old_name="syntheses_third_phase_limit",
|
||||
new_name="reviews_third_phase_limit",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="tournament",
|
||||
name="reviews_first_phase_limit",
|
||||
field=models.DateTimeField(
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="limit date to upload the written reviews for the first phase",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="tournament",
|
||||
name="reviews_second_phase_limit",
|
||||
field=models.DateTimeField(
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="limit date to upload the written reviews for the second phase",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="tournament",
|
||||
name="reviews_third_phase_limit",
|
||||
field=models.DateTimeField(
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="limit date to upload the written reviews for the third phase",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="writtenreview",
|
||||
name="passage",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="written_reviews",
|
||||
to="participation.passage",
|
||||
verbose_name="passage",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,133 @@
|
||||
# Generated by Django 5.0.6 on 2024-07-06 20:00
|
||||
import django
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("participation", "0020_rename_synthesis_writtenreview_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="note",
|
||||
old_name="defender_oral",
|
||||
new_name="reporter_oral",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="note",
|
||||
old_name="defender_writing",
|
||||
new_name="reporter_writing",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="passage",
|
||||
old_name="defender",
|
||||
new_name="reporter",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="passage",
|
||||
old_name="defender_penalties",
|
||||
new_name="reporter_penalties",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="passage",
|
||||
name="solution_number",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(1, "Problem #1"),
|
||||
(2, "Problem #2"),
|
||||
(3, "Problem #3"),
|
||||
(4, "Problem #4"),
|
||||
(5, "Problem #5"),
|
||||
(6, "Problem #6"),
|
||||
(7, "Problem #7"),
|
||||
(8, "Problem #8"),
|
||||
(9, "Problem #9"),
|
||||
(10, "Problem #10"),
|
||||
],
|
||||
verbose_name="reported solution",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="note",
|
||||
name="reporter_oral",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(2, 2),
|
||||
(3, 3),
|
||||
(4, 4),
|
||||
(5, 5),
|
||||
(6, 6),
|
||||
(7, 7),
|
||||
(8, 8),
|
||||
(9, 9),
|
||||
(10, 10),
|
||||
(11, 11),
|
||||
(12, 12),
|
||||
(13, 13),
|
||||
(14, 14),
|
||||
(15, 15),
|
||||
(16, 16),
|
||||
(17, 17),
|
||||
(18, 18),
|
||||
(19, 19),
|
||||
(20, 20),
|
||||
],
|
||||
default=0,
|
||||
verbose_name="reporter oral note",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="note",
|
||||
name="reporter_writing",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(2, 2),
|
||||
(3, 3),
|
||||
(4, 4),
|
||||
(5, 5),
|
||||
(6, 6),
|
||||
(7, 7),
|
||||
(8, 8),
|
||||
(9, 9),
|
||||
(10, 10),
|
||||
(11, 11),
|
||||
(12, 12),
|
||||
(13, 13),
|
||||
(14, 14),
|
||||
(15, 15),
|
||||
(16, 16),
|
||||
(17, 17),
|
||||
(18, 18),
|
||||
(19, 19),
|
||||
(20, 20),
|
||||
],
|
||||
default=0,
|
||||
verbose_name="reporter writing note",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="passage",
|
||||
name="reporter",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="+",
|
||||
to="participation.participation",
|
||||
verbose_name="reporter",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="passage",
|
||||
name="reporter_penalties",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
default=0,
|
||||
help_text="Number of penalties for the reporter. The reporter will loose a 0.5 coefficient per penalty.",
|
||||
verbose_name="penalties",
|
||||
),
|
||||
),
|
||||
]
|
44
participation/migrations/0022_alter_note_observer_oral.py
Normal file
@ -0,0 +1,44 @@
|
||||
# Generated by Django 5.0.6 on 2024-07-11 08:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("participation", "0021_rename_defender_oral_note_reporter_oral_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="note",
|
||||
name="observer_oral",
|
||||
field=models.SmallIntegerField(
|
||||
choices=[
|
||||
(-10, -10),
|
||||
(-9, -9),
|
||||
(-8, -8),
|
||||
(-7, -7),
|
||||
(-6, -6),
|
||||
(-5, -5),
|
||||
(-4, -4),
|
||||
(-3, -3),
|
||||
(-2, -2),
|
||||
(-1, -1),
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(2, 2),
|
||||
(3, 3),
|
||||
(4, 4),
|
||||
(5, 5),
|
||||
(6, 6),
|
||||
(7, 7),
|
||||
(8, 8),
|
||||
(9, 9),
|
||||
(10, 10),
|
||||
],
|
||||
default=0,
|
||||
verbose_name="observer oral note",
|
||||
),
|
||||
),
|
||||
]
|