1
0
mirror of https://gitlab.com/animath/si/plateforme.git synced 2025-02-26 10:26:32 +00:00

Compare commits

...

110 Commits

Author SHA1 Message Date
Emmy D'Anello
2f4755ffc7
Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-10-23 22:02:09 +02:00
Emmy D'Anello
230dc545f4
Fix export scripts
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-20 22:13:51 +02:00
Emmy D'Anello
20daecf619
Syntheses must not exceed 2 pages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-20 17:10:03 +02:00
Emmy D'Anello
3333add7e0
Fix translation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-20 11:45:21 +02:00
Emmy D'Anello
777ae059f9
Non-admin users can't promote themselves to admin users
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-20 11:35:37 +02:00
Emmy D'Anello
310ac70a74
Add ability to fake the draw for admins
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-19 18:24:01 +02:00
Emmy D'Anello
29074c4bfd
Add button to download all solutions and syntheses in a ZIP file
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-19 14:51:52 +02:00
Emmy D'Anello
9bc0e99d6d
Fix the drawing resume for the final
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-11 18:00:32 +02:00
Emmy D'Anello
b38302449c
Don't manage pools of the second day with the dices of the first day since we consider the scores of the first day
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-11 17:28:05 +02:00
Emmy D'Anello
feee5069b1
Add notification when the draw of the final is resumed
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-11 17:15:50 +02:00
Emmy D'Anello
6b962a74b3
Auto-restart the draw socket on close
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-11 17:13:52 +02:00
Emmy D'Anello
0c80385958
Use a unique socket for the drawing system
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-11 17:07:53 +02:00
Emmy D'Anello
8c41684993
Pool tables are not orderable by teams
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-16 09:25:00 +02:00
Emmy D'Anello
8245ba0063
Add Redis Channel Layer for the drawing system
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-12 00:10:17 +02:00
Emmy D'Anello
0e7a275a28
Order participations by validity status and by trigram
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-11 22:46:15 +02:00
Emmy D'Anello
59268f2d1e
Add synthesis sheet template as DOCX format
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-11 22:23:30 +02:00
Emmy D'Anello
2ad7799b38
Fix the display of the draw button
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-11 22:20:15 +02:00
Emmy D'Anello
3b7f2130f3
Check that notes correspond to someone in the jury, and throw an error if this is not the case
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-10 17:38:58 +02:00
Emmy D'Anello
d75c800275
Because django-cas-server forbids Django 4.2, we must do a small trick to allow it. Remove when not necessary anymore
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-10 17:30:11 +02:00
Emmy D'Anello
41e69992c0
Allow ISO-8859-1 encoding is CSV files
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-10 17:26:55 +02:00
Emmy D'Anello
43af14ad77
Search juries by "{first_name} {last_name}"
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-10 17:26:30 +02:00
Emmy D'Anello
acf906b284
Fix draw template
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-10 10:11:32 +02:00
Emmy D'Anello
80f0baac1e
Must be authenticated to upload notes
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-10 10:05:14 +02:00
Emmy D'Anello
3d7a39a593
Only participants in a valid team can see the draw
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-10 10:02:37 +02:00
Emmy D'Anello
a240d7cad5
Better unique validation errors
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-10 09:56:16 +02:00
Emmy D'Anello
b40dce27df
Juries can't download ZIP archives with authorizations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-09 11:37:45 +02:00
Emmy D'Anello
9734b51f53
Test draw application
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-09 00:59:35 +02:00
Emmy D'Anello
80cfe874f5
Only process CSV files when they are correctly read
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-08 17:33:01 +02:00
Emmy D'Anello
bcf4e294e0
Add odfpy in tox
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 22:38:09 +02:00
Emmy D'Anello
a27a115d66
Add observer in the passage admin page
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 22:21:29 +02:00
Emmy D'Anello
6ac36fdb69
Close database connections after 10 seconds (experimental)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 22:02:37 +02:00
Emmy D'Anello
505a94e3aa
Customize the notation sheet template for juries
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 21:47:06 +02:00
Emmy D'Anello
b921ca045e
Process notation sheets when there are 4 or 5 teams
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 13:16:49 +02:00
Emmy D'Anello
a382e089ae
Add observer notes
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 12:10:25 +02:00
Emmy D'Anello
9eed5ca2a0
Add e-mail address on tournament export
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 11:32:47 +02:00
Emmy D'Anello
cbf34fe90e
Add texmf-dist-latexextra package to have more LaTeX packages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 00:33:38 +02:00
Emmy D'Anello
7dc812984b
Add position field for passages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 00:06:21 +02:00
Emmy D'Anello
1ed4e9c17a
Add multiple sheets for 5-teams pools
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-06 23:58:59 +02:00
Emmy D'Anello
5f09c35dee
Add notation sheets templates that are autocompleted with the data
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-06 23:38:59 +02:00
Emmy D'Anello
ae62e3daf7
Reorganize the cancel step code in order to make it more readable
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-06 18:15:14 +02:00
Emmy D'Anello
8778f58fe4
The draw is now fully reversible
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-06 00:19:24 +02:00
Emmy D'Anello
751e35ac62
Cancel draw problem
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 23:28:12 +02:00
Emmy D'Anello
f41b2e16ab
Cancel choose problem
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 19:40:47 +02:00
Emmy D'Anello
1f6ce072bf
Add cancel button to cancel the last step (works for the last problem acceptance for now)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 19:22:48 +02:00
Emmy D'Anello
746aae464a
Add confirmation modal before aborting a draw
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 18:41:28 +02:00
Emmy D'Anello
7e212d011e
Add comments and linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 17:52:46 +02:00
Emmy D'Anello
2840a15fd5
Add form to add juries in a pool
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 16:54:16 +02:00
Emmy D'Anello
c1482d4802
Jury -> Juré⋅e
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 10:59:26 +02:00
Emmy D'Anello
16c4376941
Improve payment admin page
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 10:44:27 +02:00
Emmy D'Anello
dfc45dbc93
A team can't accept a problem that was previously *accepted* not the last purposed
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 21:21:55 +02:00
Emmy D'Anello
31f5373652
Await the send notifications coroutines
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 21:21:00 +02:00
Emmy D'Anello
ca7cf5987c
Try to fix requirements
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 20:02:59 +02:00
Emmy D'Anello
34390a541a
Update translations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 19:57:02 +02:00
Emmy D'Anello
b8b4891e9b
Squash migrations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 19:54:18 +02:00
Emmy D'Anello
9cfab53bd2
Add a lot of comments
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 19:52:44 +02:00
Emmy D'Anello
82cda0b279
Reduce the usage of sync_to_async
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 15:10:28 +02:00
Emmy D'Anello
4357d51b9a
Display problem names
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 11:56:13 +02:00
Emmy D'Anello
90bfc45858
Use the new asave function of Django 4.2
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 11:20:43 +02:00
Emmy D'Anello
bb9f0dab22
Django 4.2 got released
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 11:12:37 +02:00
Emmy D'Anello
b0a248e81a
Fix the transition between the two rounds
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 11:07:08 +02:00
Emmy D'Anello
b3c26b8c1c
Improve admin interface
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:55 +02:00
Emmy D'Anello
073d761a03
Add admin menu
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:55 +02:00
Emmy D'Anello
bd31375bf3
Fix CSV process
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:55 +02:00
Emmy D'Anello
7605b9cc00
Add download link to notation sheets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:55 +02:00
Emmy D'Anello
0fa76d6f25
Add letter in pool display
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:55 +02:00
Emmy D'Anello
14505260ff
Use more complex calculus to mix teams for the second day
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:54 +02:00
Emmy D'Anello
cf8892ee1a
Use separate fields for the two dices
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:54 +02:00
Emmy D'Anello
7f7d921c53
We want to avoid that a team chooses twice a same problem, not to wait an infinite loop
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:54 +02:00
Emmy D'Anello
8668430760
Add reverse-proxy headers
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:53 +02:00
Emmy D'Anello
45818eae24
Add websockets as dependency
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:53 +02:00
Emmy D'Anello
b154c4985d
Fix duplicate problem check
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:53 +02:00
Emmy D'Anello
ac039c1073
Display draw tab only for authenticated users
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:53 +02:00
Emmy D'Anello
3717cd8b3f
Don't import models too soon
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:52 +02:00
Emmy D'Anello
7855ec2225
Fix translation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:52 +02:00
Emmy D'Anello
fbaca32615
Teams can't select a same problem for the two days
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:52 +02:00
Emmy D'Anello
5b1374bf1b
Add link to the drawing interface
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:52 +02:00
Emmy D'Anello
18bd2c7c18
In a 5-teams pool, the order of two teams that present the same problem is random
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:52 +02:00
Emmy D'Anello
a4c7951475
Make all invisible when a draw is aborted
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:51 +02:00
Emmy D'Anello
c299ff6634
Remove Python 3.9 compatibility (I love match/case)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:51 +02:00
Emmy D'Anello
7d8975339e
Add continue button for the final tournament
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:51 +02:00
Emmy D'Anello
1bd9cea458
Fix update notes modal
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:51 +02:00
Emmy D'Anello
b838f1b3f0
Add export button
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:50 +02:00
Emmy D'Anello
e95d511017
Translate messages from websockets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:50 +02:00
Emmy D'Anello
942c96dbfa
Reorder teams for 5-teams pools
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:50 +02:00
Emmy D'Anello
3cd40ee192
Add margins
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:50 +02:00
Emmy D'Anello
cebe977d49
Problems can be accepted or rejected. Draw can go to the end
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:50 +02:00
Emmy D'Anello
e90005b192
Teams can draw a problem
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
Emmy D'Anello
6b5c630048
Add Abort button
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
Emmy D'Anello
c9fcfcf498
Add messages for better understanding
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
Emmy D'Anello
dec9f9be11
Update translations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
Emmy D'Anello
f85a563cf3
Auto-generate tables
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
Emmy D'Anello
5399a875c6
Draw dices
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
Emmy D'Anello
eb8ad4e771
Prepare template for the system
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:48 +02:00
Emmy D'Anello
93a71fb561
Fix errors and better tab usage
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:48 +02:00
Emmy D'Anello
bde3758c50
First interface to start draws
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:48 +02:00
Emmy D'Anello
88823b5252
Update database models and translations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:48 +02:00
Emmy D'Anello
9aa19ad3ca
Fix tests
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:48 +02:00
Emmy D'Anello
ad4593a2f6
Prepare database model
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:47 +02:00
Emmy D'Anello
849194414d
Fix tox
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:47 +02:00
Emmy D'Anello
b9ce4c737c
First play with websockets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:47 +02:00
Emmy D'Anello
30efff0d9d
Don't trigger signals on raw imports
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:47 +02:00
Emmy D'Anello
7364d27b4b
Init new draw application
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:47 +02:00
Emmy D'Anello
19f41152ee
Use Django 4.1 (soon 4.2) to use the new async framework
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:46 +02:00
Emmy D'Anello
f3d611913e
Run ASGI server instead of WSGI
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:46 +02:00
Emmy D'Anello
1d81213773
Move apps in main directory
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:46 +02:00
Emmy D'Anello
2a545dae10
Fix add organizer view
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:33 +02:00
Emmy D'Anello
fc6e2593b4
PdfFileReader is deprecated, replace by PdfReader 2023-03-29 18:34:55 +02:00
Emmy D'Anello
ce25341496
Fix administration tab 2023-03-29 18:33:48 +02:00
Emmy D'Anello
57bddc5628
Fix Update Payment modal
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-03-16 14:37:51 +01:00
Emmy D'Anello
d7b293dc87
2022 -> 2023
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-03-16 14:31:14 +01:00
171 changed files with 7814 additions and 883 deletions

View File

@ -2,14 +2,6 @@ stages:
- test - test
- quality-assurance - quality-assurance
py39:
stage: test
image: python:3.9-alpine
before_script:
- apk add --no-cache libmagic
- pip install tox --no-cache-dir
script: tox -e py39
py310: py310:
stage: test stage: test
image: python:3.10-alpine image: python:3.10-alpine

View File

@ -3,7 +3,7 @@ FROM python:3.11-alpine
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
ENV DJANGO_ALLOW_ASYNC_UNSAFE 1 ENV DJANGO_ALLOW_ASYNC_UNSAFE 1
RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libxml2-dev libxslt-dev postgresql-dev libmagic texlive RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libxml2-dev libxslt-dev postgresql-dev libmagic texlive texmf-dist-latexextra
RUN apk add --no-cache bash RUN apk add --no-cache bash
@ -13,6 +13,8 @@ COPY requirements.txt /code/requirements.txt
COPY docs/requirements.txt /code/docs/requirements.txt COPY docs/requirements.txt /code/docs/requirements.txt
RUN pip install -r requirements.txt --no-cache-dir RUN pip install -r requirements.txt --no-cache-dir
RUN pip install -r docs/requirements.txt --no-cache-dir RUN pip install -r docs/requirements.txt --no-cache-dir
# FIXME Remove this line when all dependencies will be ready
RUN pip install "Django>=4.2,<5.0"
COPY . /code/ COPY . /code/

View File

@ -2,7 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings from django.conf import settings
from django.conf.urls import include, url from django.urls import include, path
from rest_framework import routers from rest_framework import routers
from .viewsets import UserViewSet from .viewsets import UserViewSet
@ -29,6 +29,6 @@ app_name = 'api'
# Wire up our API using automatic URL routing. # Wire up our API using automatic URL routing.
# Additionally, we include login URLs for the browsable API. # Additionally, we include login URLs for the browsable API.
urlpatterns = [ urlpatterns = [
url('^', include(router.urls)), path('', include(router.urls)),
url('^api-auth/', include('rest_framework.urls', namespace='rest_framework')), path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
] ]

View File

@ -1,64 +0,0 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament, Tweak
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ('name', 'trigram', 'valid',)
search_fields = ('name', 'trigram',)
list_filter = ('participation__valid',)
def valid(self, team):
return team.participation.valid
valid.short_description = _('valid')
@admin.register(Participation)
class ParticipationAdmin(admin.ModelAdmin):
list_display = ('team', 'valid',)
search_fields = ('team__name', 'team__trigram',)
list_filter = ('valid',)
@admin.register(Pool)
class PoolAdmin(admin.ModelAdmin):
search_fields = ('participations__team__name', 'participations__team__trigram',)
@admin.register(Passage)
class PassageAdmin(admin.ModelAdmin):
search_fields = ('pool__participations__team__name', 'pool__participations__team__trigram',)
@admin.register(Note)
class NoteAdmin(admin.ModelAdmin):
search_fields = ('jury',)
@admin.register(Solution)
class SolutionAdmin(admin.ModelAdmin):
list_display = ('participation',)
search_fields = ('participation__team__name', 'participation__team__trigram',)
@admin.register(Synthesis)
class SynthesisAdmin(admin.ModelAdmin):
list_display = ('participation',)
search_fields = ('participation__team__name', 'participation__team__trigram',)
@admin.register(Tournament)
class TournamentAdmin(admin.ModelAdmin):
list_display = ('name',)
search_fields = ('name',)
@admin.register(Tweak)
class TweakAdmin(admin.ModelAdmin):
list_display = ('participation', 'pool', 'diff',)

View File

@ -1,37 +0,0 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
from django.contrib.admin import ModelAdmin
from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicParentModelAdmin
from .models import CoachRegistration, Payment, Registration, StudentRegistration, VolunteerRegistration
@admin.register(Registration)
class RegistrationAdmin(PolymorphicParentModelAdmin):
child_models = (StudentRegistration, CoachRegistration, VolunteerRegistration,)
list_display = ("user", "type", "email_confirmed",)
polymorphic_list = True
@admin.register(StudentRegistration)
class StudentRegistrationAdmin(PolymorphicChildModelAdmin):
pass
@admin.register(CoachRegistration)
class CoachRegistrationAdmin(PolymorphicChildModelAdmin):
pass
@admin.register(VolunteerRegistration)
class VolunteerRegistrationAdmin(PolymorphicChildModelAdmin):
pass
@admin.register(Payment)
class PaymentAdmin(ModelAdmin):
list_display = ('registration', 'type', 'valid', )
search_fields = ('registration__user__last_name', 'registration__user__first_name', 'registration__user__email',)
list_filter = ('type', 'valid',)

4
draw/__init__.py Normal file
View File

@ -0,0 +1,4 @@
# Copyright (C) 2023 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'draw.apps.DrawConfig'

62
draw/admin.py Normal file
View File

@ -0,0 +1,62 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Draw, Pool, Round, TeamDraw
@admin.register(Draw)
class DrawAdmin(admin.ModelAdmin):
list_display = ('tournament', 'teams', 'current_round', 'get_state',)
list_filter = ('tournament', 'current_round',)
search_fields = ('tournament__name', 'tournament__participation__team__trigram',)
@admin.display(description=_("teams"))
def teams(self, record: Draw):
return ', '.join(p.team.trigram for p in record.tournament.participations.filter(valid=True).all())
@admin.register(Round)
class RoundAdmin(admin.ModelAdmin):
list_display = ('draw', 'number', 'teams',)
list_filter = ('draw__tournament', 'number',)
search_fields = ('draw__tournament__name', 'pool__teamdraw__participation__team__trigram')
ordering = ('draw__tournament__name', 'number')
@admin.display(description=_("teams"))
def teams(self, record: Round):
return ', '.join(td.participation.team.trigram for td in record.team_draws)
@admin.register(Pool)
class PoolAdmin(admin.ModelAdmin):
list_display = ('tournament', 'round', 'letter', 'teams')
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',)
@admin.display(ordering='round__draw__tournament__name', description=_("tournament"))
def tournament(self, record):
return record.round.draw.tournament
@admin.display(description=_("teams"))
def teams(self, record: Round):
return ', '.join(td.participation.team.trigram for td in record.team_draws)
@admin.register(TeamDraw)
class TeamDrawAdmin(admin.ModelAdmin):
list_display = ('participation', 'tournament', 'view_round', 'pool', 'accepted', 'rejected',
'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',)
@admin.display(ordering='round__draw__tournament__name', description=_("tournament"))
def tournament(self, record):
return record.round.draw.tournament
@admin.display(ordering='round__number', description=_('round'))
def view_round(self, record):
return record.round.get_number_display()

10
draw/apps.py Normal file
View File

@ -0,0 +1,10 @@
# Copyright (C) 2023 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class DrawConfig(AppConfig):
name = 'draw'
verbose_name = _("Draw")

