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

Compare commits

...

110 Commits

Author SHA1 Message Date
2f4755ffc7 Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-10-23 22:02:09 +02:00
230dc545f4 Fix export scripts
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-20 22:13:51 +02:00
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
3333add7e0 Fix translation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-20 11:45:21 +02:00
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
acf906b284 Fix draw template
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-10 10:11:32 +02:00
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
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
a240d7cad5 Better unique validation errors
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-10 09:56:16 +02:00
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
9734b51f53 Test draw application
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-09 00:59:35 +02:00
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
bcf4e294e0 Add odfpy in tox
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 22:38:09 +02:00
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
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
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
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
a382e089ae Add observer notes
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 12:10:25 +02:00
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
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
7dc812984b Add position field for passages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 00:06:21 +02:00
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
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
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
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
751e35ac62 Cancel draw problem
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 23:28:12 +02:00
f41b2e16ab Cancel choose problem
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 19:40:47 +02:00
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
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
7e212d011e Add comments and linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 17:52:46 +02:00
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
c1482d4802 Jury -> Juré⋅e
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 10:59:26 +02:00
16c4376941 Improve payment admin page
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 10:44:27 +02:00
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
31f5373652 Await the send notifications coroutines
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 21:21:00 +02:00
ca7cf5987c Try to fix requirements
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 20:02:59 +02:00
34390a541a Update translations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 19:57:02 +02:00
b8b4891e9b Squash migrations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 19:54:18 +02:00
9cfab53bd2 Add a lot of comments
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 19:52:44 +02:00
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
4357d51b9a Display problem names
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 11:56:13 +02:00
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
bb9f0dab22 Django 4.2 got released
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 11:12:37 +02:00
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
b3c26b8c1c Improve admin interface
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:55 +02:00
073d761a03 Add admin menu
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:55 +02:00
bd31375bf3 Fix CSV process
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:55 +02:00
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
0fa76d6f25 Add letter in pool display
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:55 +02:00
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
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
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
8668430760 Add reverse-proxy headers
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:53 +02:00
45818eae24 Add websockets as dependency
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:53 +02:00
b154c4985d Fix duplicate problem check
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:53 +02:00
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
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
7855ec2225 Fix translation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:52 +02:00
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
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
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
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
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
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
1bd9cea458 Fix update notes modal
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:51 +02:00
b838f1b3f0 Add export button
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:50 +02:00
e95d511017 Translate messages from websockets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:50 +02:00
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
3cd40ee192 Add margins
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:50 +02:00
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
e90005b192 Teams can draw a problem
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
6b5c630048 Add Abort button
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
c9fcfcf498 Add messages for better understanding
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
dec9f9be11 Update translations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
f85a563cf3 Auto-generate tables
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
5399a875c6 Draw dices
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
eb8ad4e771 Prepare template for the system
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:48 +02:00
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
bde3758c50 First interface to start draws
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:48 +02:00
88823b5252 Update database models and translations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:48 +02:00
9aa19ad3ca Fix tests
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:48 +02:00
ad4593a2f6 Prepare database model
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:47 +02:00
849194414d Fix tox
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:47 +02:00
b9ce4c737c First play with websockets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:47 +02:00
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
7364d27b4b Init new draw application
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:47 +02:00
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
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
1d81213773 Move apps in main directory
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:46 +02:00
2a545dae10 Fix add organizer view
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:33 +02:00
fc6e2593b4 PdfFileReader is deprecated, replace by PdfReader 2023-03-29 18:34:55 +02:00
ce25341496 Fix administration tab 2023-03-29 18:33:48 +02:00
57bddc5628 Fix Update Payment modal
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-03-16 14:37:51 +01:00
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
- 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:
stage: test
image: python:3.10-alpine

View File

@ -3,7 +3,7 @@ FROM python:3.11-alpine
ENV PYTHONUNBUFFERED 1
ENV DJANGO_ALLOW_ASYNC_UNSAFE 1
RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libxml2-dev libxslt-dev postgresql-dev libmagic texlive
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
@ -13,6 +13,8 @@ COPY requirements.txt /code/requirements.txt
COPY docs/requirements.txt /code/docs/requirements.txt
RUN pip install -r 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/