1594
draw/consumers.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,529 @@
# Generated by Django 4.2 on 2023-04-04 17:54
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
("participation", "0005_alter_team_options"),
]
operations = [
migrations.CreateModel(
name="Draw",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"last_message",
models.TextField(
blank=True,
default="",
help_text="The last message that is displayed on the drawing interface.",
verbose_name="last message",
),
),
],
options={
"verbose_name": "draw",
"verbose_name_plural": "draws",
},
),
migrations.CreateModel(
name="Pool",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"letter",
models.PositiveSmallIntegerField(
choices=[(1, "A"), (2, "B"), (3, "C"), (4, "D")],
help_text="The letter of the pool: A, B, C or D.",
verbose_name="letter",
),
),
(
"size",
models.PositiveSmallIntegerField(
help_text="The number of teams in this pool, between 3 and 5.",
validators=[
django.core.validators.MinValueValidator(3),
django.core.validators.MaxValueValidator(5),
],
verbose_name="size",
),
),
(
"associated_pool",
models.OneToOneField(
default=None,
help_text="The full pool instance.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="draw_pool",
to="participation.pool",
verbose_name="associated pool",
),
),
],
options={
"verbose_name": "pool",
"verbose_name_plural": "pools",
"ordering": (
"round__draw__tournament__name",
"round__number",
"letter",
),
},
),
migrations.CreateModel(
name="Round",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"number",
models.PositiveSmallIntegerField(
choices=[(1, "Round 1"), (2, "Round 2")],
help_text="The number of the round, 1 or 2",
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(2),
],
verbose_name="number",
),
),
(
"current_pool",
models.ForeignKey(
default=None,
help_text="The current pool where teams select their problems.",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="draw.pool",
verbose_name="current pool",
),
),
(
"draw",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="draw.draw",
verbose_name="draw",
),
),
],
options={
"verbose_name": "round",
"verbose_name_plural": "rounds",
"ordering": ("draw__tournament__name", "number"),
},
),
migrations.CreateModel(
name="TeamDraw",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"passage_index",
models.PositiveSmallIntegerField(
choices=[(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)],
default=None,
help_text="The passage order in the pool, between 0 and the size of the pool minus 1.",
null=True,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(4),
],
verbose_name="passage index",
),
),
(
"choose_index",
models.PositiveSmallIntegerField(
choices=[(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)],
default=None,
help_text="The choice order in the pool, between 0 and the size of the pool minus 1.",
null=True,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(4),
],
verbose_name="choose index",
),
),
(
"accepted",
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"),
],
default=None,
null=True,
verbose_name="accepted problem",
),
),
(
"passage_dice",
models.PositiveSmallIntegerField(
choices=[
(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),
(21, 21),
(22, 22),
(23, 23),
(24, 24),
(25, 25),
(26, 26),
(27, 27),
(28, 28),
(29, 29),
(30, 30),
(31, 31),
(32, 32),
(33, 33),
(34, 34),
(35, 35),
(36, 36),
(37, 37),
(38, 38),
(39, 39),
(40, 40),
(41, 41),
(42, 42),
(43, 43),
(44, 44),
(45, 45),
(46, 46),
(47, 47),
(48, 48),
(49, 49),
(50, 50),
(51, 51),
(52, 52),
(53, 53),
(54, 54),
(55, 55),
(56, 56),
(57, 57),
(58, 58),
(59, 59),
(60, 60),
(61, 61),
(62, 62),
(63, 63),
(64, 64),
(65, 65),
(66, 66),
(67, 67),
(68, 68),
(69, 69),
(70, 70),
(71, 71),
(72, 72),
(73, 73),
(74, 74),
(75, 75),
(76, 76),
(77, 77),
(78, 78),
(79, 79),
(80, 80),
(81, 81),
(82, 82),
(83, 83),
(84, 84),
(85, 85),
(86, 86),
(87, 87),
(88, 88),
(89, 89),
(90, 90),
(91, 91),
(92, 92),
(93, 93),
(94, 94),
(95, 95),
(96, 96),
(97, 97),
(98, 98),
(99, 99),
(100, 100),
],
default=None,
null=True,
verbose_name="passage dice",
),
),
(
"choice_dice",
models.PositiveSmallIntegerField(
choices=[
(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),
(21, 21),
(22, 22),
(23, 23),
(24, 24),
(25, 25),
(26, 26),
(27, 27),
(28, 28),
(29, 29),
(30, 30),
(31, 31),
(32, 32),
(33, 33),
(34, 34),
(35, 35),
(36, 36),
(37, 37),
(38, 38),
(39, 39),
(40, 40),
(41, 41),
(42, 42),
(43, 43),
(44, 44),
(45, 45),
(46, 46),
(47, 47),
(48, 48),
(49, 49),
(50, 50),
(51, 51),
(52, 52),
(53, 53),
(54, 54),
(55, 55),
(56, 56),
(57, 57),
(58, 58),
(59, 59),
(60, 60),
(61, 61),
(62, 62),
(63, 63),
(64, 64),
(65, 65),
(66, 66),
(67, 67),
(68, 68),
(69, 69),
(70, 70),
(71, 71),
(72, 72),
(73, 73),
(74, 74),
(75, 75),
(76, 76),
(77, 77),
(78, 78),
(79, 79),
(80, 80),
(81, 81),
(82, 82),
(83, 83),
(84, 84),
(85, 85),
(86, 86),
(87, 87),
(88, 88),
(89, 89),
(90, 90),
(91, 91),
(92, 92),
(93, 93),
(94, 94),
(95, 95),
(96, 96),
(97, 97),
(98, 98),
(99, 99),
(100, 100),
],
default=None,
null=True,
verbose_name="choice dice",
),
),
(
"purposed",
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"),
],
default=None,
null=True,
verbose_name="accepted problem",
),
),
(
"rejected",
models.JSONField(default=list, verbose_name="rejected problems"),
),
(
"participation",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="participation.participation",
verbose_name="participation",
),
),
(
"pool",
models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="draw.pool",
verbose_name="pool",
),
),
(
"round",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="draw.round",
verbose_name="round",
),
),
],
options={
"verbose_name": "team draw",
"verbose_name_plural": "team draws",
"ordering": (
"round__draw__tournament__name",
"round__number",
"pool__letter",
"passage_index",
),
},
),
migrations.AddField(
model_name="pool",
name="current_team",
field=models.ForeignKey(
default=None,
help_text="The current team that is selecting its problem.",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="draw.teamdraw",
verbose_name="current team",
),
),
migrations.AddField(
model_name="pool",
name="round",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="draw.round"
),
),
migrations.AddField(
model_name="draw",
name="current_round",
field=models.ForeignKey(
default=None,
help_text="The current round where teams select their problems.",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="draw.round",
verbose_name="current round",
),
),
migrations.AddField(
model_name="draw",
name="tournament",
field=models.OneToOneField(
help_text="The associated tournament.",
on_delete=django.db.models.deletion.CASCADE,
to="participation.tournament",
verbose_name="tournament",
),
),
]

View File

526
draw/models.py Normal file
View File

@ -0,0 +1,526 @@
# Copyright (C) 2023 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from asgiref.sync import sync_to_async
from django.conf import settings
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import QuerySet
from django.urls import reverse_lazy
from django.utils.text import format_lazy, slugify
from django.utils.translation import gettext_lazy as _
from participation.models import Participation, Passage, Pool as PPool, Tournament
class Draw(models.Model):
"""
A draw instance is linked to a :model:`participation.Tournament` and contains all information
about a draw.
"""
tournament = models.OneToOneField(
Tournament,
on_delete=models.CASCADE,
verbose_name=_('tournament'),
help_text=_("The associated tournament.")
)
current_round = models.ForeignKey(
'Round',
on_delete=models.CASCADE,
null=True,
default=None,
related_name='+',
verbose_name=_('current round'),
help_text=_("The current round where teams select their problems."),
)
last_message = models.TextField(
blank=True,
default="",
verbose_name=_("last message"),
help_text=_("The last message that is displayed on the drawing interface.")
)
def get_absolute_url(self):
return reverse_lazy('draw:index') + f'#{slugify(self.tournament.name)}'
@property
def exportable(self) -> bool:
"""
True if any pool of the draw is exportable, ie. can be exported to the tournament interface.
This operation is synchronous.
"""
return any(pool.exportable for r in self.round_set.all() for pool in r.pool_set.all())
async def is_exportable(self) -> bool:
"""
True if any pool of the draw is exportable, ie. can be exported to the tournament interface.
This operation is asynchronous.
"""
return any([await pool.is_exportable() async for r in self.round_set.all() async for pool in r.pool_set.all()])
def get_state(self) -> str:
"""
The current state of the draw.
Can be:
* **DICE_SELECT_POULES** if we are waiting for teams to launch their dice to determine pools and passage order ;
* **DICE_ORDER_POULE** if we are waiting for teams to launch their dice to determine the problem draw order ;
* **WAITING_DRAW_PROBLEM** if we are waiting for a team to draw a problem ;
* **WAITING_CHOOSE_PROBLEM** if we are waiting for a team to accept or reject a problem ;
* **WAITING_FINAL** if this is the final tournament and we are between the two rounds ;
* **DRAW_ENDED** if the draw is ended.
Warning: the current round and the current team must be prefetched in an async context.
"""
if self.current_round.current_pool is None:
return 'DICE_SELECT_POULES'
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:
# The last step can be the last problem acceptation after the first round
# only for the final between the two rounds
return 'WAITING_FINAL'
else:
return 'DRAW_ENDED'
elif self.current_round.current_pool.current_team.purposed is None:
return 'WAITING_DRAW_PROBLEM'
else:
return 'WAITING_CHOOSE_PROBLEM'
@property
def information(self):
"""
The information header on the draw interface, which is defined according to the
current state.
Warning: this property is synchronous.
"""
s = ""
if self.last_message:
s += self.last_message + "<br><br>"
match self.get_state():
case 'DICE_SELECT_POULES':
# 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 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."""
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 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."""
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."""
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>. """
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."""
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."
else:
s += f"Il reste {len(settings.PROBLEMS) - 5 - len(td.rejected)} refus sans pénalité."
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 !"
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 += "<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>."""
return s
async def ainformation(self) -> str:
"""
Asynchronous version to get the information header content.
"""
return await sync_to_async(lambda: self.information)()
def __str__(self):
return str(format_lazy(_("Draw of tournament {tournament}"), tournament=self.tournament.name))
class Meta:
verbose_name = _('draw')
verbose_name_plural = _('draws')
class Round(models.Model):
"""
This model is attached to a :model:`draw.Draw` and represents the draw
for one round of the :model:`participation.Tournament`.
"""
draw = models.ForeignKey(
Draw,
on_delete=models.CASCADE,
verbose_name=_('draw'),
)
number = models.PositiveSmallIntegerField(
choices=[
(1, _('Round 1')),
(2, _('Round 2')),
],
verbose_name=_('number'),
help_text=_("The number of the round, 1 or 2"),
validators=[MinValueValidator(1), MaxValueValidator(2)],
)
current_pool = models.ForeignKey(
'Pool',
on_delete=models.CASCADE,
null=True,
default=None,
related_name='+',
verbose_name=_('current pool'),
help_text=_("The current pool where teams select their problems."),
)
def get_absolute_url(self):
return reverse_lazy('draw:index') + f'#{slugify(self.draw.tournament.name)}'
@property
def team_draws(self) -> QuerySet["TeamDraw"]:
"""
Returns a query set ordered by pool and by passage index of all team draws.
"""
return self.teamdraw_set.order_by('pool__letter', 'passage_index').all()
async def next_pool(self):
"""
Returns the next pool of the round.
For example, after the pool A, we have the pool B.
"""
pool = self.current_pool
return await self.pool_set.aget(letter=pool.letter + 1)
def __str__(self):
return self.get_number_display()
class Meta:
verbose_name = _('round')
verbose_name_plural = _('rounds')
ordering = ('draw__tournament__name', 'number',)
class Pool(models.Model):
"""
A Pool is a collection of teams in a :model:`draw.Round` of a `draw.Draw`.
It has a letter (eg. A, B, C or D) and a size, between 3 and 5.
After the draw, the pool can be exported in a `participation.Pool` instance.
"""
round = models.ForeignKey(
Round,
on_delete=models.CASCADE,
)
letter = models.PositiveSmallIntegerField(
choices=[
(1, 'A'),
(2, 'B'),
(3, 'C'),
(4, 'D'),
],
verbose_name=_('letter'),
help_text=_("The letter of the pool: A, B, C or D."),
)
size = models.PositiveSmallIntegerField(
verbose_name=_('size'),
validators=[MinValueValidator(3), MaxValueValidator(5)],
help_text=_("The number of teams in this pool, between 3 and 5."),
)
current_team = models.ForeignKey(
'TeamDraw',
on_delete=models.CASCADE,
null=True,
default=None,
related_name='+',
verbose_name=_('current team'),
help_text=_("The current team that is selecting its problem."),
)
associated_pool = models.OneToOneField(
'participation.Pool',
on_delete=models.SET_NULL,
null=True,
default=None,
related_name='draw_pool',
verbose_name=_("associated pool"),
help_text=_("The full pool instance."),
)
def get_absolute_url(self):
return reverse_lazy('draw:index') + f'#{slugify(self.round.draw.tournament.name)}'
@property
def team_draws(self) -> QuerySet["TeamDraw"]:
"""
Returns a query set ordered by passage index of all team draws in this pool.
"""
return self.teamdraw_set.order_by('passage_index').all()
@property
def trigrams(self) -> list[str]:
"""
Returns a list of trigrams of the teams in this pool ordered by passage index.
This property is synchronous.
"""
return [td.participation.team.trigram for td in self.teamdraw_set.order_by('passage_index')
.prefetch_related('participation__team').all()]
async def atrigrams(self) -> list[str]:
"""
Returns a list of trigrams of the teams in this pool ordered by passage index.
This property is asynchronous.
"""
return [td.participation.team.trigram async for td in self.teamdraw_set.order_by('passage_index')
.prefetch_related('participation__team').all()]
async def next_td(self) -> "TeamDraw":
"""
Returns the next team draw after the current one, to know who should draw a new problem.
"""
td = self.current_team
current_index = (td.choose_index + 1) % self.size
td = await self.teamdraw_set.prefetch_related('participation__team').aget(choose_index=current_index)
while td.accepted:
# Ignore if the next team already accepted its problem
current_index += 1
current_index %= self.size
td = await self.teamdraw_set.prefetch_related('participation__team').aget(choose_index=current_index)
return td
@property
def exportable(self) -> bool:
"""
True if this pool is exportable, ie. can be exported to the tournament interface. That means that
each team selected its problem.
This operation is synchronous.
"""
return self.associated_pool_id is None and self.teamdraw_set.exists() \
and all(td.accepted is not None for td in self.teamdraw_set.all())
async def is_exportable(self) -> bool:
"""
True if this pool is exportable, ie. can be exported to the tournament interface. That means that
each team selected its problem.
This operation is asynchronous.
"""
return self.associated_pool_id is None and await self.teamdraw_set.aexists() \
and all([td.accepted is not None async for td in self.teamdraw_set.all()])
async def export(self) -> PPool:
"""
Translates this Pool instance in a :model:`participation.Pool` instance, with the passage orders.
"""
# Create the pool
self.associated_pool = await PPool.objects.acreate(
tournament=self.round.draw.tournament,
round=self.round.number,
letter=self.letter,
)
# Define the participations of the pool
tds = [td async for td in self.team_draws.prefetch_related('participation')]
await self.associated_pool.participations.aset([td.participation async for td in self.team_draws
.prefetch_related('participation')])
await self.asave()
# Define the passage matrix according to the number of teams
table = []
if self.size == 3:
table = [
[0, 1, 2],
[1, 2, 0],
[2, 0, 1],
]
elif self.size == 4:
table = [
[0, 1, 2, 3],
[1, 2, 3, 0],
[2, 3, 0, 1],
[3, 0, 1, 2],
]
elif self.size == 5:
table = [
[0, 2, 3],
[1, 3, 4],
[2, 0, 1],
[3, 4, 0],
[4, 1, 2],
]
for i, line in enumerate(table):
# Create the passage
passage = await Passage.objects.acreate(
pool=self.associated_pool,
position=i + 1,
solution_number=tds[line[0]].accepted,
defender=tds[line[0]].participation,
opponent=tds[line[1]].participation,
reporter=tds[line[2]].participation,
defender_penalties=tds[line[0]].penalty_int,
)
if self.size == 4:
# Add observer for 4-teams pools
passage.observer = tds[line[3]].participation
await passage.asave()
return self.associated_pool
def __str__(self):
return str(format_lazy(_("Pool {letter}{number}"), letter=self.get_letter_display(), number=self.round.number))
class Meta:
verbose_name = _('pool')
verbose_name_plural = _('pools')
ordering = ('round__draw__tournament__name', 'round__number', 'letter',)
class TeamDraw(models.Model):
"""
This model represents the state of the draw for a given team, including
its accepted problem or their rejected ones.
"""
participation = models.ForeignKey(
Participation,
on_delete=models.CASCADE,
verbose_name=_('participation'),
)
round = models.ForeignKey(
Round,
on_delete=models.CASCADE,
verbose_name=_('round'),
)
pool = models.ForeignKey(
Pool,
on_delete=models.CASCADE,
null=True,
default=None,
verbose_name=_('pool'),
)
passage_index = models.PositiveSmallIntegerField(
choices=zip(range(0, 5), range(0, 5)),
null=True,
default=None,
verbose_name=_('passage index'),
help_text=_("The passage order in the pool, between 0 and the size of the pool minus 1."),
validators=[MinValueValidator(0), MaxValueValidator(4)],
)
choose_index = models.PositiveSmallIntegerField(
choices=zip(range(0, 5), range(0, 5)),
null=True,
default=None,
verbose_name=_('choose index'),
help_text=_("The choice order in the pool, between 0 and the size of the pool minus 1."),
validators=[MinValueValidator(0), MaxValueValidator(4)],
)
accepted = models.PositiveSmallIntegerField(
choices=[
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, len(settings.PROBLEMS) + 1)
],
null=True,
default=None,
verbose_name=_("accepted problem"),
)
passage_dice = models.PositiveSmallIntegerField(
choices=zip(range(1, 101), range(1, 101)),
null=True,
default=None,
verbose_name=_("passage dice"),
)
choice_dice = models.PositiveSmallIntegerField(
choices=zip(range(1, 101), range(1, 101)),
null=True,
default=None,
verbose_name=_("choice dice"),
)
purposed = models.PositiveSmallIntegerField(
choices=[
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, len(settings.PROBLEMS) + 1)
],
null=True,
default=None,
verbose_name=_("purposed problem"),
)
rejected = models.JSONField(
default=list,
verbose_name=_('rejected problems'),
)
def get_absolute_url(self):
return reverse_lazy('draw:index') + f'#{slugify(self.round.draw.tournament.name)}'
@property
def last_dice(self):
"""
The last dice that was thrown.
"""
return self.passage_dice if self.round.draw.get_state() == 'DICE_SELECT_POULES' else self.choice_dice
@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.
"""
return max(0, len(self.rejected) - (len(settings.PROBLEMS) - 5))
@property
def penalty(self):
"""
The penalty multiplier on the defender oral, which is a malus of 0.5 for each penalty.
"""
return 0.5 * self.penalty_int
def __str__(self):
return str(format_lazy(_("Draw of the team {trigram} for the pool {letter}{number}"),
trigram=self.participation.team.trigram,
letter=self.pool.get_letter_display() if self.pool else "",
number=self.round.number))
class Meta:
verbose_name = _('team draw')
verbose_name_plural = _('team draws')
ordering = ('round__draw__tournament__name', 'round__number', 'pool__letter', 'passage_index',)

10
draw/routing.py Normal file
View File

@ -0,0 +1,10 @@
# 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()),
]

811
draw/static/draw.js Normal file
View File

@ -0,0 +1,811 @@
(async () => {
// check notification permission
// This is useful to alert people that they should do something
await Notification.requestPermission()
})()
const problems_count = JSON.parse(document.getElementById('problems_count').textContent)
const tournaments = JSON.parse(document.getElementById('tournaments_list').textContent)
let socket = null
const messages = document.getElementById('messages')
/**
* Request to abort the draw of the given tournament.
* Only volunteers are allowed to do this.
* @param tid The tournament id
*/
function abortDraw(tid) {
socket.send(JSON.stringify({'tid': tid, 'type': 'abort'}))
}
/**
* Request to cancel the last step.
* Only volunteers are allowed to do this.
* @param tid The tournament id
*/
function cancelLastStep(tid) {
socket.send(JSON.stringify({'tid': tid, 'type': 'cancel'}))
}
/**
* Request to launch a dice between 1 and 100, for the two first steps.
* The parameter `trigram` can be specified (by volunteers) to launch a dice for a specific team.
* @param tid The tournament id
* @param trigram The trigram of the team that a volunteer wants to force the dice launch (default: null)
* @param result The forced value. Null if unused (for regular people)
*/
function drawDice(tid, trigram = null, result = null) {
socket.send(JSON.stringify({'tid': tid, 'type': 'dice', 'trigram': trigram, 'result': result}))
}
/**
* Request to draw a new problem.
* @param tid The tournament id
* @param problem The forced problem. Null if unused (for regular people)
*/
function drawProblem(tid, problem = null) {
socket.send(JSON.stringify({'tid': tid, 'type': 'draw_problem', 'problem': problem}))
}
/**
* Accept the current proposed problem.
* @param tid The tournament id
*/
function acceptProblem(tid) {
socket.send(JSON.stringify({'tid': tid, 'type': 'accept'}))
}
/**
* Reject the current proposed problem.
* @param tid The tournament id
*/
function rejectProblem(tid) {
socket.send(JSON.stringify({'tid': tid, 'type': 'reject'}))
}
/**
* Volunteers can export the draw to make it available for notation.
* @param tid The tournament id
*/
function exportDraw(tid) {
socket.send(JSON.stringify({'tid': tid, 'type': 'export'}))
}
/**
* Volunteers can make the draw continue for the second round of the final.
* @param tid The tournament id
*/
function continueFinal(tid) {
socket.send(JSON.stringify({'tid': tid, 'type': 'continue_final'}))
}
/**
* Display a new notification with the given title and the given body.
* @param title The title of the notification
* @param body The body of the notification
* @param timeout The time (in milliseconds) after that the notification automatically closes. 0 to make indefinite. Default to 5000 ms.
* @return Notification
*/
function showNotification(title, body, timeout = 5000) {
let notif = new Notification(title, {'body': body, 'icon': "/static/tfjm.svg"})
if (timeout)
setTimeout(() => notif.close(), timeout)
return notif
}
document.addEventListener('DOMContentLoaded', () => {
if (document.location.hash) {
// Open the tab of the tournament that is present in the hash
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(elem => {
if ('#' + elem.innerText.toLowerCase() === document.location.hash.toLowerCase()) {
elem.click()
}
})
}
// When a tab is opened, add the tournament name in the hash
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(
elem => elem.addEventListener(
'click', () => document.location.hash = '#' + elem.innerText.toLowerCase()))
/**
* Add alert message on the top on the interface.
* @param message The content of the alert.
* @param type The alert type, which is a bootstrap color (success, info, warning, danger,).
* @param timeout The time (in milliseconds) before the alert is auto-closing. 0 to infinitely, default to 5000 ms.
*/
function addMessage(message, type, timeout = 5000) {
const wrapper = document.createElement('div')
wrapper.innerHTML = [
`<div class="alert alert-${type} alert-dismissible" role="alert">`,
`<div>${message}</div>`,
'<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>',
].join('\n')
messages.append(wrapper)
if (timeout)
setTimeout(() => wrapper.remove(), timeout)
}
/**
* Update the information banner.
* @param tid The tournament id
* @param info The content to updated
*/
function setInfo(tid, info) {
document.getElementById(`messages-${tid}`).innerHTML = info
}
/**
* Open the draw interface, given the list of teams.
* @param tid The tournament id
* @param teams The list of teams (represented by their trigrams) that are present on this draw.
*/
function drawStart(tid, teams) {
// Hide the not-started-banner
document.getElementById(`banner-not-started-${tid}`).classList.add('d-none')
// Display the full draw interface
document.getElementById(`draw-content-${tid}`).classList.remove('d-none')
let dicesDiv = document.getElementById(`dices-${tid}`)
for (let team of teams) {
// Add empty dice score badge for each team
let col = document.createElement('div')
col.classList.add('col-md-1')
dicesDiv.append(col)
let diceDiv = document.createElement('div')
diceDiv.id = `dice-${tid}-${team}`
diceDiv.classList.add('badge', 'rounded-pill', 'text-bg-warning')
if (document.getElementById(`abort-${tid}`) !== null) {
// Check if this is a volunteer, who can launch a die for a specific team
diceDiv.onclick = (_) => drawDice(tid, team)
}
diceDiv.textContent = `${team} 🎲 ??`
col.append(diceDiv)
}
}
/**
* Abort the current draw, and make all invisible, except the not-started-banner.
* @param tid The tournament id
*/
function drawAbort(tid) {
document.getElementById(`banner-not-started-${tid}`).classList.remove('d-none')
document.getElementById(`draw-content-${tid}`).classList.add('d-none')
document.getElementById(`dices-${tid}`).innerHTML = ""
document.getElementById(`recap-${tid}-round-list`).innerHTML = ""
document.getElementById(`tables-${tid}`).innerHTML = ""
updateDiceVisibility(tid, false)
updateBoxVisibility(tid, false)
updateButtonsVisibility(tid, false)
updateExportVisibility(tid, false)
updateContinueVisibility(tid, false)
}
/**
* This function is triggered after a new dice result. We update the score of the team.
* Can be resetted to empty values if the result is null.
* @param tid The tournament id
* @param trigram The trigram of the team that launched its dice
* @param result The result of the dice. null if it is a reset.
*/
function updateDiceInfo(tid, trigram, result) {
let elem = document.getElementById(`dice-${tid}-${trigram}`)
if (result === null) {
elem.classList.remove('text-bg-success')
elem.classList.add('text-bg-warning')
elem.innerText = `${trigram} 🎲 ??`
} else {
elem.classList.remove('text-bg-warning')
elem.classList.add('text-bg-success')
elem.innerText = `${trigram} 🎲 ${result}`
}
}
/**
* Display or hide the dice button.
* @param tid The tournament id
* @param visible The visibility status
*/
function updateDiceVisibility(tid, visible) {
let div = document.getElementById(`launch-dice-${tid}`)
if (visible)
div.classList.remove('d-none')
else
div.classList.add('d-none')
}
/**
* Display or hide the box button.
* @param tid The tournament id
* @param visible The visibility status
*/
function updateBoxVisibility(tid, visible) {
let div = document.getElementById(`draw-problem-${tid}`)
if (visible)
div.classList.remove('d-none')
else
div.classList.add('d-none')
}
/**
* Display or hide the accept and reject buttons.
* @param tid The tournament id
* @param visible The visibility status
*/
function updateButtonsVisibility(tid, visible) {
let div = document.getElementById(`buttons-${tid}`)
if (visible)
div.classList.remove('d-none')
else
div.classList.add('d-none')
}
/**
* Display or hide the export button.
* @param tid The tournament id
* @param visible The visibility status
*/
function updateExportVisibility(tid, visible) {
let div = document.getElementById(`export-${tid}`)
if (visible)
div.classList.remove('d-none')
else
div.classList.add('d-none')
}
/**
* Display or hide the continuation button.
* @param tid The tournament id
* @param visible The visibility status
*/
function updateContinueVisibility(tid, visible) {
let div = document.getElementById(`continue-${tid}`)
if (div !== null) {
// Only present during the final
if (visible)
div.classList.remove('d-none')
else
div.classList.add('d-none')
}
}
/**
* 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 poules The list of poules, which are represented with their letters and trigrams,
* [{'letter': 'A', 'teams': ['ABC', 'DEF', 'GHI']}]
*/
function updatePoules(tid, round, poules) {
let roundList = document.getElementById(`recap-${tid}-round-list`)
let poolListId = `recap-${tid}-round-${round}-pool-list`
let poolList = document.getElementById(poolListId)
if (poolList === null) {
// Add a div for the round in the recap div
let div = document.createElement('div')
div.id = `recap-${tid}-round-${round}`
div.classList.add('col-md-6', 'px-3', 'py-3')
div.setAttribute('data-tournament', tid)
let title = document.createElement('strong')
title.textContent = 'Tour ' + round
poolList = document.createElement('ul')
poolList.id = poolListId
poolList.classList.add('list-group', 'list-group-flush')
div.append(title, poolList)
roundList.append(div)
}
let c = 1
for (let poule of poules) {
let teamListId = `recap-${tid}-round-${round}-pool-${poule.letter}-team-list`
let teamList = document.getElementById(teamListId)
if (teamList === null) {
// Add a div for the pool in the recap div
let li = document.createElement('li')
li.id = `recap-${tid}-round-${round}-pool-${poule.letter}`
li.classList.add('list-group-item', 'px-3', 'py-3')
li.setAttribute('data-tournament', tid)
let title = document.createElement('strong')
title.textContent = 'Poule ' + poule.letter + round
teamList = document.createElement('ul')
teamList.id = teamListId
teamList.classList.add('list-group', 'list-group-flush')
li.append(title, teamList)
poolList.append(li)
}
teamList.innerHTML = ""
for (let team of poule.teams) {
// Reorder dices
let diceDiv = document.getElementById(`dice-${tid}-${team}`)
diceDiv.parentElement.style.order = c.toString()
c += 1
let teamLiId = `recap-${tid}-round-${round}-team-${team}`
// Add a line for the team in the recap
let teamLi = document.createElement('li')
teamLi.id = teamLiId
teamLi.classList.add('list-group-item')
teamLi.setAttribute('data-tournament', tid)
teamList.append(teamLi)
// Add the accepted problem div (empty for now)
let acceptedDivId = `recap-${tid}-round-${round}-team-${team}-accepted`
let acceptedDiv = document.getElementById(acceptedDivId)
if (acceptedDiv === null) {
acceptedDiv = document.createElement('div')
acceptedDiv.id = acceptedDivId
acceptedDiv.classList.add('badge', 'rounded-pill', 'text-bg-warning')
acceptedDiv.textContent = `${team} 📃 ?`
teamLi.append(acceptedDiv)
}
// Add the rejected problems div (empty for now)
let rejectedDivId = `recap-${tid}-round-${round}-team-${team}-rejected`
let rejectedDiv = document.getElementById(rejectedDivId)
if (rejectedDiv === null) {
rejectedDiv = document.createElement('div')
rejectedDiv.id = rejectedDivId
rejectedDiv.classList.add('badge', 'rounded-pill', 'text-bg-danger')
rejectedDiv.textContent = '🗑️'
teamLi.append(rejectedDiv)
}
}
// Draw tables
let tablesDiv = document.getElementById(`tables-${tid}`)
let tablesRoundDiv = document.getElementById(`tables-${tid}-round-${round}`)
if (tablesRoundDiv === null) {
// Add the tables div for the current round if necessary
let card = document.createElement('div')
card.classList.add('card', 'col-md-6')
tablesDiv.append(card)
let cardHeader = document.createElement('div')
cardHeader.classList.add('card-header')
cardHeader.innerHTML = `<h2>Tour ${round}</h2>`
card.append(cardHeader)
tablesRoundDiv = document.createElement('div')
tablesRoundDiv.id = `tables-${tid}-round-${round}`
tablesRoundDiv.classList.add('card-body', 'd-flex', 'flex-wrap')
card.append(tablesRoundDiv)
}
for (let poule of poules) {
if (poule.teams.length === 0)
continue
// Display the table for the pool
updatePouleTable(tid, round, poule)
}
}
}
/**
* 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 poule The current pool, which id represented with its letter and trigrams,
* {'letter': 'A', 'teams': ['ABC', 'DEF', 'GHI']}
*/
function updatePouleTable(tid, round, poule) {
let tablesRoundDiv = document.getElementById(`tables-${tid}-round-${round}`)
let pouleTable = document.getElementById(`table-${tid}-${round}-${poule.letter}`)
if (pouleTable === null) {
// Create table
let card = document.createElement('div')
card.classList.add('card', 'w-100', 'my-3', `order-${poule.letter.charCodeAt(0) - 64}`)
tablesRoundDiv.append(card)
let cardHeader = document.createElement('div')
cardHeader.classList.add('card-header')
cardHeader.innerHTML = `<h2>Poule ${poule.letter}${round}</h2>`
card.append(cardHeader)
let cardBody = document.createElement('div')
cardBody.classList.add('card-body')
card.append(cardBody)
pouleTable = document.createElement('table')
pouleTable.id = `table-${tid}-${round}-${poule.letter}`
pouleTable.classList.add('table', 'table-stripped')
cardBody.append(pouleTable)
let thead = document.createElement('thead')
pouleTable.append(thead)
let phaseTr = document.createElement('tr')
thead.append(phaseTr)
let teamTh = document.createElement('th')
teamTh.classList.add('text-center')
teamTh.rowSpan = poule.teams.length === 5 ? 3 : 2
teamTh.textContent = "Équipe"
phaseTr.append(teamTh)
// Add columns
for (let i = 1; i <= (poule.teams.length === 4 ? 4 : 3); ++i) {
let phaseTh = document.createElement('th')
phaseTh.classList.add('text-center')
if (poule.teams.length === 5 && i < 3)
phaseTh.colSpan = 2
phaseTh.textContent = `Phase ${i}`
phaseTr.append(phaseTh)
}
if (poule.teams.length === 5) {
let roomTr = document.createElement('tr')
thead.append(roomTr)
for (let i = 0; i < 5; ++i) {
let roomTh = document.createElement('th')
roomTh.classList.add('text-center')
roomTh.textContent = `Salle ${1 + (i % 2)}`
roomTr.append(roomTh)
}
}
let problemTr = document.createElement('tr')
thead.append(problemTr)
for (let team of poule.teams) {
let problemTh = document.createElement('th')
problemTh.classList.add('text-center')
// Problem is unknown for now
problemTh.innerHTML = `Pb. <span id="table-${tid}-round-${round}-problem-${team}">?</span>`
problemTr.append(problemTh)
}
// Add body
let tbody = document.createElement('tbody')
pouleTable.append(tbody)
for (let i = 0; i < poule.teams.length; ++i) {
let team = poule.teams[i]
let teamTr = document.createElement('tr')
tbody.append(teamTr)
// First create cells, then we will add them in the table
let teamTd = document.createElement('td')
teamTd.classList.add('text-center')
teamTd.innerText = team
teamTr.append(teamTd)
let defenderTd = document.createElement('td')
defenderTd.classList.add('text-center')
defenderTd.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'
// 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)
break
case 1:
teamTr.append(opponentTd, defenderTd, reporterTd)
break
case 2:
teamTr.append(reporterTd, opponentTd, defenderTd)
break
}
} else if (poule.teams.length === 4) {
let emptyTd = document.createElement('td')
switch (i) {
case 0:
teamTr.append(defenderTd, emptyTd, reporterTd, opponentTd)
break
case 1:
teamTr.append(opponentTd, defenderTd, emptyTd, reporterTd)
break
case 2:
teamTr.append(reporterTd, opponentTd, defenderTd, emptyTd)
break
case 3:
teamTr.append(emptyTd, reporterTd, opponentTd, defenderTd)
break
}
} else if (poule.teams.length === 5) {
let emptyTd = document.createElement('td')
let emptyTd2 = document.createElement('td')
switch (i) {
case 0:
teamTr.append(defenderTd, emptyTd, opponentTd, reporterTd, emptyTd2)
break
case 1:
teamTr.append(emptyTd, defenderTd, reporterTd, emptyTd2, opponentTd)
break
case 2:
teamTr.append(opponentTd, emptyTd, defenderTd, emptyTd2, reporterTd)
break
case 3:
teamTr.append(reporterTd, opponentTd, emptyTd, defenderTd, emptyTd2)
break
case 4:
teamTr.append(emptyTd, reporterTd, emptyTd2, opponentTd, defenderTd)
break
}
}
}
}
}
/**
* 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 pool The current pool letter (A, B, C or D) (null if non-relevant)
* @param team The current team trigram (null if non-relevant)
*/
function updateActiveRecap(tid, round, pool, team) {
// Remove the previous highlights
document.querySelectorAll(`div.text-bg-secondary[data-tournament="${tid}"]`)
.forEach(elem => elem.classList.remove('text-bg-secondary'))
document.querySelectorAll(`li.list-group-item-success[data-tournament="${tid}"]`)
.forEach(elem => elem.classList.remove('list-group-item-success'))
document.querySelectorAll(`li.list-group-item-info[data-tournament="${tid}"]`)
.forEach(elem => elem.classList.remove('list-group-item-info'))
// Highlight current round, if existing
let roundDiv = document.getElementById(`recap-${tid}-round-${round}`)
if (roundDiv !== null)
roundDiv.classList.add('text-bg-secondary')
// Highlight current pool, if existing
let poolLi = document.getElementById(`recap-${tid}-round-${round}-pool-${pool}`)
if (poolLi !== null)
poolLi.classList.add('list-group-item-success')
// Highlight current team, if existing
let teamLi = document.getElementById(`recap-${tid}-round-${round}-team-${team}`)
if (teamLi !== null)
teamLi.classList.add('list-group-item-info')
}
/**
* 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 team The current team trigram
* @param problem The accepted problem, as integer
*/
function setProblemAccepted(tid, round, team, problem) {
// Update recap
let recapDiv = document.getElementById(`recap-${tid}-round-${round}-team-${team}-accepted`)
if (problem !== null) {
recapDiv.classList.remove('text-bg-warning')
recapDiv.classList.add('text-bg-success')
} else {
recapDiv.classList.add('text-bg-warning')
recapDiv.classList.remove('text-bg-success')
}
recapDiv.textContent = `${team} 📃 ${problem ? problem : '?'}`
// Update table
let tableSpan = document.getElementById(`table-${tid}-round-${round}-problem-${team}`)
tableSpan.textContent = problem ? problem : '?'
}
/**
* 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 team The current team trigram
* @param rejected The full list of rejected problems
*/
function setProblemRejected(tid, round, team, rejected) {
// Update recap
let recapDiv = document.getElementById(`recap-${tid}-round-${round}-team-${team}-rejected`)
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 (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))}`
} else {
// Eventually remove this div
if (penaltyDiv !== null)
penaltyDiv.remove()
}
}
/**
* 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 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]
*/
function reorderPoule(tid, round, poule, teams, problems) {
// Redraw the pool table
let table = document.getElementById(`table-${tid}-${round}-${poule}`)
table.parentElement.parentElement.remove()
updatePouleTable(tid, round, {'letter': poule, 'teams': teams})
// Put the problems in the table
for (let i = 0; i < teams.length; ++i) {
let team = teams[i]
let problem = problems[i]
setProblemAccepted(tid, round, team, problem)
}
}
/**
* Process the received data from the server.
* @param tid The tournament id
* @param data The received message
*/
function processMessage(tid, data) {
switch (data.type) {
case 'alert':
// Add alert message
addMessage(data.message, data.alert_type)
break
case 'notification':
// Add notification
showNotification(data.title, data.body)
break
case 'set_info':
// Update information banner
setInfo(tid, data.information)
break
case 'draw_start':
// Start the draw and update the interface
drawStart(tid, data.trigrams)
break
case 'abort':
// Abort the current draw
drawAbort(tid)
break
case 'dice':
// Update the interface after a dice launch
updateDiceInfo(tid, data.team, data.result)
break
case 'dice_visibility':
// Update the dice button visibility
updateDiceVisibility(tid, data.visible)
break
case 'box_visibility':
// Update the box button visibility
updateBoxVisibility(tid, data.visible)
break
case 'buttons_visibility':
// Update the accept/reject buttons visibility
updateButtonsVisibility(tid, data.visible)
break
case 'export_visibility':
// Update the export button visibility
updateExportVisibility(tid, data.visible)
break
case 'continue_visibility':
// Update the continue button visibility for the final tournament
updateContinueVisibility(tid, data.visible)
break
case 'set_poules':
// Set teams order and pools and update the interface
updatePoules(tid, data.round, data.poules)
break
case 'set_active':
// Highlight the team that is selecting a problem
updateActiveRecap(tid, data.round, data.poule, data.team)
break
case 'set_problem':
// Mark a problem as accepted and update the interface
setProblemAccepted(tid, data.round, data.team, data.problem)
break
case 'reject_problem':
// Mark a problem as rejected and update the interface
setProblemRejected(tid, data.round, data.team, data.rejected)
break
case 'reorder_poule':
// Reorder a pool and redraw the associated table
reorderPoule(tid, data.round, data.poule, data.teams, data.problems)
break
}
}
function setupSocket() {
// Open a global websocket
socket = new WebSocket(
(document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/ws/draw/'
)
// Listen on websockets and process messages from the server
socket.addEventListener('message', e => {
// Parse received data as JSON
const data = JSON.parse(e.data)
processMessage(data['tid'], data)
})
// Manage errors
socket.addEventListener('close', e => {
console.error('Chat socket closed unexpectedly, restarting…')
setupSocket()
})
// When the socket is opened, set the language in order to receive alerts in the good language
socket.addEventListener('open', e => {
socket.send(JSON.stringify({
'tid': tournaments[0].id,
'type': 'set_language',
'language': document.getElementsByName('language')[0].value,
}))
})
for (let tournament of tournaments) {
// Manage the start form
let format_form = document.getElementById('format-form-' + tournament.id)
if (format_form !== null) {
format_form.addEventListener('submit', function (e) {
e.preventDefault()
socket.send(JSON.stringify({
'tid': tournament.id,
'type': 'start_draw',
'fmt': document.getElementById('format-' + tournament.id).value
}))
})
}
}
}
setupSocket()
if (document.querySelector('a[href="/admin/"]')) {
// Administrators can fake the draw
// This is useful for debug purposes, or
document.getElementsByTagName('body')[0].addEventListener('keyup', event => {
if (event.key === 'f') {
let activeTab = document.querySelector('#tournaments-tab button.active')
let tid = activeTab.id.substring(4)
let dice = document.getElementById(`launch-dice-${tid}`)
let box = document.getElementById(`draw-problem-${tid}`)
let value = NaN
if (!dice.classList.contains('d-none')) {
value = parseInt(prompt("Entrez la valeur du dé (laissez vide pour annuler) :"))
if (!isNaN(value) && 1 <= value && value <= 100)
drawDice(tid, null, value)
} else if (!box.classList.contains('d-none')) {
value = parseInt(prompt("Entrez le numéro du problème à choisir (laissez vide pour annuler) :"))
if (!isNaN(value) && 1 <= value && value <= 8)
drawProblem(tid, value)
}
}
})
}
})

View File

@ -0,0 +1,44 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% block content %}
{# The navbar to select the tournament #}
<ul class="nav nav-tabs" id="tournaments-tab" role="tablist">
{% for tournament in tournaments %}
<li class="nav-item" role="presentation">
<button class="nav-link{% if forloop.first %} active{% endif %}"
id="tab-{{ tournament.id }}" data-bs-toggle="tab"
data-bs-target="#tab-{{ tournament.id }}-pane" type="button" role="tab"
aria-controls="tab-{{ tournament.id }}-pane" aria-selected="true">
{{ tournament.name }}
</button>
</li>
{% endfor %}
</ul>
<div class="tab-content" id="tab-content">
{# For each tournament, we draw a div #}
{% for tournament in tournaments %}
<div class="tab-pane fade{% if forloop.first %} show active{% endif %}"
id="tab-{{ tournament.id }}-pane" role="tabpanel"
aria-labelledby="tab-{{ tournament.id }}" tabindex="0">
{% include "draw/tournament_content.html" with tournament=tournament %}
</div>
{% empty %}
<div class="alert alert-warning">
{% trans "You don't participate to any tournament." %}
</div>
{% endfor %}
</div>
{% endblock %}
{% block extrajavascript %}
{# Import the list of tournaments and give it to JavaScript #}
{{ tournaments_simplified|json_script:'tournaments_list' }}
{{ problems|length|json_script:'problems_count' }}
{# This script contains all data for the draw management #}
<script src="{% static 'draw.js' %}"></script>
{% endblock %}

View File

@ -0,0 +1,359 @@
{% load i18n %}
<div id="banner-not-started-{{ tournament.id }}" class="alert alert-warning{% if tournament.draw %} d-none{% endif %}">
{# This div is visible iff the draw is not started. #}
{% trans "The draw has not started yet." %}
{% if user.registration.is_volunteer %}
{# Volunteers have a form to start the draw #}
<form id="format-form-{{ tournament.id }}">
<div class="col-md-3">
<div class="input-group">
<label class="input-group-text" for="format-{{ tournament.id }}">
{% trans "Configuration:" %}
</label>
{# The configuration is the size of pools per pool, for example 3+3+3 #}
<input type="text" class="form-control" id="format-{{ tournament.id }}"
pattern="^[345](\+[345])*$"
placeholder="{{ tournament.best_format }}"
value="{{ tournament.best_format }}">
<button class="btn btn-success input-group-btn">{% trans "Start!" %}</button>
</div>
</div>
</form>
{% endif %}
</div>
<div id="draw-content-{{ tournament.id }}" class="{% if not tournament.draw %}d-none{% endif %}">
{# Displayed only if the tournament has started #}
<div class="container">
<div class="card col-md-12 my-3">
<div class="card-header">
<h2>{% trans "Last dices" %}</h2>
</div>
<div class="card-body">
<div id="dices-{{ tournament.id }}" class="row">
{# Display last dices of all teams #}
{% 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 }}"
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 #}
onclick="drawDice({{ tournament.id }}, '{{ td.participation.team.trigram }}')"
{% endif %}>
{{ td.participation.team.trigram }} 🎲 {{ td.last_dice|default:'??' }}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="row">
<div class="col-md-5 my-3">
<div class="card">
<div class="card-header">
Recap
{% if user.registration.is_volunteer %}
<button id="cancel-last-step-{{ tournament.id }}"
class="badge rounded-pill text-bg-warning"
onclick="cancelLastStep({{ tournament.id }})">
🔙 {% trans "Cancel last step" %}
</button>
{% endif %}
</div>
<div class="card-body">
<div id="recap-{{ tournament.id }}-round-list" class="row">
{% for round in tournament.draw.round_set.all %}
{# For each round, add a recap of drawn problems #}
<div id="recap-{{ tournament.id }}-round-{{ round.number }}"
class="col-md-6 px-3 py-3 {% if tournament.draw.current_round == round %} text-bg-secondary{% endif %}"
data-tournament="{{ tournament.id }}">
<strong>{{ round }}</strong>
<ul id="recap-{{ tournament.id }}-round-{{ round.number }}-pool-list"
class="list-group list-group-flush">
{% for pool in round.pool_set.all %}
{# Add one item per pool #}
<li id="recap-{{ tournament.id }}-round-{{ round.number }}-pool-{{ pool.get_letter_display }}"
class="list-group-item px-3 py-3 {% if tournament.draw.current_round.current_pool == pool %} list-group-item-success{% endif %}"
data-tournament="{{ tournament.id }}">
<strong>{{ pool }}</strong>
<ul id="recap-{{ tournament.id }}-round-{{ round.number }}-pool-{{ pool.get_letter_display }}-team-list"
class="list-group list-group-flush">
{% for td in pool.team_draws.all %}
{# Add teams of the pool #}
<li id="recap-{{ tournament.id }}-round-{{ round.number }}-team-{{ td.participation.team.trigram }}"
class="list-group-item{% if tournament.draw.current_round.current_pool.current_team == td %} list-group-item-info{% endif %}"
data-tournament="{{ tournament.id }}">
{# Add the accepted problem, if existing #}
<div id="recap-{{ tournament.id }}-round-{{ round.number }}-team-{{ td.participation.team.trigram }}-accepted"
class="badge rounded-pill text-bg-{% if td.accepted %}success{% else %}warning{% endif %}">
{{ td.participation.team.trigram }} 📃 {{ td.accepted|default:'?' }}
</div>
{# Add the rejected problems #}
<div id="recap-{{ tournament.id }}-round-{{ round.number }}-team-{{ td.participation.team.trigram }}-rejected"
class="badge rounded-pill text-bg-danger">
🗑️ {{ td.rejected|join:', ' }}
</div>
{% if td.penalty %}
{# 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 }}
</div>
{% endif %}
</li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="col-md-7 my-3">
<div class="card">
<div class="card-body">
<div id="messages-{{ tournament.id }}" class="alert alert-info">
{# Display the insctructions of the draw to the teams #}
{{ tournament.draw.information|safe }}
</div>
<div id="launch-dice-{{ tournament.id }}"
{% if tournament.draw.get_state != 'DICE_SELECT_POULES' and tournament.draw.get_state != 'DICE_ORDER_POULE' %}class="d-none"
{% else %}{% if not user.registration.is_volunteer and user.registration.team.trigram not in tournament.draw.current_round.current_pool.trigrams %}class="d-none"{% endif %}{% endif %}>
{# Display the dice interface if this is the time for it #}
{# ie. if we are in the state where teams must launch a dice to choose the passage order or the choice order and we are in a team in the good pool, or a volunteer #}
<div class="text-center">
<button class="btn btn-lg" style="font-size: 100pt" onclick="drawDice({{ tournament.id }})">
🎲
</button>
</div>
<h2 class="text-center">
{% trans "Launch dice" %}
</h2>
</div>
<div id="draw-problem-{{ tournament.id }}"
{% if tournament.draw.get_state != 'WAITING_DRAW_PROBLEM' %}class="d-none"
{% else %}{% if user.registration.team.participation != tournament.draw.current_round.current_pool.current_team.participation and not user.registration.is_volunteer %}class="d-none"{% endif %}{% endif %}>
{# Display the box only if needed #}
<div class="text-center">
<button class="btn btn-lg" style="font-size: 100pt" onclick="drawProblem({{ tournament.id }})">
🗳️
</button>
</div>
<h2 class="text-center">
{% trans "Draw a problem" %}
</h2>
</div>
<div id="buttons-{{ tournament.id }}"
{% if tournament.draw.get_state != 'WAITING_CHOOSE_PROBLEM' %}class="d-none"
{% else %}{% if user.registration.team.participation != tournament.draw.current_round.current_pool.current_team.participation and not user.registration.is_volunteer %}class="d-none"{% endif %}{% endif %}>
{# Display buttons if a problem has been drawn and we are waiting for its acceptation or reject #}
<div class="d-grid">
<div class="btn-group">
<button class="btn btn-success" onclick="acceptProblem({{ tournament.id }})">
{% trans "Accept" %}
</button>
<button class="btn btn-danger" onclick="rejectProblem({{ tournament.id }})">
{% trans "Decline" %}
</button>
</div>
</div>
</div>
</div>
{% if user.registration.is_volunteer %}
{# Volunteers can export the draw if possible #}
<div id="export-{{ tournament.id }}"
class="card-footer text-center{% if not tournament.draw.exportable %} d-none{% endif %}">
<button class="btn btn-info text-center" onclick="exportDraw({{ tournament.id }})">
📁 {% trans "Export" %}
</button>
</div>
{% if tournament.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 %}">
<button class="btn btn-success text-center" onclick="continueFinal({{ tournament.id }})">
➡️ {% trans "Continue draw" %}
</button>
</div>
{% endif %}
{% endif %}
</div>
</div>
</div>
<div id="tables-{{ tournament.id }}" class="row">
{# Display tables with the advancement of the draw below #}
{% for round in tournament.draw.round_set.all %}
<div class="card col-md-6">
<div class="card-header">
<h2>
{{ round }}
</h2>
</div>
<div id="tables-{{ tournament.id }}-round-{{ round.number }}" class="card-body d-flex flex-wrap">
{% for pool in round.pool_set.all %}
{# Draw one table per pool #}
{% if pool.teamdraw_set.count %}
<div class="card w-100 my-3 order-{{ pool.letter }}">
<div class="card-header">
<h3>
{{ pool }}
</h3>
</div>
<div class="card-body">
<table id="table-{{ tournament.id }}-{{ round.number }}-{{ pool.get_letter_display }}" class="table table-striped">
<thead>
{# One column per phase #}
<tr>
<th class="text-center" rowspan="{% if pool.size == 5 %}3{% else %}2{% endif %}">{% trans "team"|capfirst %}</th>
<th class="text-center"{% if pool.size == 5 %} colspan="2"{% endif %}>Phase 1</th>
<th class="text-center"{% if pool.size == 5 %} colspan="2"{% endif %}>Phase 2</th>
<th class="text-center">Phase 3</th>
{% if pool.size == 4 %}
<th class="text-center">Phase 4</th>
{% endif %}
</tr>
{% if pool.size == 5 %}
<tr>
<th class="text-center">{% trans "Room" %} 1</th>
<th class="text-center">{% trans "Room" %} 2</th>
<th class="text-center">{% trans "Room" %} 1</th>
<th class="text-center">{% trans "Room" %} 2</th>
<th class="text-center">{% trans "Room" %} 1</th>
</tr>
{% endif %}
<tr>
{% for td in pool.team_draws.all %}
<th class="text-center">
Pb.
<span id="table-{{ tournament.id }}-round-{{ round.number }}-problem-{{ td.participation.team.trigram }}">{{ td.accepted|default:"?" }}</span>
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{# Draw the order regarding the pool size #}
{% for td in pool.team_draws %}
<tr>
<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>
{% elif forloop.counter == 2 %}
<td class="text-center">Opp</td>
<td class="text-center">Déf</td>
<td class="text-center">Rap</td>
{% elif forloop.counter == 3 %}
<td class="text-center">Rap</td>
<td class="text-center">Opp</td>
<td class="text-center">Déf</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>
{% 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>
{% 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>
{% 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>
{% 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>
{% 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>
{% 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>
{% 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>
{% 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>
{% endif %}
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% if user.registration.is_volunteer %}
{# Volunteers can click on this button to abort the draw #}
<div class="text-center mt-3">
<button id="abort-{{ tournament.id }}" class="badge rounded-pill text-bg-danger" data-bs-toggle="modal" data-bs-target="#abort{{ tournament.id }}Modal">
{% trans "Abort" %}
</button>
</div>
{% endif %}
</div>
<div id="abort{{ tournament.id }}Modal" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans "Are you sure?" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{% trans "This will reset the draw from the beginning." %}
{% trans "This operation is irreversible." %}
{% trans "Are you sure you want to abort this draw?" %}
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-danger" data-bs-dismiss="modal" onclick="abortDraw({{ tournament.id }})">{% trans "Abort" %}</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
</div>
</div>
</div>
</div>

812
draw/tests.py Normal file
View File

@ -0,0 +1,812 @@
# Copyright (C) 2023 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
import asyncio
from random import shuffle
from asgiref.sync import sync_to_async
from channels.auth import AuthMiddlewareStack
from channels.routing import URLRouter
from channels.testing import WebsocketCommunicator
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.contrib.sites.models import Site
from django.test import TestCase
from django.urls import reverse
from participation.models import Team, Tournament
from . import routing
from .models import Draw, Pool, Round, TeamDraw
class TestDraw(TestCase):
def setUp(self):
self.superuser = User.objects.create_superuser(
username="admin",
email="admin@example.com",
password="toto1234",
)
self.tournament = Tournament.objects.create(
name="Test",
)
self.teams = []
for i in range(12):
t = Team.objects.create(
name=f"Team {i + 1}",
trigram=3 * chr(65 + i),
)
t.participation.tournament = self.tournament
t.participation.valid = True
t.participation.save()
self.teams.append(t)
shuffle(self.teams)
async def test_draw(self): # noqa: C901
"""
Simulate a full draw operation.
"""
await sync_to_async(self.async_client.force_login)(self.superuser)
tid = self.tournament.id
resp = await self.async_client.get(reverse('draw:index'))
self.assertEqual(resp.status_code, 200)
# Connect to Websocket
headers = [(b'cookie', self.async_client.cookies.output(header='', sep='; ').encode())]
communicator = WebsocketCommunicator(AuthMiddlewareStack(URLRouter(routing.websocket_urlpatterns)),
"/ws/draw/", headers)
connected, subprotocol = await communicator.connect()
self.assertTrue(connected)
# Define language
await communicator.send_json_to({'tid': tid, 'type': 'set_language', 'language': 'en'})
# Ensure that Draw has not started
self.assertFalse(await Draw.objects.filter(tournament=self.tournament).aexists())
# Must be an error since 1+1+1 != 12
await communicator.send_json_to({'tid': tid, 'type': 'start_draw', 'fmt': '1+1+1'})
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.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'})
# Receive data after the start
self.assertEqual((await communicator.receive_json_from())['type'], 'alert')
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'set_poules', 'round': 1,
'poules': [{'letter': 'A', 'teams': []},
{'letter': 'B', 'teams': []},
{'letter': 'C', 'teams': []}]})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'set_poules', 'round': 2,
'poules': [{'letter': 'A', 'teams': []},
{'letter': 'B', 'teams': []},
{'letter': 'C', 'teams': []}]})
self.assertEqual(await communicator.receive_json_from(),
{'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],
'trigrams': ['AAA', 'BBB', 'CCC', 'DDD', 'EEE', 'FFF',
'GGG', 'HHH', 'III', 'JJJ', 'KKK', 'LLL']})
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'set_active', 'round': 1, 'poule': None, 'team': None})
self.assertEqual((await communicator.receive_json_from())['type'], 'notification')
# Ensure that now tournament has started
self.assertTrue(await Draw.objects.filter(tournament=self.tournament).aexists())
# Render page
resp = await self.async_client.get(reverse('draw:index'))
self.assertEqual(resp.status_code, 200)
# Try to relaunch the draw
await communicator.send_json_to({'tid': tid, 'type': 'start_draw', 'fmt': '3+4+5'})
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.")
draw: Draw = await Draw.objects.prefetch_related(
'current_round__current_pool__current_team__participation__team').aget(tournament=self.tournament)
r: Round = draw.current_round
for i, team in enumerate(self.teams):
# Launch a new dice
await communicator.send_json_to({'tid': tid, 'type': "dice", 'trigram': team.trigram})
resp = await communicator.receive_json_from()
self.assertEqual(resp['type'], "dice")
self.assertEqual(resp['team'], team.trigram)
self.assertGreaterEqual(resp['result'], 1)
self.assertLessEqual(resp['result'], 100)
td: TeamDraw = await r.teamdraw_set.aget(participation=team.participation)
if i != len(self.teams) - 1:
self.assertEqual(resp['result'], td.passage_dice)
# Try to relaunch the dice
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.")
# Force exactly one duplicate
await td.arefresh_from_db()
td.passage_dice = 101 + i if i != 2 else 101
await td.asave()
# Manage duplicates
while dup_count := await r.teamdraw_set.filter(passage_dice__isnull=True).acount():
for i in range(dup_count):
# Dice
resp = await communicator.receive_json_from()
self.assertEqual(resp['type'], 'dice')
self.assertIsNone(resp['result'])
# Alert
self.assertEqual((await communicator.receive_json_from())['type'], 'alert')
for i in range(dup_count):
await communicator.send_json_to({'tid': tid, 'type': "dice", 'trigram': None})
await communicator.receive_json_from()
# Reset dices
for _i in range(12):
resp = await communicator.receive_json_from()
self.assertEqual(resp['type'], 'dice')
self.assertIsNone(resp['result'])
# Hide and re-display the dice
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'dice_visibility', 'visible': False})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'dice_visibility', 'visible': True})
# Set pools for the two rounds
self.assertEqual((await communicator.receive_json_from())['type'], 'set_poules')
self.assertEqual((await communicator.receive_json_from())['type'], 'set_poules')
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
# Manage the first pool
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'set_active', 'round': 1, 'poule': 'A', 'team': None})
r: Round = await Round.objects.prefetch_related('current_pool__current_team__participation__team')\
.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.current_team, None)
# Render page
resp = await self.async_client.get(reverse('draw:index'))
self.assertEqual(resp.status_code, 200)
i = 0
async for td in p.teamdraw_set.prefetch_related('participation__team').all():
# Launch a new dice
await communicator.send_json_to({'tid': tid, 'type': "dice", 'trigram': td.participation.team.trigram})
resp = await communicator.receive_json_from()
self.assertEqual(resp['type'], "dice")
trigram = td.participation.team.trigram
self.assertEqual(resp['team'], trigram)
self.assertGreaterEqual(resp['result'], 1)
self.assertLessEqual(resp['result'], 100)
if i != p.size - 1:
await td.arefresh_from_db()
self.assertEqual(resp['result'], td.choice_dice)
# Try to relaunch the dice
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.")
# Force exactly one duplicate
await td.arefresh_from_db()
td.passage_dice = 101 + i if i != 1 else 101
await td.asave()
i += 1
# Manage duplicates
while dup_count := await p.teamdraw_set.filter(choice_dice__isnull=True).acount():
for i in range(dup_count):
# Dice
resp = await communicator.receive_json_from()
self.assertEqual(resp['type'], 'dice')
self.assertIsNone(resp['result'])
# Alert
self.assertEqual((await communicator.receive_json_from())['type'], 'alert')
for i in range(dup_count):
await communicator.send_json_to({'tid': tid, 'type': "dice", 'trigram': None})
await communicator.receive_json_from()
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
# Check current pool
p: Pool = await Pool.objects.prefetch_related('current_team__participation__team').aget(round=r, letter=1)
td = p.current_team
trigram = td.participation.team.trigram
self.assertEqual(td.choose_index, 0)
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'set_active', 'round': 1,
'poule': 'A', 'team': td.participation.team.trigram})
# Dice is hidden for everyone first
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'dice_visibility', 'visible': False})
# The draw box is displayed for the current team and for volunteers
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'box_visibility', 'visible': True})
# Render page
resp = await self.async_client.get(reverse('draw:index'))
self.assertEqual(resp.status_code, 200)
# Try to launch a dice while it is not the time
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.")
# Draw a problem
await communicator.send_json_to({'tid': tid, 'type': 'draw_problem'})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'box_visibility', 'visible': False})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'buttons_visibility', 'visible': True})
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
td: TeamDraw = await TeamDraw.objects.prefetch_related('participation__team').aget(pk=td.pk)
purposed = td.purposed
self.assertIsNotNone(td.purposed)
self.assertIn(td.purposed, range(1, len(settings.PROBLEMS) + 1))
# Render page
resp = await self.async_client.get(reverse('draw:index'))
self.assertEqual(resp.status_code, 200)
# Try to redraw a problem while it is not the time
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.")
# Reject the first problem
await communicator.send_json_to({'tid': tid, 'type': 'reject'})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'buttons_visibility', 'visible': False})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'reject_problem', 'round': 1, 'team': trigram, 'rejected': [purposed]})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'box_visibility', 'visible': True})
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
td: TeamDraw = await TeamDraw.objects.prefetch_related('participation__team').aget(pk=td.pk)
self.assertIsNone(td.purposed)
self.assertEqual(td.rejected, [purposed])
for i in range(4):
# Next team
p: Pool = await Pool.objects.prefetch_related('current_team__participation__team').aget(round=r, letter=1)
td = p.current_team
trigram = td.participation.team.trigram
self.assertEqual(td.choose_index, i + 1)
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'set_active', 'round': 1, 'poule': 'A', 'team': trigram})
# Render page
resp = await self.async_client.get(reverse('draw:index'))
self.assertEqual(resp.status_code, 200)
# Draw a problem
await communicator.send_json_to({'tid': tid, 'type': 'draw_problem'})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'box_visibility', 'visible': False})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'buttons_visibility', 'visible': True})
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
td: TeamDraw = await TeamDraw.objects.prefetch_related('participation__team').aget(pk=td.pk)
self.assertIsNotNone(td.purposed)
self.assertIn(td.purposed, range(1, len(settings.PROBLEMS) + 1))
# Assume that this is the problem 1 for teams 2 et 4 and the problem 2 for teams 3 and 5
td.purposed = 1 + (i % 2)
await td.asave()
# Render page
resp = await self.async_client.get(reverse('draw:index'))
self.assertEqual(resp.status_code, 200)
# Accept the problem
await communicator.send_json_to({'tid': tid, 'type': 'accept'})
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': 1 + (i % 2)})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'box_visibility', 'visible': True})
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
td: TeamDraw = await TeamDraw.objects.prefetch_related('participation__team').aget(pk=td.pk)
self.assertIsNone(td.purposed)
self.assertEqual(td.accepted, 1 + (i % 2))
# Render page
resp = await self.async_client.get(reverse('draw:index'))
self.assertEqual(resp.status_code, 200)
# Go back to the first team of the pool
p: Pool = await Pool.objects.prefetch_related('current_team__participation__team').aget(round=r, letter=1)
td = p.current_team
trigram = td.participation.team.trigram
self.assertEqual(td.choose_index, 0)
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'set_active', 'round': 1, 'poule': 'A', 'team': trigram})
# Draw and reject 100 times a problem
for _i in range(100):
# Draw a problem
await communicator.send_json_to({'tid': tid, 'type': 'draw_problem'})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'box_visibility', 'visible': False})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'buttons_visibility', 'visible': True})
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
td: TeamDraw = await TeamDraw.objects.prefetch_related('participation__team').aget(pk=td.pk)
self.assertIsNotNone(td.purposed)
# Problems 1 and 2 are not available
self.assertIn(td.purposed, range(3, len(settings.PROBLEMS) + 1))
# Reject
await communicator.send_json_to({'tid': tid, 'type': 'reject'})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'buttons_visibility', 'visible': False})
self.assertEqual((await communicator.receive_json_from())['type'], 'reject_problem')
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'box_visibility', 'visible': True})
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
td: TeamDraw = await TeamDraw.objects.prefetch_related('participation__team').aget(pk=td.pk)
self.assertIsNone(td.purposed)
self.assertIn(purposed, td.rejected)
# Ensures that this is still the first team
p: Pool = await Pool.objects.prefetch_related('current_team__participation__team').aget(round=r, letter=1)
self.assertEqual(p.current_team, td)
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'set_active', 'round': 1, 'poule': 'A', 'team': trigram})
# Render page
resp = await self.async_client.get(reverse('draw:index'))
self.assertEqual(resp.status_code, 200)
# Ensures that there is a penalty
self.assertGreaterEqual(td.penalty, 1)
# Draw a last problem
await communicator.send_json_to({'tid': tid, 'type': 'draw_problem'})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'box_visibility', 'visible': False})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'buttons_visibility', 'visible': True})
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
td: TeamDraw = await TeamDraw.objects.prefetch_related('participation__team').aget(pk=td.pk)
self.assertIsNotNone(td.purposed)
self.assertIn(td.purposed, range(1, len(settings.PROBLEMS) + 1))
# Accept the problem
await communicator.send_json_to({'tid': tid, 'type': 'accept'})
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': td.purposed})
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')
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'set_active', 'round': 1, 'poule': 'B', 'team': None})
# Start pool 2
r: Round = await Round.objects.prefetch_related('current_pool__current_team__participation__team')\
.aget(number=1, draw=draw)
p = r.current_pool
self.assertEqual(p.letter, 2)
self.assertEqual(p.size, 4)
self.assertEqual(await p.teamdraw_set.acount(), 4)
self.assertEqual(p.current_team, None)
# Render page
resp = await self.async_client.get(reverse('draw:index'))
self.assertEqual(resp.status_code, 200)
i = 0
async for td in p.teamdraw_set.prefetch_related('participation__team').all():
# Launch a new dice
await communicator.send_json_to({'tid': tid, 'type': "dice", 'trigram': td.participation.team.trigram})
await communicator.receive_json_from()
await td.arefresh_from_db()
td.choice_dice = 101 + i # Avoid duplicates
await td.asave()
i += 1
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
for i in range(4):
# Next team
p: Pool = await Pool.objects.prefetch_related('current_team__participation__team').aget(round=r, letter=2)
td = p.current_team
trigram = td.participation.team.trigram
self.assertEqual(td.choose_index, i)
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'set_active', 'round': 1, 'poule': 'B', 'team': trigram})
if i == 0:
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'dice_visibility', 'visible': False})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'box_visibility', 'visible': True})
# Render page
resp = await self.async_client.get(reverse('draw:index'))
self.assertEqual(resp.status_code, 200)
# Draw a problem
await communicator.send_json_to({'tid': tid, 'type': 'draw_problem'})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'box_visibility', 'visible': False})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'buttons_visibility', 'visible': True})
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
td: TeamDraw = await TeamDraw.objects.prefetch_related('participation__team').aget(pk=td.pk)
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)
# Assume that this is the problem is i for the team i
td.purposed = i + 1
await td.asave()
# Render page
resp = await self.async_client.get(reverse('draw:index'))
self.assertEqual(resp.status_code, 200)
# Accept the problem
await communicator.send_json_to({'tid': tid, 'type': 'accept'})
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})
if i < 3:
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'box_visibility', 'visible': True})
else:
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'dice_visibility', 'visible': True})
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
td: TeamDraw = await TeamDraw.objects.prefetch_related('participation__team').aget(pk=td.pk)
self.assertIsNone(td.purposed)
self.assertEqual(td.accepted, i + 1)
# Render page
resp = await self.async_client.get(reverse('draw:index'))
self.assertEqual(resp.status_code, 200)
# Start pool 3
r: Round = await Round.objects.prefetch_related('current_pool__current_team__participation__team')\
.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.current_team, None)
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'set_active', 'round': 1, 'poule': 'C', 'team': None})
# Render page
resp = await self.async_client.get(reverse('draw:index'))
self.assertEqual(resp.status_code, 200)
i = 0
async for td in p.teamdraw_set.prefetch_related('participation__team').all():
# Launch a new dice
await communicator.send_json_to({'tid': tid, 'type': "dice", 'trigram': td.participation.team.trigram})
await communicator.receive_json_from()
await td.arefresh_from_db()
td.choice_dice = 101 + i # Avoid duplicates
await td.asave()
i += 1
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
for i in range(3):
# Next team
p: Pool = await Pool.objects.prefetch_related('current_team__participation__team').aget(round=r, letter=3)
td = p.current_team
trigram = td.participation.team.trigram
self.assertEqual(td.choose_index, i)
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'set_active', 'round': 1, 'poule': 'C', 'team': trigram})
if i == 0:
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'dice_visibility', 'visible': False})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'box_visibility', 'visible': True})
# Render page
resp = await self.async_client.get(reverse('draw:index'))
self.assertEqual(resp.status_code, 200)
# Draw a problem
await communicator.send_json_to({'tid': tid, 'type': 'draw_problem'})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'box_visibility', 'visible': False})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'buttons_visibility', 'visible': True})
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
td: TeamDraw = await TeamDraw.objects.prefetch_related('participation__team').aget(pk=td.pk)
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)
# Assume that this is the problem is i for the team i
td.purposed = i + 1
await td.asave()
# Render page
resp = await self.async_client.get(reverse('draw:index'))
self.assertEqual(resp.status_code, 200)
# Accept the problem
await communicator.send_json_to({'tid': tid, 'type': 'accept'})
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})
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:
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')
# Render page
resp = await self.async_client.get(reverse('draw:index'))
self.assertEqual(resp.status_code, 200)
# Start round 2
draw: Draw = await Draw.objects.prefetch_related(
'current_round__current_pool__current_team__participation__team').aget(tournament=self.tournament)
r = draw.current_round
p = r.current_pool
self.assertEqual(r.number, 2)
self.assertEqual(p.letter, 1)
for j in range(12):
# Reset dices
self.assertIsNone((await communicator.receive_json_from())['result'])
# Get pools
resp = await communicator.receive_json_from()
self.assertEqual(resp['type'], 'set_poules')
self.assertEqual(resp['round'], 2)
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'dice_visibility', 'visible': True})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'export_visibility', 'visible': True})
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
# Render page
resp = await self.async_client.get(reverse('draw:index'))
self.assertEqual(resp.status_code, 200)
for i in range(3):
# Iterate on each pool
r: Round = await Round.objects.prefetch_related('current_pool__current_team__participation__team') \
.aget(draw=draw, number=2)
p = r.current_pool
self.assertEqual(p.letter, i + 1)
self.assertEqual(p.size, 5 - i)
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'set_active', 'round': 2, 'poule': chr(65 + i), 'team': None})
j = 0
async for td in p.teamdraw_set.prefetch_related('participation__team').all():
# Launch a new dice
await communicator.send_json_to({'tid': tid, 'type': "dice", 'trigram': td.participation.team.trigram})
await communicator.receive_json_from()
await td.arefresh_from_db()
td.choice_dice = 101 + j # Avoid duplicates
await td.asave()
j += 1
resp = await communicator.receive_json_from()
self.assertEqual(resp['type'], 'set_info')
for j in range(5 - i):
# Next team
p: Pool = await Pool.objects.prefetch_related('current_team__participation__team').aget(round=r,
letter=i + 1)
td = p.current_team
trigram = td.participation.team.trigram
self.assertEqual(td.choose_index, j)
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'set_active', 'round': 2, 'poule': chr(65 + i),
'team': trigram})
if j == 0:
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'dice_visibility', 'visible': False})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'box_visibility', 'visible': True})
# Render page
resp = await self.async_client.get(reverse('draw:index'))
self.assertEqual(resp.status_code, 200)
# Draw a problem
await communicator.send_json_to({'tid': tid, 'type': 'draw_problem'})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'box_visibility', 'visible': False})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'buttons_visibility', 'visible': True})
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
td: TeamDraw = await TeamDraw.objects.prefetch_related('participation__team').aget(pk=td.pk)
self.assertIsNotNone(td.purposed)
self.assertIn(td.purposed, range(1, len(settings.PROBLEMS) + 1))
# Check that the problem is different from the previous day
old_td = await TeamDraw.objects.aget(round__number=1, round__draw=draw,
participation_id=td.participation_id)
self.assertNotEqual(td.purposed, old_td.accepted)
# Accept the problem
await communicator.send_json_to({'tid': tid, 'type': 'accept'})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'buttons_visibility', 'visible': False})
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:
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:
# Reorder the pool since there are 5 teams
self.assertEqual((await communicator.receive_json_from())['type'], 'reorder_poule')
if i < 2:
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'dice_visibility', 'visible': True})
else:
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'export_visibility', 'visible': True})
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
self.assertEqual((await communicator.receive_json_from())['type'], 'set_active')
# Export the draw
await communicator.send_json_to({'tid': tid, 'type': 'export'})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'export_visibility', 'visible': False})
# Cancel all steps and reset all
for i in range(1000):
await communicator.send_json_to({'tid': tid, 'type': 'cancel'})
# Purge receive queue
while True:
try:
await communicator.receive_json_from()
except asyncio.TimeoutError:
break
if await Draw.objects.filter(tournament_id=tid).aexists():
print((await Draw.objects.filter(tournament_id=tid).aexists()))
current_state = (await Draw.objects.filter(tournament_id=tid).prefetch_related(
'current_round__current_pool__current_team__participation__team').aget()).get_state()
raise AssertionError("Draw wasn't aborted after 1000 steps, current state: " + current_state)
# Abort while the tournament is already aborted
await communicator.send_json_to({'tid': tid, 'type': "abort"})
def test_admin_pages(self):
"""
Check that admin pages are rendering successfully.
"""
self.client.force_login(self.superuser)
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)
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)
p22 = Pool.objects.create(round=r2, letter=2, size=4)
p23 = Pool.objects.create(round=r2, letter=3, size=3)
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))
tds.append(TeamDraw.objects.create(participation=team.participation,
round=r2,
pool=p21) if i < 5 else p22 if i < 9 else p23)
p11.current_team = tds[0]
p11.save()
r1.current_pool = p11
r1.save()
draw.current_round = r1
draw.save()
response = self.client.get(reverse("admin:index") + "draw/draw/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index")
+ f"draw/draw/{draw.pk}/change/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index") +
f"r/{ContentType.objects.get_for_model(Draw).id}/"
f"{draw.pk}/")
self.assertRedirects(response, "http://" + Site.objects.get().domain +
str(draw.get_absolute_url()), 302, 200)
response = self.client.get(reverse("admin:index") + "draw/round/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index")
+ f"draw/round/{r1.pk}/change/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index")
+ f"draw/round/{r2.pk}/change/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index") +
f"r/{ContentType.objects.get_for_model(Round).id}/"
f"{r1.pk}/")
self.assertRedirects(response, "http://" + Site.objects.get().domain +
str(r1.get_absolute_url()), 302, 200)
response = self.client.get(reverse("admin:index") + "draw/pool/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index")
+ f"draw/pool/{p11.pk}/change/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index") +
f"r/{ContentType.objects.get_for_model(Pool).id}/"
f"{p11.pk}/")
self.assertRedirects(response, "http://" + Site.objects.get().domain +
str(p11.get_absolute_url()), 302, 200)
response = self.client.get(reverse("admin:index") + "draw/teamdraw/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index")
+ f"draw/teamdraw/{tds[0].pk}/change/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index") +
f"r/{ContentType.objects.get_for_model(TeamDraw).id}/"
f"{tds[0].pk}/")
self.assertRedirects(response, "http://" + Site.objects.get().domain +
str(tds[0].get_absolute_url()), 302, 200)

13
draw/urls.py Normal file
View File

@ -0,0 +1,13 @@
# Copyright (C) 2023 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path
from .views import DisplayView
app_name = "draw"
urlpatterns = [
path('', DisplayView.as_view(), name='index'),
]

43
draw/views.py Normal file
View File

@ -0,0 +1,43 @@
# Copyright (C) 2023 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView
from participation.models import Tournament
class DisplayView(LoginRequiredMixin, TemplateView):
"""
This view is the main interface of the drawing system, which is working
with Javascript and websockets.
"""
template_name = 'draw/index.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
reg = self.request.user.registration
if reg.is_admin:
# Administrators can manage all tournaments
tournaments = Tournament.objects.order_by('id').all()
elif reg.is_volunteer:
# A volunteer can see their tournaments
tournaments = reg.interesting_tournaments
else:
if not reg.team:
raise PermissionDenied(_("You are not in a team."))
# A participant can see its own tournament, or the final if necessary
tournaments = [reg.team.participation.tournament] if reg.team.participation.valid else []
if reg.team.participation.final:
tournaments.append(Tournament.final_tournament())
context['tournaments'] = tournaments
# This will be useful for JavaScript data
context['tournaments_simplified'] = [{'id': t.id, 'name': t.name} for t in tournaments]
context['problems'] = settings.PROBLEMS
return context

View File

@ -8,7 +8,20 @@ python manage.py loaddata initial
nginx nginx
if [ "$TFJM_STAGE" = "prod" ]; then if [ "$TFJM_STAGE" = "prod" ]; then
gunicorn -b 0.0.0.0:8000 --workers=2 --threads=4 --worker-class=gthread tfjm.wsgi --access-logfile '-' --error-logfile '-'; gunicorn -b 0.0.0.0:8000 \
--workers=2 \
--threads=4 \
--worker-class=uvicorn.workers.UvicornWorker \
tfjm.asgi \
--access-logfile '-' \
--error-logfile '-'
else else
python manage.py runserver 0.0.0.0:8000; gunicorn -b 0.0.0.0:8000 \
--workers=2 \
--threads=4 \
--worker-class=uvicorn.workers.UvicornWorker \
tfjm.asgi \
--access-logfile '-' \
--error-logfile '-' \
--reload
fi fi

File diff suppressed because it is too large Load Diff

View File

@ -34,13 +34,13 @@ def pre_save_object(sender, instance, **kwargs):
instance._previous = None instance._previous = None
def save_object(sender, instance, **kwargs): def save_object(sender, instance, raw, **kwargs):
""" """
Each time a model is saved, an entry in the table `Changelog` is added in the database Each time a model is saved, an entry in the table `Changelog` is added in the database
in order to store each modification made in order to store each modification made
""" """
# noinspection PyProtectedMember # noinspection PyProtectedMember
if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal"): if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal") or raw:
return return
# noinspection PyProtectedMember # noinspection PyProtectedMember

View File

@ -10,9 +10,15 @@ server {
location / { location / {
proxy_pass http://tfjm; proxy_pass http://tfjm;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off; proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $server_name;
} }
location /static { location /static {

135
participation/admin.py Normal file
View File

@ -0,0 +1,135 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament, Tweak
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ('name', 'trigram', 'tournament', 'valid', 'final',)
search_fields = ('name', 'trigram',)
list_filter = ('participation__valid', 'participation__tournament', 'participation__final',)
@admin.display(description=_("tournament"))
def tournament(self, record):
return record.participation.tournament
@admin.display(description=_("valid"), boolean=True)
def valid(self, team):
return team.participation.valid
@admin.display(description=_("selected for final"), boolean=True)
def final(self, team):
return team.participation.final
@admin.register(Participation)
class ParticipationAdmin(admin.ModelAdmin):
list_display = ('team', 'tournament', 'valid', 'final',)
search_fields = ('team__name', 'team__trigram',)
list_filter = ('valid',)
autocomplete_fields = ('team', 'tournament',)
@admin.register(Pool)
class PoolAdmin(admin.ModelAdmin):
list_display = ('__str__', 'tournament', 'round', 'letter', 'teams',)
list_filter = ('tournament', 'round', 'letter',)
search_fields = ('participations__team__name', 'participations__team__trigram',)
autocomplete_fields = ('tournament', 'participations', 'juries',)
@admin.display(description=_("teams"))
def teams(self, record: Pool):
return ', '.join(p.team.trigram for p in record.participations.all())
@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',)
@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"))
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=_("tournament"))
def tournament(self, record: Passage):
return record.pool.tournament
@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',)
autocomplete_fields = ('jury', 'passage',)
@admin.display(description=_("pool"))
def pool(self, record):
return record.passage.pool.get_letter_display()
@admin.register(Solution)
class SolutionAdmin(admin.ModelAdmin):
list_display = ('team', 'tournament', 'problem', 'final_solution',)
list_filter = ('problem', 'participation__tournament', 'final_solution',)
search_fields = ('participation__team__name', 'participation__team__trigram',)
autocomplete_fields = ('participation',)
@admin.display(ordering='participation__team', description=_("team"))
def team(self, record):
return record.participation.team
@admin.display(ordering='participation__tournament__name', description=_("tournament"))
def tournament(self, record):
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',)
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=_("problem"))
def problem(self, record: Synthesis):
return record.passage.solution_number
@admin.register(Tournament)
class TournamentAdmin(admin.ModelAdmin):
list_display = ('name',)
search_fields = ('name',)
autocomplete_fields = ('organizers',)
@admin.register(Tweak)
class TweakAdmin(admin.ModelAdmin):
list_display = ('participation', 'pool', 'diff',)
autocomplete_fields = ('participation', 'pool',)

View File

@ -6,12 +6,17 @@ from io import StringIO
import re import re
from typing import Iterable 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 django import forms from django import forms
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator 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 _ from django.utils.translation import gettext_lazy as _
from pypdf import PdfFileReader from pypdf import PdfReader
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament
@ -23,7 +28,7 @@ class TeamForm(forms.ModelForm):
def clean_name(self): def clean_name(self):
if "name" in self.cleaned_data: if "name" in self.cleaned_data:
name = self.cleaned_data["name"] name = self.cleaned_data["name"]
if not self.instance.pk and Team.objects.filter(name=name).exists(): if Team.objects.filter(name=name).exclude(pk=self.instance.pk).exists():
raise ValidationError(_("This name is already used.")) raise ValidationError(_("This name is already used."))
return name return name
@ -33,7 +38,7 @@ class TeamForm(forms.ModelForm):
if not re.match("[A-Z]{3}", trigram): if not re.match("[A-Z]{3}", trigram):
raise ValidationError(_("The trigram must be composed of three uppercase letters.")) raise ValidationError(_("The trigram must be composed of three uppercase letters."))
if not self.instance.pk and Team.objects.filter(trigram=trigram).exists(): if Team.objects.filter(trigram=trigram).exclude(pk=self.instance.pk).exists():
raise ValidationError(_("This trigram is already used.")) raise ValidationError(_("This trigram is already used."))
return trigram return trigram
@ -151,7 +156,7 @@ class SolutionForm(forms.ModelForm):
raise ValidationError(_("The uploaded file size must be under 5 Mo.")) raise ValidationError(_("The uploaded file size must be under 5 Mo."))
if file.content_type != "application/pdf": if file.content_type != "application/pdf":
raise ValidationError(_("The uploaded file must be a PDF file.")) raise ValidationError(_("The uploaded file must be a PDF file."))
pdf_reader = PdfFileReader(file) pdf_reader = PdfReader(file)
pages = len(pdf_reader.pages) pages = len(pdf_reader.pages)
if pages > 30: if pages > 30:
raise ValidationError(_("The PDF file must not have more than 30 pages.")) raise ValidationError(_("The PDF file must not have more than 30 pages."))
@ -170,7 +175,7 @@ class SolutionForm(forms.ModelForm):
class PoolForm(forms.ModelForm): class PoolForm(forms.ModelForm):
class Meta: class Meta:
model = Pool model = Pool
fields = ('tournament', 'round', 'bbb_url', 'results_available', 'juries',) fields = ('tournament', 'round', 'letter', 'bbb_url', 'results_available', 'juries',)
widgets = { widgets = {
"juries": forms.SelectMultiple(attrs={ "juries": forms.SelectMultiple(attrs={
'class': 'selectpicker', 'class': 'selectpicker',
@ -198,6 +203,48 @@ class PoolTeamsForm(forms.ModelForm):
} }
class AddJuryForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_class = 'form-inline'
self.helper.layout = Fieldset(
_("Add new jury"),
Div(
Div(
InlineField('first_name', autofocus="autofocus"),
css_class='col-xl-3',
),
Div(
InlineField('last_name'),
css_class='col-xl-3',
),
Div(
InlineField('email'),
css_class='col-xl-5',
),
Div(
Submit('submit', _("Add")),
css_class='col-xl-1',
),
css_class='row',
)
)
def clean_email(self):
"""
Ensure that the email address is unique.
"""
email = self.data["email"]
if User.objects.filter(email=email).exists():
self.add_error("email", _("This email address is already used."))
return email
class Meta:
model = User
fields = ('first_name', 'last_name', 'email',)
class UploadNotesForm(forms.Form): class UploadNotesForm(forms.Form):
file = forms.FileField( file = forms.FileField(
label=_("CSV file:"), label=_("CSV file:"),
@ -215,40 +262,57 @@ class UploadNotesForm(forms.Form):
file = cleaned_data['file'] file = cleaned_data['file']
with file: with file:
try: try:
csvfile = csv.reader(StringIO(file.read().decode())) data: bytes = file.read()
try:
content = data.decode()
except UnicodeDecodeError: except UnicodeDecodeError:
self.add_error('file', _("This file contains non-UTF-8 content. " # This is not UTF-8, grrrr
"Please send your sheet as a CSV file.")) content = data.decode('latin1')
csvfile = csv.reader(StringIO(content))
self.process(csvfile, cleaned_data) self.process(csvfile, cleaned_data)
except UnicodeDecodeError:
self.add_error('file', _("This file contains non-UTF-8 and non-ISO-8859-1 content. "
"Please send your sheet as a CSV file."))
return cleaned_data return cleaned_data
def process(self, csvfile: Iterable[str], cleaned_data: dict): def process(self, csvfile: Iterable[str], cleaned_data: dict):
parsed_notes = {} 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: for line in csvfile:
line = [s for s in line if s] line = [s.strip() for s in line if s]
if len(line) < 19: if line and line[0] == 'Problème':
continue pool_size = len(line) - 1
name = line[0] if pool_size < 3 or pool_size > 5:
notes = line[1:19] self.add_error('file', _("Can't determine the pool size. Are you sure your file is correct?"))
if not all(s.isnumeric() for s in notes): return
continue line_length = valid_lengths[pool_size - 3]
notes = list(map(int, notes))
if max(notes) < 3 or min(notes) < 0:
continue continue
max_notes = 3 * [20, 16, 9, 10, 9, 10] 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"]:
continue
notes = line[1:line_length]
if not all(s.isnumeric() or s[0] == '-' and s[1:].isnumeric() for s in notes):
continue
notes = list(map(int, notes))
max_notes = pool_size * ([20, 16, 9, 10, 9, 10] + ([4] if pool_size == 4 else []))
for n, max_n in zip(notes, max_notes): for n, max_n in zip(notes, max_notes):
if n > max_n: if n > max_n:
self.add_error('file', self.add_error('file',
_("The following note is higher of the maximum expected value:") _("The following note is higher of the maximum expected value:")
+ str(n) + " > " + str(max_n)) + str(n) + " > " + str(max_n))
first_name, last_name = tuple(name.split(' ', 1)) # Search by "{first_name} {last_name}"
jury = User.objects.annotate(full_name=Concat('first_name', Value(' '), 'last_name',
jury = User.objects.filter(first_name=first_name, last_name=last_name, output_field=CharField())) \
registration__volunteerregistration__isnull=False) .filter(full_name=name.replace('', '\''), registration__volunteerregistration__isnull=False)
if jury.count() != 1: if jury.count() != 1:
self.add_error('file', _("The following user was not found:") + " " + name) self.add_error('file', _("The following user was not found:") + " " + name)
continue continue
@ -276,7 +340,7 @@ class PassageForm(forms.ModelForm):
class Meta: class Meta:
model = Passage model = Passage
fields = ('solution_number', 'defender', 'opponent', 'reporter', 'defender_penalties',) fields = ('position', 'solution_number', 'defender', 'opponent', 'reporter', 'observer', 'defender_penalties',)
class SynthesisForm(forms.ModelForm): class SynthesisForm(forms.ModelForm):
@ -287,6 +351,10 @@ class SynthesisForm(forms.ModelForm):
raise ValidationError(_("The uploaded file size must be under 2 Mo.")) raise ValidationError(_("The uploaded file size must be under 2 Mo."))
if file.content_type != "application/pdf": if file.content_type != "application/pdf":
raise ValidationError(_("The uploaded file must be a PDF file.")) raise ValidationError(_("The uploaded file must be a PDF file."))
pdf_reader = PdfReader(file)
pages = len(pdf_reader.pages)
if pages > 2:
raise ValidationError(_("The PDF file must not have more than 2 pages."))
return self.cleaned_data["file"] return self.cleaned_data["file"]
def save(self, commit=True): def save(self, commit=True):
@ -303,4 +371,4 @@ class NoteForm(forms.ModelForm):
class Meta: class Meta:
model = Note model = Note
fields = ('defender_writing', 'defender_oral', 'opponent_writing', fields = ('defender_writing', 'defender_oral', 'opponent_writing',
'opponent_oral', 'reporter_writing', 'reporter_oral', ) 'opponent_oral', 'reporter_writing', 'reporter_oral', 'observer_oral', )

View File

View File

@ -23,7 +23,7 @@ class Command(BaseCommand):
token = response['access_token'] token = response['access_token']
organization = "animath" organization = "animath"
form_slug = "tfjm-2022-tournois-regionaux" form_slug = "tfjm-2023-tournois-regionaux"
from_date = "2000-01-01" from_date = "2000-01-01"
url = f"https://api.helloasso.com/v5/organizations/{organization}/forms/Event/{form_slug}/payments" \ url = f"https://api.helloasso.com/v5/organizations/{organization}/forms/Event/{form_slug}/payments" \
f"?from={from_date}&pageIndex=1&pageSize=100&retrieveOfflineDonations=false" f"?from={from_date}&pageIndex=1&pageSize=100&retrieveOfflineDonations=false"

View File

@ -4,8 +4,7 @@
from django.core.management import BaseCommand from django.core.management import BaseCommand
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.translation import activate from django.utils.translation import activate
from participation.models import Tournament
from .models import Tournament
class Command(BaseCommand): class Command(BaseCommand):

View File

@ -3,29 +3,17 @@
from pathlib import Path from pathlib import Path
from django.conf import settings
from django.core.management import BaseCommand from django.core.management import BaseCommand
from django.utils.translation import activate from django.utils.translation import activate
from participation.models import Solution, Tournament
from .models import Solution, Tournament
PROBLEMS = [
"Pliage de polygones",
"Mélodie des hirondelles",
"Professeur confiné",
"Nain sans mémoire",
"Bricolage microscopique",
"Villes jumelées",
"Promenade de chiens",
"Persée et la Gorgone",
]
class Command(BaseCommand): class Command(BaseCommand):
def handle(self, *args, **kwargs): def handle(self, *args, **kwargs):
activate('fr') activate('fr')
base_dir = Path(__file__).parent.parent.parent.parent.parent base_dir = Path(__file__).parent.parent.parent.parent
base_dir /= "output" base_dir /= "output"
if not base_dir.is_dir(): if not base_dir.is_dir():
base_dir.mkdir() base_dir.mkdir()
@ -41,7 +29,7 @@ class Command(BaseCommand):
if not base_dir.is_dir(): if not base_dir.is_dir():
base_dir.mkdir() base_dir.mkdir()
for problem_id, problem_name in enumerate(PROBLEMS): for problem_id, problem_name in enumerate(settings.PROBLEMS):
dir_name = f"Problème n°{problem_id + 1} : {problem_name}" dir_name = f"Problème n°{problem_id + 1} : {problem_name}"
problem_dir = base_dir / dir_name problem_dir = base_dir / dir_name
if not problem_dir.is_dir(): if not problem_dir.is_dir():

View File

@ -30,7 +30,7 @@ class Command(BaseCommand):
else: else:
stat_file = os.stat("tfjm/static/logo.png") stat_file = os.stat("tfjm/static/logo.png")
with open("tfjm/static/logo.png", "rb") as f: with open("tfjm/static/logo.png", "rb") as f:
resp = (await Matrix.upload(f, filename="logo.png", content_type="image/png", resp = (await Matrix.upload(f, filename="../../../tfjm/static/logo.png", content_type="image/png",
filesize=stat_file.st_size))[0][0] filesize=stat_file.st_size))[0][0]
avatar_uri = resp.content_uri avatar_uri = resp.content_uri
with open(".matrix_avatar", "w") as f: with open(".matrix_avatar", "w") as f:
@ -66,7 +66,7 @@ class Command(BaseCommand):
visibility=RoomVisibility.public, visibility=RoomVisibility.public,
alias="bienvenue", alias="bienvenue",
name="Bienvenue", name="Bienvenue",
topic="Bienvenue au TFJM² 2022 !", topic="Bienvenue au TFJM² 2023 !",
federate=False, federate=False,
preset=RoomPreset.public_chat, preset=RoomPreset.public_chat,
) )

View File

@ -3,7 +3,6 @@
import datetime import datetime
import django.core.validators import django.core.validators
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone import django.utils.timezone
import participation.models import participation.models

View File

@ -0,0 +1,30 @@
# Generated by Django 4.1.7 on 2023-03-31 15:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("participation", "0003_alter_team_trigram"),
]
operations = [
migrations.AlterModelOptions(
name="pool",
options={
"ordering": ("round", "letter"),
"verbose_name": "pool",
"verbose_name_plural": "pools",
},
),
migrations.AddField(
model_name="pool",
name="letter",
field=models.PositiveSmallIntegerField(
choices=[(1, "A"), (2, "B"), (3, "C"), (4, "D")],
default=1,
verbose_name="letter",
),
preserve_default=False,
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.1.7 on 2023-04-03 17:12
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("participation", "0004_alter_pool_options_pool_letter"),
]
operations = [
migrations.AlterModelOptions(
name="team",
options={
"ordering": ("trigram",),
"verbose_name": "team",
"verbose_name_plural": "teams",
},
),
]

View File

@ -0,0 +1,44 @@
# Generated by Django 4.2 on 2023-04-06 22:05
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("participation", "0005_alter_team_options"),
]
operations = [
migrations.AlterModelOptions(
name="passage",
options={
"ordering": ("pool", "position"),
"verbose_name": "passage",
"verbose_name_plural": "passages",
},
),
migrations.AddField(
model_name="passage",
name="position",
field=models.PositiveSmallIntegerField(
choices=[(1, 1), (2, 2), (3, 3), (4, 4), (5, 5)],
default=1,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(5),
],
verbose_name="position",
),
),
migrations.AlterField(
model_name="participation",
name="valid",
field=models.BooleanField(
default=None,
help_text="The participation got the validation of the organizers.",
null=True,
verbose_name="valid team",
),
),
]

View File

@ -0,0 +1,45 @@
# Generated by Django 4.2 on 2023-04-07 10:07
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("participation", "0006_alter_passage_options_passage_position_and_more"),
]
operations = [
migrations.AddField(
model_name="note",
name="observer_oral",
field=models.SmallIntegerField(
choices=[
(-4, -4),
(-3, -3),
(-2, -2),
(-1, -1),
(0, 0),
(1, 1),
(2, 2),
(3, 3),
(4, 4),
],
default=0,
verbose_name="observer note",
),
),
migrations.AddField(
model_name="passage",
name="observer",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="+",
to="participation.participation",
verbose_name="observer",
),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.2 on 2023-04-11 20:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("participation", "0007_note_observer_oral_passage_observer"),
]
operations = [
migrations.AlterModelOptions(
name="participation",
options={
"ordering": ("valid", "team__trigram"),
"verbose_name": "participation",
"verbose_name_plural": "participations",
},
),
]

View File

@ -6,7 +6,7 @@ import os
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models from django.db import models
from django.db.models import Index from django.db.models import Index
from django.urls import reverse_lazy from django.urls import reverse_lazy
@ -124,6 +124,7 @@ class Team(models.Model):
class Meta: class Meta:
verbose_name = _("team") verbose_name = _("team")
verbose_name_plural = _("teams") verbose_name_plural = _("teams")
ordering = ('trigram',)
indexes = [ indexes = [
Index(fields=("trigram", )), Index(fields=("trigram", )),
] ]
@ -278,6 +279,12 @@ class Tournament(models.Model):
return Synthesis.objects.filter(final_solution=True) return Synthesis.objects.filter(final_solution=True)
return Synthesis.objects.filter(participation__tournament=self) return Synthesis.objects.filter(participation__tournament=self)
@property
def best_format(self):
n = len(self.participations.filter(valid=True).all())
fmt = [n] if n <= 5 else [3] * (n // 3 - 1) + [3 + n % 3]
return '+'.join(map(str, sorted(fmt, reverse=True)))
def get_absolute_url(self): def get_absolute_url(self):
return reverse_lazy("participation:tournament_detail", args=(self.pk,)) return reverse_lazy("participation:tournament_detail", args=(self.pk,))
@ -315,7 +322,7 @@ class Participation(models.Model):
valid = models.BooleanField( valid = models.BooleanField(
null=True, null=True,
default=None, default=None,
verbose_name=_("valid"), verbose_name=_("valid team"),
help_text=_("The participation got the validation of the organizers."), help_text=_("The participation got the validation of the organizers."),
) )
@ -334,6 +341,7 @@ class Participation(models.Model):
class Meta: class Meta:
verbose_name = _("participation") verbose_name = _("participation")
verbose_name_plural = _("participations") verbose_name_plural = _("participations")
ordering = ('valid', 'team__trigram',)
class Pool(models.Model): class Pool(models.Model):
@ -352,6 +360,16 @@ class Pool(models.Model):
] ]
) )
letter = models.PositiveSmallIntegerField(
choices=[
(1, 'A'),
(2, 'B'),
(3, 'C'),
(4, 'D'),
],
verbose_name=_('letter'),
)
participations = models.ManyToManyField( participations = models.ManyToManyField(
Participation, Participation,
related_name="pools", related_name="pools",
@ -381,12 +399,16 @@ class Pool(models.Model):
@property @property
def solutions(self): def solutions(self):
return Solution.objects.filter(participation__in=self.participations, final_solution=self.tournament.final) return [passage.defended_solution for passage in self.passages.all()]
def average(self, participation): def average(self, participation):
return sum(passage.average(participation) for passage in self.passages.all()) \ return sum(passage.average(participation) for passage in self.passages.all()) \
+ sum(tweak.diff for tweak in participation.tweaks.filter(pool=self).all()) + sum(tweak.diff for tweak in participation.tweaks.filter(pool=self).all())
async def aaverage(self, participation):
return sum([passage.average(participation) async for passage in self.passages.all()]) \
+ sum([tweak.diff async for tweak in participation.tweaks.filter(pool=self).all()])
def get_absolute_url(self): def get_absolute_url(self):
return reverse_lazy("participation:pool_detail", args=(self.pk,)) return reverse_lazy("participation:pool_detail", args=(self.pk,))
@ -399,6 +421,7 @@ class Pool(models.Model):
class Meta: class Meta:
verbose_name = _("pool") verbose_name = _("pool")
verbose_name_plural = _("pools") verbose_name_plural = _("pools")
ordering = ('round', 'letter',)
class Passage(models.Model): class Passage(models.Model):
@ -409,10 +432,17 @@ class Passage(models.Model):
related_name="passages", related_name="passages",
) )
position = models.PositiveSmallIntegerField(
verbose_name=_("position"),
choices=zip(range(1, 6), range(1, 6)),
default=1,
validators=[MinValueValidator(1), MaxValueValidator(5)],
)
solution_number = models.PositiveSmallIntegerField( solution_number = models.PositiveSmallIntegerField(
verbose_name=_("defended solution"), verbose_name=_("defended solution"),
choices=[ choices=[
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, settings.PROBLEM_COUNT + 1) (i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, len(settings.PROBLEMS) + 1)
], ],
) )
@ -437,6 +467,16 @@ class Passage(models.Model):
related_name="+", related_name="+",
) )
observer = models.ForeignKey(
Participation,
on_delete=models.PROTECT,
null=True,
blank=True,
default=None,
verbose_name=_("observer"),
related_name="+",
)
defender_penalties = models.PositiveSmallIntegerField( defender_penalties = models.PositiveSmallIntegerField(
verbose_name=_("penalties"), verbose_name=_("penalties"),
default=0, default=0,
@ -491,9 +531,25 @@ class Passage(models.Model):
def average_reporter(self) -> float: def average_reporter(self) -> float:
return self.average_reporter_writing + self.average_reporter_oral return self.average_reporter_writing + self.average_reporter_oral
@property
def average_observer(self) -> float:
return self.avg(note.observer_oral for note in self.notes.all())
@property
def averages(self):
yield self.average_defender_writing
yield self.average_defender_oral
yield self.average_opponent_writing
yield self.average_opponent_oral
yield self.average_reporter_writing
yield self.average_reporter_oral
if self.observer:
yield self.average_observer
def average(self, participation): def average(self, participation):
return self.average_defender if participation == self.defender else self.average_opponent \ return self.average_defender if participation == self.defender else self.average_opponent \
if participation == self.opponent else self.average_reporter if participation == self.reporter else 0 if participation == self.opponent else self.average_reporter if participation == self.reporter \
else self.average_observer if participation == self.observer else 0
def get_absolute_url(self): def get_absolute_url(self):
return reverse_lazy("participation:passage_detail", args=(self.pk,)) return reverse_lazy("participation:passage_detail", args=(self.pk,))
@ -508,6 +564,9 @@ class Passage(models.Model):
if self.reporter not in self.pool.participations.all(): if self.reporter not in self.pool.participations.all():
raise ValidationError(_("Team {trigram} is not registered in the pool.") raise ValidationError(_("Team {trigram} is not registered in the pool.")
.format(trigram=self.reporter.team.trigram)) .format(trigram=self.reporter.team.trigram))
if self.observer and self.observer not in self.pool.participations.all():
raise ValidationError(_("Team {trigram} is not registered in the pool.")
.format(trigram=self.observer.team.trigram))
return super().clean() return super().clean()
def __str__(self): def __str__(self):
@ -517,6 +576,7 @@ class Passage(models.Model):
class Meta: class Meta:
verbose_name = _("passage") verbose_name = _("passage")
verbose_name_plural = _("passages") verbose_name_plural = _("passages")
ordering = ('pool', 'position',)
class Tweak(models.Model): class Tweak(models.Model):
@ -566,7 +626,7 @@ class Solution(models.Model):
problem = models.PositiveSmallIntegerField( problem = models.PositiveSmallIntegerField(
verbose_name=_("problem"), verbose_name=_("problem"),
choices=[ choices=[
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, settings.PROBLEM_COUNT + 1) (i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, len(settings.PROBLEMS) + 1)
], ],
) )
@ -686,14 +746,31 @@ class Note(models.Model):
default=0, default=0,
) )
observer_oral = models.SmallIntegerField(
verbose_name=_("observer note"),
choices=zip(range(-4, 5), range(-4, 5)),
default=0,
)
def get_all(self):
yield self.defender_writing
yield self.defender_oral
yield self.opponent_writing
yield self.opponent_oral
yield self.reporter_writing
yield self.reporter_oral
if self.passage.observer:
yield self.observer_oral
def set_all(self, defender_writing: int, defender_oral: int, opponent_writing: int, opponent_oral: int, def set_all(self, defender_writing: int, defender_oral: int, opponent_writing: int, opponent_oral: int,
reporter_writing: int, reporter_oral: int): reporter_writing: int, reporter_oral: int, observer_oral: int = 0):
self.defender_writing = defender_writing self.defender_writing = defender_writing
self.defender_oral = defender_oral self.defender_oral = defender_oral
self.opponent_writing = opponent_writing self.opponent_writing = opponent_writing
self.opponent_oral = opponent_oral self.opponent_oral = opponent_oral
self.reporter_writing = reporter_writing self.reporter_writing = reporter_writing
self.reporter_oral = reporter_oral self.reporter_oral = reporter_oral
self.observer_oral = observer_oral
def get_absolute_url(self): def get_absolute_url(self):
return reverse_lazy("participation:passage_detail", args=(self.passage.pk,)) return reverse_lazy("participation:passage_detail", args=(self.passage.pk,))
@ -703,7 +780,7 @@ class Note(models.Model):
def __bool__(self): def __bool__(self):
return any((self.defender_writing, self.defender_oral, self.opponent_writing, self.opponent_oral, return any((self.defender_writing, self.defender_oral, self.opponent_writing, self.opponent_oral,
self.reporter_writing, self.reporter_oral)) self.reporter_writing, self.reporter_oral, self.observer_oral))
class Meta: class Meta:
verbose_name = _("note") verbose_name = _("note")

View File

@ -6,21 +6,22 @@ from participation.models import Note, Participation, Passage, Pool, Team
from tfjm.lists import get_sympa_client from tfjm.lists import get_sympa_client
def create_team_participation(instance, created, **_): def create_team_participation(instance, created, raw, **_):
""" """
When a team got created, create an associated participation. When a team got created, create an associated participation.
""" """
if not raw:
participation = Participation.objects.get_or_create(team=instance)[0] participation = Participation.objects.get_or_create(team=instance)[0]
participation.save() participation.save()
if not created: if not created:
participation.team.create_mailing_list() participation.team.create_mailing_list()
def update_mailing_list(instance: Team, **_): def update_mailing_list(instance: Team, raw, **_):
""" """
When a team name or trigram got updated, update mailing lists and Matrix rooms When a team name or trigram got updated, update mailing lists and Matrix rooms
""" """
if instance.pk: if instance.pk and not raw:
old_team = Team.objects.get(pk=instance.pk) old_team = Team.objects.get(pk=instance.pk)
if old_team.trigram != instance.trigram: if old_team.trigram != instance.trigram:
# TODO Rename Matrix room # TODO Rename Matrix room
@ -36,10 +37,11 @@ def update_mailing_list(instance: Team, **_):
f"{coach.user.first_name} {coach.user.last_name}") f"{coach.user.first_name} {coach.user.last_name}")
def create_notes(instance: Union[Passage, Pool], **_): def create_notes(instance: Union[Passage, Pool], raw, **_):
if not raw:
if isinstance(instance, Pool): if isinstance(instance, Pool):
for passage in instance.passages.all(): for passage in instance.passages.all():
create_notes(passage) create_notes(passage, raw)
return return
for jury in instance.pool.juries.all(): for jury in instance.pool.juries.all():

View File

@ -54,6 +54,7 @@ class ParticipationTable(tables.Table):
} }
model = Team model = Team
fields = ('name', 'trigram', 'valid',) fields = ('name', 'trigram', 'valid',)
order = ('-valid',)
class TournamentTable(tables.Table): class TournamentTable(tables.Table):
@ -76,13 +77,21 @@ class TournamentTable(tables.Table):
class PoolTable(tables.Table): class PoolTable(tables.Table):
teams = tables.LinkColumn( letter = tables.LinkColumn(
'participation:pool_detail', 'participation:pool_detail',
args=[tables.A('id')], args=[tables.A('id')],
verbose_name=_("pool").capitalize,
)
teams = tables.Column(
verbose_name=_("teams").capitalize, verbose_name=_("teams").capitalize,
empty_values=(), empty_values=(),
orderable=False,
) )
def render_letter(self, record):
return format_lazy(_("Pool {letter}{round}"), letter=record.get_letter_display(), round=record.round)
def render_teams(self, record): def render_teams(self, record):
return ", ".join(participation.team.trigram for participation in record.participations.all()) \ return ", ".join(participation.team.trigram for participation in record.participations.all()) \
or _("No defined team") or _("No defined team")
@ -92,7 +101,7 @@ class PoolTable(tables.Table):
'class': 'table table-condensed table-striped', 'class': 'table table-condensed table-striped',
} }
model = Pool model = Pool
fields = ('teams', 'round', 'tournament',) fields = ('letter', 'teams', 'round', 'tournament',)
class PassageTable(tables.Table): class PassageTable(tables.Table):
@ -134,4 +143,4 @@ class NoteTable(tables.Table):
} }
model = Note model = Note
fields = ('jury', 'defender_writing', 'defender_oral', 'opponent_writing', 'opponent_oral', fields = ('jury', 'defender_writing', 'defender_oral', 'opponent_writing', 'opponent_oral',
'reporter_writing', 'reporter_oral',) 'reporter_writing', 'reporter_oral', 'observer_oral',)

View File

@ -13,6 +13,9 @@
<dt class="col-sm-3">{% trans "Pool:" %}</dt> <dt class="col-sm-3">{% trans "Pool:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.pool.get_absolute_url }}">{{ passage.pool }}</a></dd> <dd class="col-sm-9"><a href="{{ passage.pool.get_absolute_url }}">{{ passage.pool }}</a></dd>
<dt class="col-sm-3">{% trans "Position:" %}</dt>
<dd class="col-sm-9">{{ passage.position }}</dd>
<dt class="col-sm-3">{% trans "Defender:" %}</dt> <dt class="col-sm-3">{% trans "Defender:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.defender.get_absolute_url }}">{{ passage.defender.team }}</a></dd> <dd class="col-sm-9"><a href="{{ passage.defender.get_absolute_url }}">{{ passage.defender.team }}</a></dd>
@ -22,6 +25,11 @@
<dt class="col-sm-3">{% trans "Reporter:" %}</dt> <dt class="col-sm-3">{% trans "Reporter:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.reporter.get_absolute_url }}">{{ passage.reporter.team }}</a></dd> <dd class="col-sm-9"><a href="{{ passage.reporter.get_absolute_url }}">{{ passage.reporter.team }}</a></dd>
{% if passage.observer %}
<dt class="col-sm-3">{% trans "Observer:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.observer.get_absolute_url }}">{{ passage.observer.team }}</a></dd>
{% endif %}
<dt class="col-sm-3">{% trans "Defended solution:" %}</dt> <dt class="col-sm-3">{% trans "Defended solution:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.defended_solution.file.url }}">{{ passage.defended_solution }}</a></dd> <dd class="col-sm-9"><a href="{{ passage.defended_solution.file.url }}">{{ passage.defended_solution }}</a></dd>
@ -79,6 +87,11 @@
<dt class="col-sm-8">{% trans "Average points for the reporter oral:" %}</dt> <dt class="col-sm-8">{% trans "Average points for the reporter oral:" %}</dt>
<dd class="col-sm-4">{{ passage.average_reporter_oral|floatformat }}/10</dd> <dd class="col-sm-4">{{ passage.average_reporter_oral|floatformat }}/10</dd>
{% if passage.observer %}
<dt class="col-sm-8">{% trans "Average points for the observer oral:" %}</dt>
<dd class="col-sm-4">{{ passage.average_observer|floatformat }}/4</dd>
{% endif %}
</dl> </dl>
<hr> <hr>
@ -92,6 +105,11 @@
<dt class="col-sm-8">{% trans "Reporter points:" %}</dt> <dt class="col-sm-8">{% trans "Reporter points:" %}</dt>
<dd class="col-sm-4">{{ passage.average_reporter|floatformat }}/19</dd> <dd class="col-sm-4">{{ passage.average_reporter|floatformat }}/19</dd>
{% if passage.observer %}
<dt class="col-sm-8">{% trans "Observer points:" %}</dt>
<dd class="col-sm-4">{{ passage.average_observer|floatformat }}/4</dd>
{% endif %}
</dl> </dl>
</div> </div>
</div> </div>
@ -124,7 +142,7 @@
initModal("updatePassage", "{% url "participation:passage_update" pk=passage.pk %}") initModal("updatePassage", "{% url "participation:passage_update" pk=passage.pk %}")
{% if my_note is not None %} {% if my_note is not None %}
initModal("updateNotesModal", "{% url "participation:update_notes" pk=my_note.pk %}") initModal("updateNotes", "{% url "participation:update_notes" pk=my_note.pk %}")
{% endif %} {% endif %}
{% elif user.registration.participates %} {% elif user.registration.participates %}
initModal("uploadSynthesis", "{% url "participation:upload_synthesis" pk=passage.pk %}") initModal("uploadSynthesis", "{% url "participation:upload_synthesis" pk=passage.pk %}")

View File

@ -0,0 +1,47 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% load i18n %}
{% block content %}
<div class="alert alert-info">
<p>
{% trans "You can here register juries for the pool." %}
{% trans "Be careful: this form register new users. To add existing users into the jury, please use this form:" %}
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updatePoolModal">{% trans "Update pool" %}</button>
</p>
<p>
{% trans "For now, the registered juries for the tournament are:" %}
<ul>
{% for jury in pool.juries.all %}
<li>{{ jury.user.first_name }} {{ jury.user.last_name }} (<a class="alert-link" href="mailto:{{ jury.user.email }}">{{ jury.user.email }}</a>)</li>
{% empty %}
<li><i>{% trans "There is no jury yet." %}</i></li>
{% endfor %}
</ul>
</p>
</div>
{% crispy form %}
<hr>
<div class="row text-center">
<a href="{% url 'participation:pool_detail' pk=pool.pk %}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> {% trans "Back to pool detail" %}
</a>
</div>
{% trans "Update pool" as modal_title %}
{% trans "Update" as modal_button %}
{% url "participation:pool_update" pk=pool.pk as modal_action %}
{% include "base_modal.html" with modal_id="updatePool" %}
{% endblock %}
{% block extrajavascript %}
<script>
document.addEventListener('DOMContentLoaded', () => {
initModal("updatePool", "{% url "participation:pool_update" pk=pool.pk %}")
})
</script>
{% endblock %}

View File

@ -15,6 +15,9 @@
<dt class="col-sm-3">{% trans "Round:" %}</dt> <dt class="col-sm-3">{% trans "Round:" %}</dt>
<dd class="col-sm-9">{{ pool.get_round_display }}</dd> <dd class="col-sm-9">{{ pool.get_round_display }}</dd>
<dt class="col-sm-3">{% trans "Letter:" %}</dt>
<dd class="col-sm-9">{{ pool.get_letter_display }}</dd>
<dt class="col-sm-3">{% trans "Teams:" %}</dt> <dt class="col-sm-3">{% trans "Teams:" %}</dt>
<dd class="col-sm-9"> <dd class="col-sm-9">
{% for participation in pool.participations.all %} {% for participation in pool.participations.all %}
@ -23,13 +26,40 @@
</dd> </dd>
<dt class="col-sm-3">{% trans "Juries:" %}</dt> <dt class="col-sm-3">{% trans "Juries:" %}</dt>
<dd class="col-sm-9">{{ pool.juries.all|join:", " }}</dd> <dd class="col-sm-9">
{{ pool.juries.all|join:", " }}
<a class="badge rounded-pill text-bg-info" href="{% url 'participation:pool_add_jurys' pk=pool.pk %}">
<i class="fas fa-plus"></i> {% trans "Add jurys" %}
</a>
</dd>
<dt class="col-sm-3">{% trans "Defended solutions:" %}</dt> <dt class="col-sm-3">{% trans "Defended solutions:" %}</dt>
<dd class="col-sm-9"> <dd class="col-sm-9">
{% for passage in pool.passages.all %} {% for passage in pool.passages.all %}
<a href="{{ passage.defended_solution.file.url }}">{{ passage.defended_solution }}{% if not forloop.last %}, {% endif %}</a> <a href="{{ passage.defended_solution.file.url }}">{{ passage.defender.team.trigram }} — {{ passage.get_solution_number_display }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %} {% endfor %}
<a href="{% url 'participation:pool_download_solutions' pk=pool.pk %}" class="badge rounded-pill text-bg-secondary">
<i class="fas fa-download"></i> {% trans "Download all" %}
</a>
</dd>
<dt class="col-sm-3">{% trans "Syntheses:" %}</dt>
<dd class="col-sm-9">
<ul class="list-group list-group-flush">
{% for passage in pool.passages.all %}
<li class="list-group-item">
{{ passage.defender.team.trigram }} — {{ passage.get_solution_number_display }} :
{% for synthesis in passage.syntheses.all %}
<a href="{{ synthesis.file.url }}">{{ synthesis.participation.team.trigram }} ({{ synthesis.get_type_display }})</a>{% if not forloop.last %}, {% endif %}
{% empty %}
{% trans "No synthesis was uploaded yet." %}
{% endfor %}
</li>
{% endfor %}
</ul>
<a href="{% url 'participation:pool_download_syntheses' pk=pool.pk %}" class="badge rounded-pill text-bg-secondary">
<i class="fas fa-download"></i> {% trans "Download all" %}
</a>
</dd> </dd>
<dt class="col-sm-3">{% trans "BigBlueButton link:" %}</dt> <dt class="col-sm-3">{% trans "BigBlueButton link:" %}</dt>
@ -47,6 +77,31 @@
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
{% if user.registration.is_volunteer %}
<div class="card-footer text-center">
<div class="btn-group">
<a class="btn btn-info" href="{% url 'participation:pool_scale_note_sheet' pk=pool.pk %}">
{% trans "Download the scale sheet" %}{% if pool.passages.count == 5 %} — {% trans "Room" %} 1{% endif %}
</a>
{% if pool.passages.count == 5 %}
<a class="btn btn-info" href="{% url 'participation:pool_scale_note_sheet' pk=pool.pk %}?page=2">
{% trans "Room" %} 2
</a>
{% endif %}
</div>
<div class="btn-group">
<a class="btn btn-info" href="{% url 'participation:pool_final_note_sheet' pk=pool.pk %}">
{% trans "Download the final notation sheet" %}{% if pool.passages.count == 5 %} — {% trans "Room" %} 1{% endif %}
</a>
{% if pool.passages.count == 5 %}
<a class="btn btn-info" href="{% url 'participation:pool_final_note_sheet' pk=pool.pk %}?page=2">
{% trans "Room" %} 2
</a>
{% endif %}
</div>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadNotesModal">{% trans "Upload notes from a CSV file" %}</button>
</div>
{% endif %}
</div> </div>
</div> </div>
{% if user.registration.is_volunteer %} {% if user.registration.is_volunteer %}
@ -54,7 +109,6 @@
<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#addPassageModal">{% trans "Add passage" %}</button> <button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#addPassageModal">{% trans "Add passage" %}</button>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updatePoolModal">{% trans "Update" %}</button> <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updatePoolModal">{% trans "Update" %}</button>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updateTeamsModal">{% trans "Update teams" %}</button> <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updateTeamsModal">{% trans "Update teams" %}</button>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadNotesModal">{% trans "Upload notes from a CSV file" %}</button>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@ -115,12 +115,14 @@
</dl> </dl>
{% if user.registration.is_volunteer %} {% if user.registration.is_volunteer %}
{% if user.registration in self.team.participation.tournament.organizers or user.registration.is_admin %}
<div class="text-center"> <div class="text-center">
<a class="btn btn-info" href="{% url "participation:team_authorizations" pk=team.pk %}"> <a class="btn btn-info" href="{% url "participation:team_authorizations" pk=team.pk %}">
<i class="fas fa-file-archive"></i> {% trans "Download all submitted authorizations" %} <i class="fas fa-file-archive"></i> {% trans "Download all submitted authorizations" %}
</a> </a>
</div> </div>
{% endif %} {% endif %}
{% endif %}
</div> </div>
<div class="card-footer text-center"> <div class="card-footer text-center">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updateTeamModal">{% trans "Update" %}</button> <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updateTeamModal">{% trans "Update" %}</button>

View File

@ -0,0 +1,126 @@
\documentclass[12pt,a4paper,landscape]{article}
\usepackage[T1]{fontenc}
\usepackage[utf8x]{inputenc}
\usepackage[french]{babel}
\usepackage[a4paper]{geometry}
\usepackage{graphicx}
\usepackage{amsmath}
\usepackage{amsfonts}
\usepackage{amssymb}
\usepackage{amsthm}
\usepackage{hyperref}
\usepackage{color}
\usepackage{mathtools}
\usepackage{comment}
\usepackage{array}
\usepackage{multirow}
\usepackage{footnote}
\usepackage{xintexpr}
\addtolength{\textwidth}{4cm}
\setlength{\parindent}{0mm}
\geometry{left=1.6cm,right=1.6cm,top=1.2cm,bottom=2cm}
\newcommand{\tfjm}{$\mathbb{TFJM}^2$}
\pagestyle{empty}
\renewcommand{\leq}{\leqslant}
\def\tfjmedition{~{{ tfjm_number }}}
\begin{document}
\thispagestyle{empty}
\begin{center}
\Large {\bf \tfjmedition$^{e}$ Tournoi Fran\c cais des Jeunes Math\'ematiciennes et Math\'ematiciens \tfjm}
\end{center}
\vspace{3mm}
\begin{center}
\begin{itemize}
{% for passage in passages.all %}
\item D\'efenseur\textperiodcentered{}se au passage {{ forloop.counter }} : \underline{\texttt{~{{ passage.defender.team.trigram }}~}} $\qquad$ probl\`eme \underline{~{{ passage.solution_number }}~}
{% endfor %}
\end{itemize}
\end{center}
\vspace{6mm}
%%%%%%%%%%%%%%%%%%%%%DEFENSEUR
\begin{tabular}{|c|p{20mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
\multicolumn{4}{|l|}{{\bf D\'efenseur\textperiodcentered{}se} \normalsize pr\'esente les id\'ees et r\'esultats principaux pour la solution du probl\`eme.} {% for passage in passages.all %}& Passage {{ forloop.counter }} {% endfor %}\\ \hline \hline
%ECRIT
\multirow{6}{3mm}{\centering \bf\'E\\ C\\ R\\ I\\ T} & \multirow{3}{20mm}{Partie scientifique} & Profondeur des r\'esultats d\'emontr\'es & [0,5] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Originalit\'e et pertinence des preuves& [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Exactitude et justesse des d\'emonstrations, algorithmes, etc. & [0,7] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multirow{2}{20mm}{Forme} & Pr\'esentation (lisibilit\'e, etc.) & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Clart\'e du raisonnement : facile \`a comprendre ou compl\`etement obscur ? & [0,3]{{ esp|safe }} \\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf TOTAL \'ECRIT (/20)} {{ esp|safe }} \\ \hline \hline
%ORAL
\multirow{8}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{4}{20mm}{Partie scientifique} & Compr\'ehension du mat\'eriel, connaissance des sujets math\'ematiques correspondants \emph{lors de la pr\'esentation} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& P\'edagogie, notamment clart\'e, exactitude et justesse des d\'emonstrations \emph{lors de la pr\'esentation} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Capacit\'e \`a r\'eagir aux questions et remarques de l'Opposant\textperiodcentered{}e et de læ Rapporteur\textperiodcentered{}e & [0,4] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Capacit\'e \`a r\'eagir aux questions et remarques du jury & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multirow{3}{20mm}{Forme} & Bri\`evet\'e et propret\'e de la pr\'esentation & [0,2] {{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}}
&& Capacit\'e de faire avancer le d\'ebat & [0,2] {{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}}
&& \emph{Conformit\'e} entre la pr\'esentation et le mat\'eriel \'ecrit & [--5,0] {{ esp|safe }} \\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf TOTAL ORAL (/16)} {{ esp|safe }} \\ \hline
\end{tabular}
\newpage
%%%%%%%%%%%%%%%%%OPPOSANT
\begin{tabular}{|c|p{20mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
\multicolumn{4}{|l|}{L' {\bf Opposant\textperiodcentered{}e} \normalsize fournit une analyse critique de la solution et de la pr\'esentation.}
{% for passage in passages.all %}& Passage {{ forloop.counter }} {% endfor %} \\ \hline \hline
%ECRIT
\multirow{4}{3mm}{\centering\bf\'E\\ C\\ R\\ I\\ T} &\multirow{2}{20mm}{Partie scientifique} & Compr\'ehension du probl\`eme, savoir \'evaluer la qualit\'e g\'en\'erale de la solution & [0,4] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Rep\'erer les erreurs et leur importance & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& Forme & Pr\'esentation (lisibilit\'e, etc.) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf TOTAL \'ECRIT (/9)} {{ esp|safe }} \\ \hline \hline
%ORAL
\multirow{6}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{3}{20mm}{Partie scientifique} & Compr\'ehension du probl\`eme, savoir \'evaluer la qualit\'e g\'en\'erale de la pr\'esentation de læ D\'efenseur\textperiodcentered{}se
& [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Rep\'erer les erreurs et leur importance & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Pertinence des questions & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& Forme & M\`ene un d\'ebat de fa\c con comp\'etente et propre. & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf TOTAL ORAL (/10)} {{ esp|safe }}\\ \hline
\end{tabular}
\vfill
%%%%%%%%%%%%%%%%%%%%%%RAPPORTEUR
\begin{tabular}{|c|p{20mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
\multicolumn{4}{|l|}{{\bf Rapporteur\textperiodcentered{}e} \normalsize \'evalue le d\'ebat entre læ D\'efenseur\textperiodcentered{}se et l'Opposant\textperiodcentered{}e.} {% for passage in passages.all %}& Passage {{ forloop.counter }} {% endfor %}\\ \hline \hline
%ECRIT
\multirow{4}{3mm}{\centering\bf\'E\\ C\\ R\\ I\\ T} &\multirow{2}{20mm}{Partie scientifique} & Compr\'ehension du probl\`eme, savoir \'evaluer la qualit\'e g\'en\'erale de la solution & [0,4] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
& & Rep\'erer les erreurs et leur importance & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& Forme & Pr\'esentation (lisibilit\'e, etc.) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf TOTAL \'ECRIT (/9)} {{ esp|safe }}\\ \hline \hline
%ORAL
\multirow{6}{3mm}{\centering\bf O\\ R\\ A\\ L} &\multirow{4}{20mm}{Partie scientifique} & Compr\'ehension du probl\`eme, savoir \'evaluer la qualit\'e g\'en\'erale de la pr\'esentation de læ D\'efenseur\textperiodcentered{}se & [0,1] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Savoir \'evaluer la qualit\'e g\'en\'erale du d\'ebat & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Rep\'erer les points importants non abord\'es & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Pertinence des questions & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& Forme & M\`ene un d\'ebat de fa\c con comp\'etente et propre. & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& \multicolumn{3}{|l|}{\bf TOTAL ORAL (/10)} {{ esp|safe }}\\ \hline
\end{tabular}
\vfill
{% if passages.count == 4 %}
%%%%%%% INTERVENTION EXCEPTIONNELLE
\begin{tabular}{|c|p{11cm}|c|p{2cm}|p{2cm}|p{2cm}|p{2cm}|}\hline
\multicolumn{3}{|l|}{L'{\bf Intervention exceptionnelle} \normalsize permet de signaler une erreur grave omise par tous.} {% for passage in passages.all %}& Passage {{ forloop.counter }} {% endfor %}\\ \hline \hline
%ORAL
\multirow{1}{3mm}{\centering\bf O\\ R\\ A\\ L}
& Toute intervention exceptionnelle non pertinente est sanctionn\'ee par une note n\'egative, l'absence d'intervention re\c coit un z\'ero forfaitaire. \phantom{pour avoir oral en entier dans la} \phantom{colonne il} \phantom{faut blablater un peu}& [-4,4] {{ esp|safe }}\\ \hline
\end{tabular}
{% endif %}
\end{document}

View File

@ -0,0 +1,90 @@
\documentclass[10pt,a4paper,landscape]{article}
\usepackage[T1]{fontenc}
\usepackage[utf8x]{inputenc}
\usepackage[french]{babel}
\usepackage[a4paper]{geometry}
\usepackage{graphicx}
\usepackage{amsmath}
\usepackage{amsfonts}
\usepackage{amssymb}
\usepackage{amsthm}
\usepackage{hyperref}
\usepackage{color}
\usepackage{mathtools}
\usepackage{comment}
\usepackage{array}
\usepackage{multirow}
\usepackage{footnote}
\usepackage{tabularx}
\usepackage{xintexpr}
\addtolength{\textwidth}{6cm}
\addtolength{\oddsidemargin}{-3cm}
\addtolength{\textheight}{2cm}
\addtolength{\topmargin}{-0.5cm}
\setlength{\parindent}{0mm}
\newcommand{\tfjm}{$\mathbb{TFJM}^2$}
\renewcommand{\leq}{\leqslant}
\def\tfjmedition{~{{ tfjm_number }}}
\begin{document}
\pagenumbering{gobble}
\centering
\Large {\bf \tfjmedition$^{e}$ Tournoi Fran\c cais des Jeunes Math\'ematiciennes et Math\'ematiciens \tfjm}\\
\vspace{3mm}
Tour {{ pool.round }} \;-- Poule {{ pool.get_letter_display }}{{ page }} \;-- {% if pool.round == 1 %}{{ pool.tournament.date_start }}{% else %}{{ pool.tournament.date_end }}{% endif %}
\vspace{15mm}
\begin{tabular}{|p{35mm}{% for passage in passages.all %}{% if passages.count == 3 %}|p{3cm}|p{3cm}{% else %}|p{2.5cm}|p{2.5cm}{% endif %}{% endfor %}|}\hline
\multirow{2}{35mm}{\LARGE R\^ole} {% for passage in passages.all %}& \multicolumn{2}{c|}{ \Large Probl\`eme {{ passage.solution_number }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
{% for passage in passages.all %}& \hspace{4mm} {\Large \'ECRIT} & \hspace{4mm} {\Large ORAL}{% endfor %} \\ \hline
\multirow{2}{35mm}{\LARGE D\'efenseur\textperiodcentered{}se} {% for passage in passages.all %}& \multicolumn{2}{c|}{\Large {{ passage.defender.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
{% for passage in passages.all %}
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 20$
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 16$
{% endfor %} & \hline
\multirow{2}{35mm}{\LARGE Opposant\textperiodcentered{}e} {% for passage in passages.all %}& \multicolumn{2}{c|}{\Large {{ passage.opponent.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
{% for passage in passages.all %}
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 9$
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 10$
{% endfor %} & \hline
\multirow{2}{35mm}{\LARGE Rapporteur\textperiodcentered{}e} {% for passage in passages.all %}& \multicolumn{2}{c|}{\Large {{ passage.reporter.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
{% for passage in passages.all %}
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 9$
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 10$
{% endfor %} & \hline
{% if passages.count == 4 %}
\multirow{4}{35mm}{\Large Intervention exceptionnelle}{% for passage in passages.all %} & \multicolumn{2}{c|}{\Large {{ passage.observer.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}\\
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}\\
& \multicolumn{2}{c|}{\centering \normalsize$-4\leq x\leq 4$}
& \multicolumn{2}{c|}{\centering \normalsize$-4\leq x\leq 4$}
& \multicolumn{2}{c|}{\centering \normalsize$-4\leq x\leq 4$}
& \multicolumn{2}{c|}{\centering \normalsize$-4\leq x\leq 4$} & \hline
{% endif %}
\end{tabular}
\vspace{15mm}
\LARGE Nom de læ jur\'e\textperiodcentered{}e :
{% if is_jury %}\underline{ {{ user.first_name|safe }} {{ user.last_name|safe }} }{% else %}\underline{\phantom{Phrase suffisamment longue pour le nom}}{% endif %}
$\qquad$ Signature : \underline{\phantom{Phrase moins longue}}
\newpage
%}
\end{document}

View File

@ -6,6 +6,11 @@
{% block content %} {% block content %}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<div id="form-content"> <div id="form-content">
<div class="alert alert-info">
<a class="alert-link" href="{% url "participation:pool_notes_template" pk=pool.pk %}">
{% trans "Download empty notation sheet" %}
</a>
</div>
{% csrf_token %} {% csrf_token %}
{{ form|crispy }} {{ form|crispy }}
</div> </div>

View File

@ -7,8 +7,9 @@
<div id="form-content"> <div id="form-content">
<div class="alert alert-info"> <div class="alert alert-info">
{% trans "Templates:" %} {% trans "Templates:" %}
<a class="alert-link" href="{% static "Fiche_synthèse.pdf" %}"> PDF</a> - <a class="alert-link" href="{% static "Fiche_synthèse.pdf" %}"> PDF</a>
<a class="alert-link" href="{% static "Fiche_synthèse.tex" %}"> TEX</a> <a class="alert-link" href="{% static "Fiche_synthèse.tex" %}"> TEX</a>
<a class="alert-link" href="{% static "Fiche_synthèse.docx" %}" title="{% trans "Warning: non-free format" %}"> DOCX</a>
</div> </div>
{% csrf_token %} {% csrf_token %}
{{ form|crispy }} {{ form|crispy }}

Some files were not shown because too many files have changed in this diff Show More