View File

@ -2,7 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.conf.urls import include, url
from django.urls import include, path
from rest_framework import routers
from .viewsets import UserViewSet
@ -29,6 +29,6 @@ app_name = 'api'
# Wire up our API using automatic URL routing.
# Additionally, we include login URLs for the browsable API.
urlpatterns = [
url('^', include(router.urls)),
url('^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
path('', include(router.urls)),
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 dé 100,
en cliquant sur le gros bouton. Les poules et l'ordre de passage
lors du premier tour sera l'ordre croissant des dés, c'est-à-dire
que le plus petit lancer sera le premier à passer dans la poule A."""
case 'DICE_ORDER_POULE':
# Waiting for dices to determine the choice order
s += f"""Nous passons au tirage des problèmes pour la poule
<strong>{self.current_round.current_pool}</strong>, entre les équipes
<strong>{', '.join(td.participation.team.trigram
for td in self.current_round.current_pool.teamdraw_set.all())}</strong>.
Les capitaines peuvent lancer un dé 100 en cliquant sur le gros bouton
pour déterminer l'ordre de tirage. L'équipe réalisant le plus gros score pourra
tirer en premier."""
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
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
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

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
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
in order to store each modification made
"""
# 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
# noinspection PyProtectedMember

View File

@ -10,9 +10,15 @@ server {
location / {
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_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 {

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
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.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator
from django.db.models import CharField, Value
from django.db.models.functions import Concat
from django.utils.translation import gettext_lazy as _
from pypdf import PdfFileReader
from pypdf import PdfReader
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament
@ -23,7 +28,7 @@ class TeamForm(forms.ModelForm):
def clean_name(self):
if "name" in self.cleaned_data:
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."))
return name
@ -33,7 +38,7 @@ class TeamForm(forms.ModelForm):
if not re.match("[A-Z]{3}", trigram):
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."))
return trigram
@ -151,7 +156,7 @@ class SolutionForm(forms.ModelForm):
raise ValidationError(_("The uploaded file size must be under 5 Mo."))
if file.content_type != "application/pdf":
raise ValidationError(_("The uploaded file must be a PDF file."))
pdf_reader = PdfFileReader(file)
pdf_reader = PdfReader(file)
pages = len(pdf_reader.pages)
if pages > 30:
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 Meta:
model = Pool
fields = ('tournament', 'round', 'bbb_url', 'results_available', 'juries',)
fields = ('tournament', 'round', 'letter', 'bbb_url', 'results_available', 'juries',)
widgets = {
"juries": forms.SelectMultiple(attrs={
'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):
file = forms.FileField(
label=_("CSV file:"),
@ -215,40 +262,57 @@ class UploadNotesForm(forms.Form):
file = cleaned_data['file']
with file:
try:
csvfile = csv.reader(StringIO(file.read().decode()))
data: bytes = file.read()
try:
content = data.decode()
except UnicodeDecodeError:
# This is not UTF-8, grrrr
content = data.decode('latin1')
csvfile = csv.reader(StringIO(content))
self.process(csvfile, cleaned_data)
except UnicodeDecodeError:
self.add_error('file', _("This file contains non-UTF-8 content. "
self.add_error('file', _("This file contains non-UTF-8 and non-ISO-8859-1 content. "
"Please send your sheet as a CSV file."))
self.process(csvfile, cleaned_data)
return cleaned_data
def process(self, csvfile: Iterable[str], cleaned_data: dict):
parsed_notes = {}
valid_lengths = [1 + 6 * 3, 1 + 7 * 4, 1 + 6 * 5] # Per pool sizes
pool_size = 0
line_length = 0
for line in csvfile:
line = [s for s in line if s]
if len(line) < 19:
continue
name = line[0]
notes = line[1:19]
if not all(s.isnumeric() for s in notes):
continue
notes = list(map(int, notes))
if max(notes) < 3 or min(notes) < 0:
line = [s.strip() for s in line if s]
if line and line[0] == 'Problème':
pool_size = len(line) - 1
if pool_size < 3 or pool_size > 5:
self.add_error('file', _("Can't determine the pool size. Are you sure your file is correct?"))
return
line_length = valid_lengths[pool_size - 3]
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):
if n > max_n:
self.add_error('file',
_("The following note is higher of the maximum expected value:")
+ str(n) + " > " + str(max_n))
first_name, last_name = tuple(name.split(' ', 1))
jury = User.objects.filter(first_name=first_name, last_name=last_name,
registration__volunteerregistration__isnull=False)
# Search by "{first_name} {last_name}"
jury = User.objects.annotate(full_name=Concat('first_name', Value(' '), 'last_name',
output_field=CharField())) \
.filter(full_name=name.replace('', '\''), registration__volunteerregistration__isnull=False)
if jury.count() != 1:
self.add_error('file', _("The following user was not found:") + " " + name)
continue
@ -276,7 +340,7 @@ class PassageForm(forms.ModelForm):
class Meta:
model = Passage
fields = ('solution_number', 'defender', 'opponent', 'reporter', 'defender_penalties',)
fields = ('position', 'solution_number', 'defender', 'opponent', 'reporter', 'observer', 'defender_penalties',)
class SynthesisForm(forms.ModelForm):
@ -287,6 +351,10 @@ class SynthesisForm(forms.ModelForm):
raise ValidationError(_("The uploaded file size must be under 2 Mo."))
if file.content_type != "application/pdf":
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"]
def save(self, commit=True):
@ -303,4 +371,4 @@ class NoteForm(forms.ModelForm):
class Meta:
model = Note
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']
organization = "animath"
form_slug = "tfjm-2022-tournois-regionaux"
form_slug = "tfjm-2023-tournois-regionaux"
from_date = "2000-01-01"
url = f"https://api.helloasso.com/v5/organizations/{organization}/forms/Event/{form_slug}/payments" \
f"?from={from_date}&pageIndex=1&pageSize=100&retrieveOfflineDonations=false"

View File

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

View File

@ -3,29 +3,17 @@
from pathlib import Path
from django.conf import settings
from django.core.management import BaseCommand
from django.utils.translation import activate
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",
]
from participation.models import Solution, Tournament
class Command(BaseCommand):
def handle(self, *args, **kwargs):
activate('fr')
base_dir = Path(__file__).parent.parent.parent.parent.parent
base_dir = Path(__file__).parent.parent.parent.parent
base_dir /= "output"
if not base_dir.is_dir():
base_dir.mkdir()
@ -41,7 +29,7 @@ class Command(BaseCommand):
if not base_dir.is_dir():
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}"
problem_dir = base_dir / dir_name
if not problem_dir.is_dir():

View File

@ -30,7 +30,7 @@ class Command(BaseCommand):
else:
stat_file = os.stat("tfjm/static/logo.png")
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]
avatar_uri = resp.content_uri
with open(".matrix_avatar", "w") as f:
@ -66,7 +66,7 @@ class Command(BaseCommand):
visibility=RoomVisibility.public,
alias="bienvenue",
name="Bienvenue",
topic="Bienvenue au TFJM² 2022 !",
topic="Bienvenue au TFJM² 2023 !",
federate=False,
preset=RoomPreset.public_chat,
)

View File

@ -3,7 +3,6 @@
import datetime
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
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.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.models import Index
from django.urls import reverse_lazy
@ -124,6 +124,7 @@ class Team(models.Model):
class Meta:
verbose_name = _("team")
verbose_name_plural = _("teams")
ordering = ('trigram',)
indexes = [
Index(fields=("trigram", )),
]
@ -278,6 +279,12 @@ class Tournament(models.Model):
return Synthesis.objects.filter(final_solution=True)
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):
return reverse_lazy("participation:tournament_detail", args=(self.pk,))
@ -315,7 +322,7 @@ class Participation(models.Model):
valid = models.BooleanField(
null=True,
default=None,
verbose_name=_("valid"),
verbose_name=_("valid team"),
help_text=_("The participation got the validation of the organizers."),
)
@ -334,6 +341,7 @@ class Participation(models.Model):
class Meta:
verbose_name = _("participation")
verbose_name_plural = _("participations")
ordering = ('valid', 'team__trigram',)
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(
Participation,
related_name="pools",
@ -381,12 +399,16 @@ class Pool(models.Model):
@property
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):
return sum(passage.average(participation) for passage in self.passages.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):
return reverse_lazy("participation:pool_detail", args=(self.pk,))
@ -399,6 +421,7 @@ class Pool(models.Model):
class Meta:
verbose_name = _("pool")
verbose_name_plural = _("pools")
ordering = ('round', 'letter',)
class Passage(models.Model):
@ -409,10 +432,17 @@ class Passage(models.Model):
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(
verbose_name=_("defended solution"),
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="+",
)
observer = models.ForeignKey(
Participation,
on_delete=models.PROTECT,
null=True,
blank=True,
default=None,
verbose_name=_("observer"),
related_name="+",
)
defender_penalties = models.PositiveSmallIntegerField(
verbose_name=_("penalties"),
default=0,
@ -491,9 +531,25 @@ class Passage(models.Model):
def average_reporter(self) -> float:
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):
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):
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():
raise ValidationError(_("Team {trigram} is not registered in the pool.")
.format(trigram=self.reporter.team.trigram))
if self.observer and self.observer not in self.pool.participations.all():
raise ValidationError(_("Team {trigram} is not registered in the pool.")
.format(trigram=self.observer.team.trigram))
return super().clean()
def __str__(self):
@ -517,6 +576,7 @@ class Passage(models.Model):
class Meta:
verbose_name = _("passage")
verbose_name_plural = _("passages")
ordering = ('pool', 'position',)
class Tweak(models.Model):
@ -566,7 +626,7 @@ class Solution(models.Model):
problem = models.PositiveSmallIntegerField(
verbose_name=_("problem"),
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,
)
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,
reporter_writing: int, reporter_oral: int):
reporter_writing: int, reporter_oral: int, observer_oral: int = 0):
self.defender_writing = defender_writing
self.defender_oral = defender_oral
self.opponent_writing = opponent_writing
self.opponent_oral = opponent_oral
self.reporter_writing = reporter_writing
self.reporter_oral = reporter_oral
self.observer_oral = observer_oral
def get_absolute_url(self):
return reverse_lazy("participation:passage_detail", args=(self.passage.pk,))
@ -703,7 +780,7 @@ class Note(models.Model):
def __bool__(self):
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:
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
def create_team_participation(instance, created, **_):
def create_team_participation(instance, created, raw, **_):
"""
When a team got created, create an associated participation.
"""
participation = Participation.objects.get_or_create(team=instance)[0]
participation.save()
if not created:
participation.team.create_mailing_list()
if not raw:
participation = Participation.objects.get_or_create(team=instance)[0]
participation.save()
if not created:
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
"""
if instance.pk:
if instance.pk and not raw:
old_team = Team.objects.get(pk=instance.pk)
if old_team.trigram != instance.trigram:
# TODO Rename Matrix room
@ -36,11 +37,12 @@ def update_mailing_list(instance: Team, **_):
f"{coach.user.first_name} {coach.user.last_name}")
def create_notes(instance: Union[Passage, Pool], **_):
if isinstance(instance, Pool):
for passage in instance.passages.all():
create_notes(passage)
return
def create_notes(instance: Union[Passage, Pool], raw, **_):
if not raw:
if isinstance(instance, Pool):
for passage in instance.passages.all():
create_notes(passage, raw)
return
for jury in instance.pool.juries.all():
Note.objects.get_or_create(jury=jury, passage=instance)
for jury in instance.pool.juries.all():
Note.objects.get_or_create(jury=jury, passage=instance)

View File

@ -54,6 +54,7 @@ class ParticipationTable(tables.Table):
}
model = Team
fields = ('name', 'trigram', 'valid',)
order = ('-valid',)
class TournamentTable(tables.Table):
@ -76,13 +77,21 @@ class TournamentTable(tables.Table):
class PoolTable(tables.Table):
teams = tables.LinkColumn(
letter = tables.LinkColumn(
'participation:pool_detail',
args=[tables.A('id')],
verbose_name=_("pool").capitalize,
)
teams = tables.Column(
verbose_name=_("teams").capitalize,
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):
return ", ".join(participation.team.trigram for participation in record.participations.all()) \
or _("No defined team")
@ -92,7 +101,7 @@ class PoolTable(tables.Table):
'class': 'table table-condensed table-striped',
}
model = Pool
fields = ('teams', 'round', 'tournament',)
fields = ('letter', 'teams', 'round', 'tournament',)
class PassageTable(tables.Table):
@ -134,4 +143,4 @@ class NoteTable(tables.Table):
}
model = Note
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>
<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>
<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>
<dd class="col-sm-9"><a href="{{ passage.reporter.get_absolute_url }}">{{ passage.reporter.team }}</a></dd>
{% if passage.observer %}
<dt class="col-sm-3">{% trans "Observer:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.observer.get_absolute_url }}">{{ passage.observer.team }}</a></dd>
{% endif %}
<dt class="col-sm-3">{% trans "Defended solution:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.defended_solution.file.url }}">{{ passage.defended_solution }}</a></dd>
@ -79,6 +87,11 @@
<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>
{% if passage.observer %}
<dt class="col-sm-8">{% trans "Average points for the observer oral:" %}</dt>
<dd class="col-sm-4">{{ passage.average_observer|floatformat }}/4</dd>
{% endif %}
</dl>
<hr>
@ -92,6 +105,11 @@
<dt class="col-sm-8">{% trans "Reporter points:" %}</dt>
<dd class="col-sm-4">{{ passage.average_reporter|floatformat }}/19</dd>
{% if passage.observer %}
<dt class="col-sm-8">{% trans "Observer points:" %}</dt>
<dd class="col-sm-4">{{ passage.average_observer|floatformat }}/4</dd>
{% endif %}
</dl>
</div>
</div>
@ -124,7 +142,7 @@
initModal("updatePassage", "{% url "participation:passage_update" pk=passage.pk %}")
{% 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 %}
{% elif user.registration.participates %}
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>
<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>
<dd class="col-sm-9">
{% for participation in pool.participations.all %}
@ -23,13 +26,40 @@
</dd>
<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>
<dd class="col-sm-9">
{% 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 %}
<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>
<dt class="col-sm-3">{% trans "BigBlueButton link:" %}</dt>
@ -47,6 +77,31 @@
{% endfor %}
</ul>
</div>
{% if user.registration.is_volunteer %}
<div class="card-footer text-center">
<div class="btn-group">
<a class="btn btn-info" href="{% url 'participation:pool_scale_note_sheet' pk=pool.pk %}">
{% trans "Download the scale sheet" %}{% if pool.passages.count == 5 %} — {% trans "Room" %} 1{% endif %}
</a>
{% if pool.passages.count == 5 %}
<a class="btn btn-info" href="{% url 'participation:pool_scale_note_sheet' pk=pool.pk %}?page=2">
{% trans "Room" %} 2
</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>
{% 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-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="#uploadNotesModal">{% trans "Upload notes from a CSV file" %}</button>
</div>
{% endif %}
</div>

View File

@ -115,11 +115,13 @@
</dl>
{% if user.registration.is_volunteer %}
<div class="text-center">
<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" %}
</a>
</div>
{% if user.registration in self.team.participation.tournament.organizers or user.registration.is_admin %}
<div class="text-center">
<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" %}
</a>
</div>
{% endif %}
{% endif %}
</div>
<div class="card-footer text-center">

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 %}
<form method="post" enctype="multipart/form-data">
<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 %}
{{ form|crispy }}
</div>

View File

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

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