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

Compare commits

..

54 Commits

Author SHA1 Message Date
Emmy D'Anello
ca7cf5987c
Try to fix requirements
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 20:02:59 +02:00
Emmy D'Anello
34390a541a
Update translations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 19:57:02 +02:00
Emmy D'Anello
b8b4891e9b
Squash migrations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 19:54:18 +02:00
Emmy D'Anello
9cfab53bd2
Add a lot of comments
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 19:52:44 +02:00
Emmy D'Anello
82cda0b279
Reduce the usage of sync_to_async
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 15:10:28 +02:00
Emmy D'Anello
4357d51b9a
Display problem names
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 11:56:13 +02:00
Emmy D'Anello
90bfc45858
Use the new asave function of Django 4.2
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 11:20:43 +02:00
Emmy D'Anello
bb9f0dab22
Django 4.2 got released
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 11:12:37 +02:00
Emmy D'Anello
b0a248e81a
Fix the transition between the two rounds
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 11:07:08 +02:00
Emmy D'Anello
b3c26b8c1c
Improve admin interface
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:55 +02:00
Emmy D'Anello
073d761a03
Add admin menu
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:55 +02:00
Emmy D'Anello
bd31375bf3
Fix CSV process
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:55 +02:00
Emmy D'Anello
7605b9cc00
Add download link to notation sheets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:55 +02:00
Emmy D'Anello
0fa76d6f25
Add letter in pool display
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:55 +02:00
Emmy D'Anello
14505260ff
Use more complex calculus to mix teams for the second day
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:54 +02:00
Emmy D'Anello
cf8892ee1a
Use separate fields for the two dices
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:54 +02:00
Emmy D'Anello
7f7d921c53
We want to avoid that a team chooses twice a same problem, not to wait an infinite loop
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:54 +02:00
Emmy D'Anello
8668430760
Add reverse-proxy headers
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:53 +02:00
Emmy D'Anello
45818eae24
Add websockets as dependency
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:53 +02:00
Emmy D'Anello
b154c4985d
Fix duplicate problem check
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:53 +02:00
Emmy D'Anello
ac039c1073
Display draw tab only for authenticated users
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:53 +02:00
Emmy D'Anello
3717cd8b3f
Don't import models too soon
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:52 +02:00
Emmy D'Anello
7855ec2225
Fix translation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:52 +02:00
Emmy D'Anello
fbaca32615
Teams can't select a same problem for the two days
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:52 +02:00
Emmy D'Anello
5b1374bf1b
Add link to the drawing interface
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:52 +02:00
Emmy D'Anello
18bd2c7c18
In a 5-teams pool, the order of two teams that present the same problem is random
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:52 +02:00
Emmy D'Anello
a4c7951475
Make all invisible when a draw is aborted
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:51 +02:00
Emmy D'Anello
c299ff6634
Remove Python 3.9 compatibility (I love match/case)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:51 +02:00
Emmy D'Anello
7d8975339e
Add continue button for the final tournament
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:51 +02:00
Emmy D'Anello
1bd9cea458
Fix update notes modal
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:51 +02:00
Emmy D'Anello
b838f1b3f0
Add export button
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:50 +02:00
Emmy D'Anello
e95d511017
Translate messages from websockets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:50 +02:00
Emmy D'Anello
942c96dbfa
Reorder teams for 5-teams pools
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:50 +02:00
Emmy D'Anello
3cd40ee192
Add margins
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:50 +02:00
Emmy D'Anello
cebe977d49
Problems can be accepted or rejected. Draw can go to the end
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:50 +02:00
Emmy D'Anello
e90005b192
Teams can draw a problem
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
Emmy D'Anello
6b5c630048
Add Abort button
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
Emmy D'Anello
c9fcfcf498
Add messages for better understanding
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
Emmy D'Anello
dec9f9be11
Update translations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
Emmy D'Anello
f85a563cf3
Auto-generate tables
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
Emmy D'Anello
5399a875c6
Draw dices
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
Emmy D'Anello
eb8ad4e771
Prepare template for the system
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:48 +02:00
Emmy D'Anello
93a71fb561
Fix errors and better tab usage
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:48 +02:00
Emmy D'Anello
bde3758c50
First interface to start draws
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:48 +02:00
Emmy D'Anello
88823b5252
Update database models and translations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:48 +02:00
Emmy D'Anello
9aa19ad3ca
Fix tests
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:48 +02:00
Emmy D'Anello
ad4593a2f6
Prepare database model
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:47 +02:00
Emmy D'Anello
849194414d
Fix tox
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:47 +02:00
Emmy D'Anello
b9ce4c737c
First play with websockets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:47 +02:00
Emmy D'Anello
30efff0d9d
Don't trigger signals on raw imports
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:47 +02:00
Emmy D'Anello
7364d27b4b
Init new draw application
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:47 +02:00
Emmy D'Anello
19f41152ee
Use Django 4.1 (soon 4.2) to use the new async framework
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:46 +02:00
Emmy D'Anello
f3d611913e
Run ASGI server instead of WSGI
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:46 +02:00
Emmy D'Anello
1d81213773
Move apps in main directory
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:46 +02:00
165 changed files with 4702 additions and 755 deletions

View File

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

View File

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

View File

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

View File

@ -1,14 +0,0 @@
{% extends request.content_only|yesno:"empty.html,base.html" %}
{% load crispy_forms_tags %}
{% load i18n %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<div id="form-content">
{% csrf_token %}
{{ form|crispy }}
</div>
<button class="btn btn-primary" type="submit">{% trans "Upload" %}</button>
</form>
{% endblock %}

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, Round, Pool, 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")

1008
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

512
draw/models.py Normal file
View File

@ -0,0 +1,512 @@
# 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 MinValueValidator, MaxValueValidator
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 Passage, Participation, Pool as PPool, Tournament
class Draw(models.Model):
"""
A draw instance is linked to a :model:`participation.Tournament` and contains all information
about a draw.
"""
tournament = models.OneToOneField(
Tournament,
on_delete=models.CASCADE,
verbose_name=_('tournament'),
help_text=_("The associated tournament.")
)
current_round = models.ForeignKey(
'Round',
on_delete=models.CASCADE,
null=True,
default=None,
related_name='+',
verbose_name=_('current round'),
help_text=_("The current round where teams select their problems."),
)
last_message = models.TextField(
blank=True,
default="",
verbose_name=_("last message"),
help_text=_("The last message that is displayed on the drawing interface.")
)
def get_absolute_url(self):
return reverse_lazy('draw:index') + f'#{slugify(self.tournament.name)}'
@property
def exportable(self) -> bool:
"""
True if any pool of the draw is exportable, ie. can be exported to the tournament interface.
This operation is synchronous.
"""
return any(pool.exportable for r in self.round_set.all() for pool in r.pool_set.all())
async def is_exportable(self) -> bool:
"""
True if any pool of the draw is exportable, ie. can be exported to the tournament interface.
This operation is asynchronous.
"""
return any([await pool.is_exportable() async for r in self.round_set.all() async for pool in r.pool_set.all()])
def get_state(self) -> str:
"""
The current state of the draw.
Can be:
* **DICE_SELECT_POULES** if we are waiting for teams to launch their dice to determine pools and passage order ;
* **DICE_ORDER_POULE** if we are waiting for teams to launch their dice to determine the problem draw order ;
* **WAITING_DRAW_PROBLEM** if we are waiting for a team to draw a problem ;
* **WAITING_CHOOSE_PROBLEM** if we are waiting for a team to accept or reject a problem ;
* **WAITING_FINAL** if this is the final tournament and we are between the two rounds ;
* **DRAW_ENDED** if the draw is ended.
Warning: the current round and the current team must be prefetched in an async context.
"""
if self.current_round.current_pool is None:
return 'DICE_SELECT_POULES'
elif self.current_round.current_pool.current_team is None:
return 'DICE_ORDER_POULE'
elif self.current_round.current_pool.current_team.accepted is not None:
if self.current_round.number == 1:
# The last step can be the last problem acceptation after the first round
# only for the final between the two rounds
return 'WAITING_FINAL'
else:
return 'DRAW_ENDED'
elif self.current_round.current_pool.current_team.purposed is None:
return 'WAITING_DRAW_PROBLEM'
else:
return 'WAITING_CHOOSE_PROBLEM'
@property
def information(self):
"""
The information header on the draw interface, which is defined according to the
current state.
Warning: this property is synchronous.
"""
s = ""
if self.last_message:
s += self.last_message + "<br><br>"
match self.get_state():
case 'DICE_SELECT_POULES':
# Waiting for dices to determine pools and passage order
if self.current_round.number == 1:
# Specific information for the first round
s += """Nous allons commencer le tirage des problèmes.<br>
Vous pouvez à tout moment poser toute question si quelque chose
n'est pas clair ou ne va pas.<br><br>
Nous allons d'abord tirer les poules et l'ordre de passage
pour le premier tour avec toutes les équipes puis pour chaque poule,
nous tirerons l'ordre de tirage pour le tour et les problèmes.<br><br>"""
s += """
Les capitaines, vous pouvez désormais toustes lancer un 100,
en cliquant sur le gros bouton. Les poules et l'ordre de passage
lors du premier tour sera l'ordre croissant des dés, c'est-à-dire
que le plus petit lancer sera le premier à passer dans la poule A."""
case 'DICE_ORDER_POULE':
# Waiting for dices to determine the choice order
s += f"""Nous passons au tirage des problèmes pour la poule
<strong>{self.current_round.current_pool}</strong>, entre les équipes
<strong>{', '.join(td.participation.team.trigram
for td in self.current_round.current_pool.teamdraw_set.all())}</strong>.
Les capitaines peuvent lancer un 100 en cliquant sur le gros bouton
pour déterminer l'ordre de tirage. L'équipe réalisant le plus gros score pourra
tirer en premier."""
case 'WAITING_DRAW_PROBLEM':
# Waiting for a problem draw
td = self.current_round.current_pool.current_team
s += f"""C'est au tour de l'équipe <strong>{td.participation.team.trigram}</strong>
de choisir son problème. Cliquez sur l'urne au milieu pour tirer un problème au sort."""
case 'WAITING_CHOOSE_PROBLEM':
# Waiting for the team that can accept or reject the problem
td = self.current_round.current_pool.current_team
s += f"""L'équipe <strong>{td.participation.team.trigram}</strong> a tiré le problème
<strong>{td.purposed} : {settings.PROBLEMS[td.purposed - 1]}</strong>. """
if td.purposed in td.rejected:
# The problem was previously rejected
s += """Elle a déjà refusé ce problème auparavant, elle peut donc le refuser sans pénalité et
tirer un nouveau problème immédiatement, ou bien revenir sur son choix."""
else:
# The problem can be rejected
s += "Elle peut décider d'accepter ou de refuser ce problème. "
if len(td.rejected) >= len(settings.PROBLEMS) - 5:
s += "Refuser ce problème ajoutera une nouvelle pénalité de 0.5 sur le coefficient de l'oral de læ défenseur⋅se."
else:
s += f"Il reste {len(settings.PROBLEMS) - 5 - len(td.rejected)} refus sans pénalité."
case 'WAITING_FINAL':
# We are between the two rounds of the final tournament
s += "Le tirage au sort pour le tour 2 aura lieu à la fin du premier tour. Bon courage !"
case 'DRAW_ENDED':
# The draw is ended
s += "Le tirage au sort est terminé. Les solutions des autres équipes peuvent être trouvées dans l'onglet « Ma participation »."
s += "<br><br>" if s else ""
s += """Pour plus de détails sur le déroulement du tirage au sort,
le règlement est accessible sur
<a class="alert-link" href="https://tfjm.org/reglement">https://tfjm.org/reglement</a>."""
return s
async def ainformation(self) -> str:
"""
Asynchronous version to get the information header content.
"""
return await sync_to_async(lambda: self.information)()
def __str__(self):
return str(format_lazy(_("Draw of tournament {tournament}"), tournament=self.tournament.name))
class Meta:
verbose_name = _('draw')
verbose_name_plural = _('draws')
class Round(models.Model):
"""
This model is attached to a :model:`draw.Draw` and represents the draw
for one round of the :model:`participation.Tournament`.
"""
draw = models.ForeignKey(
Draw,
on_delete=models.CASCADE,
verbose_name=_('draw'),
)
number = models.PositiveSmallIntegerField(
choices=[
(1, _('Round 1')),
(2, _('Round 2')),
],
verbose_name=_('number'),
help_text=_("The number of the round, 1 or 2"),
validators=[MinValueValidator(1), MaxValueValidator(2)],
)
current_pool = models.ForeignKey(
'Pool',
on_delete=models.CASCADE,
null=True,
default=None,
related_name='+',
verbose_name=_('current pool'),
help_text=_("The current pool where teams select their problems."),
)
@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."),
)
@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
if self.size == 3:
table = [
[0, 1, 2],
[1, 2, 0],
[2, 0, 1],
]
elif self.size == 4:
table = [
[0, 1, 2],
[1, 2, 3],
[2, 3, 0],
[3, 0, 1],
]
elif self.size == 5:
table = [
[0, 2, 3],
[1, 3, 4],
[2, 0, 1],
[3, 4, 0],
[4, 1, 2],
]
for line in table:
# Create the passage
await Passage.objects.acreate(
pool=self.associated_pool,
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,
)
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=_("accepted problem"),
)
rejected = models.JSONField(
default=list,
verbose_name=_('rejected problems'),
)
@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',)

7
draw/routing.py Normal file
View File

@ -0,0 +1,7 @@
from django.urls import path
from . import consumers
websocket_urlpatterns = [
path("ws/draw/<int:tournament_id>/", consumers.DrawConsumer.as_asgi()),
]

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

@ -0,0 +1,742 @@
(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)
const sockets = {}
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) {
sockets[tid].send(JSON.stringify({'type': 'abort'}))
}
/**
* 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)
*/
function drawDice(tid, trigram = null) {
sockets[tid].send(JSON.stringify({'type': 'dice', 'trigram': trigram}))
}
/**
* Request to draw a new problem.
* @param tid The tournament id
*/
function drawProblem(tid) {
sockets[tid].send(JSON.stringify({'type': 'draw_problem'}))
}
/**
* Accept the current proposed problem.
* @param tid The tournament id
*/
function acceptProblem(tid) {
sockets[tid].send(JSON.stringify({'type': 'accept'}))
}
/**
* Reject the current proposed problem.
* @param tid The tournament id
*/
function rejectProblem(tid) {
sockets[tid].send(JSON.stringify({'type': 'reject'}))
}
/**
* Volunteers can export the draw to make it available for notation.
* @param tid The tournament id
*/
function exportDraw(tid) {
sockets[tid].send(JSON.stringify({'type': 'export'}))
}
/**
* Volunteers can make the draw continue for the second round of the final.
* @param tid The tournament id
*/
function continueFinal(tid) {
sockets[tid].send(JSON.stringify({'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()))
for (let tournament of tournaments) {
// Open a websocket per tournament
let socket = new WebSocket(
(document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host
+ '/ws/draw/' + tournament.id + '/'
)
sockets[tournament.id] = socket
/**
* 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 info The content to updated
*/
function setInfo(info) {
document.getElementById(`messages-${tournament.id}`).innerHTML = info
}
/**
* Open the draw interface, given the list of teams.
* @param teams The list of teams (represented by their trigrams) that are present on this draw.
*/
function drawStart(teams) {
// Hide the not-started-banner
document.getElementById(`banner-not-started-${tournament.id}`).classList.add('d-none')
// Display the full draw interface
document.getElementById(`draw-content-${tournament.id}`).classList.remove('d-none')
let dicesDiv = document.getElementById(`dices-${tournament.id}`)
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-${tournament.id}-${team}`
diceDiv.classList.add('badge', 'rounded-pill', 'text-bg-warning')
if (document.getElementById(`abort-${tournament.id}`) !== null) {
// Check if this is a volunteer, who can launch a dice for a specific team
diceDiv.onclick = (e) => drawDice(tournament.id, team)
}
diceDiv.textContent = `${team} 🎲 ??`
col.append(diceDiv)
}
}
/**
* Abort the current draw, and make all invisible, except the not-started-banner.
*/
function drawAbort() {
document.getElementById(`banner-not-started-${tournament.id}`).classList.remove('d-none')
document.getElementById(`draw-content-${tournament.id}`).classList.add('d-none')
document.getElementById(`dices-${tournament.id}`).innerHTML = ""
document.getElementById(`recap-${tournament.id}-round-list`).innerHTML = ""
document.getElementById(`tables-${tournament.id}`).innerHTML = ""
updateDiceVisibility(false)
updateBoxVisibility(false)
updateButtonsVisibility(false)
updateExportVisibility(false)
updateContinueVisibility(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 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(trigram, result) {
let elem = document.getElementById(`dice-${tournament.id}-${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 visible The visibility status
*/
function updateDiceVisibility(visible) {
let div = document.getElementById(`launch-dice-${tournament.id}`)
if (visible)
div.classList.remove('d-none')
else
div.classList.add('d-none')
}
/**
* Display or hide the box button.
* @param visible The visibility status
*/
function updateBoxVisibility(visible) {
let div = document.getElementById(`draw-problem-${tournament.id}`)
if (visible)
div.classList.remove('d-none')
else
div.classList.add('d-none')
}
/**
* Display or hide the accept and reject buttons.
* @param visible The visibility status
*/
function updateButtonsVisibility(visible) {
let div = document.getElementById(`buttons-${tournament.id}`)
if (visible)
div.classList.remove('d-none')
else
div.classList.add('d-none')
}
/**
* Display or hide the export button.
* @param visible The visibility status
*/
function updateExportVisibility(visible) {
let div = document.getElementById(`export-${tournament.id}`)
if (visible)
div.classList.remove('d-none')
else
div.classList.add('d-none')
}
/**
* Display or hide the continuation button.
* @param visible The visibility status
*/
function updateContinueVisibility(visible) {
let div = document.getElementById(`continue-${tournament.id}`)
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 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(round, poules) {
let roundList = document.getElementById(`recap-${tournament.id}-round-list`)
let poolListId = `recap-${tournament.id}-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-${tournament.id}-round-${round}`
div.classList.add('col-md-6', 'px-3', 'py-3')
div.setAttribute('data-tournament', tournament.id)
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-${tournament.id}-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-${tournament.id}-round-${round}-pool-${poule.letter}`
li.classList.add('list-group-item', 'px-3', 'py-3')
li.setAttribute('data-tournament', tournament.id)
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)
}
if (poule.teams.length > 0) {
// The pool is initialized
for (let team of poule.teams) {
// Reorder dices
let diceDiv = document.getElementById(`dice-${tournament.id}-${team}`)
diceDiv.parentElement.style.order = c.toString()
c += 1
let teamLiId = `recap-${tournament.id}-round-${round}-team-${team}`
let teamLi = document.getElementById(teamLiId)
if (teamLi === null) {
// Add a line for the team in the recap
teamLi = document.createElement('li')
teamLi.id = teamLiId
teamLi.classList.add('list-group-item')
teamLi.setAttribute('data-tournament', tournament.id)
teamList.append(teamLi)
}
// Add the accepted problem div (empty for now)
let acceptedDivId = `recap-${tournament.id}-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-${tournament.id}-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-${tournament.id}`)
let tablesRoundDiv = document.getElementById(`tables-${tournament.id}-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-${tournament.id}-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(round, poule)
}
}
}
/**
* Update the table for the given round and the given pool, where there will be the chosen problems.
* @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(round, poule) {
let tablesRoundDiv = document.getElementById(`tables-${tournament.id}-round-${round}`)
let pouleTable = document.getElementById(`table-${tournament.id}-${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-${tournament.id}-${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-${tournament.id}-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
}
}
}
}
}
/**
* Highligh the team that is currently choosing its problem.
* @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(round, pool, team) {
// Remove the previous highlights
document.querySelectorAll(`div.text-bg-secondary[data-tournament="${tournament.id}"]`)
.forEach(elem => elem.classList.remove('text-bg-secondary'))
document.querySelectorAll(`li.list-group-item-success[data-tournament="${tournament.id}"]`)
.forEach(elem => elem.classList.remove('list-group-item-success'))
document.querySelectorAll(`li.list-group-item-info[data-tournament="${tournament.id}"]`)
.forEach(elem => elem.classList.remove('list-group-item-info'))
// Highlight current round, if existing
let roundDiv = document.getElementById(`recap-${tournament.id}-round-${round}`)
if (roundDiv !== null)
roundDiv.classList.add('text-bg-secondary')
// Highlight current pool, if existing
let poolLi = document.getElementById(`recap-${tournament.id}-round-${round}-pool-${pool}`)
if (poolLi !== null)
poolLi.classList.add('list-group-item-success')
// Highlight current team, if existing
let teamLi = document.getElementById(`recap-${tournament.id}-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 round The current round, as integer (1 or 2)
* @param team The current team trigram
* @param problem The accepted problem, as integer
*/
function setProblemAccepted(round, team, problem) {
// Update recap
let recapDiv = document.getElementById(`recap-${tournament.id}-round-${round}-team-${team}-accepted`)
recapDiv.classList.remove('text-bg-warning')
recapDiv.classList.add('text-bg-success')
recapDiv.textContent = `${team} 📃 ${problem}`
// Update table
let tableSpan = document.getElementById(`table-${tournament.id}-round-${round}-problem-${team}`)
tableSpan.textContent = problem
}
/**
* Update the recap when a team rejects a problem.
* @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(round, team, rejected) {
// Update recap
let recapDiv = document.getElementById(`recap-${tournament.id}-round-${round}-team-${team}-rejected`)
recapDiv.textContent = `🗑️ ${rejected.join(', ')}`
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
let penaltyDiv = document.getElementById(`recap-${tournament.id}-round-${round}-team-${team}-penalty`)
if (penaltyDiv === null) {
penaltyDiv = document.createElement('div')
penaltyDiv.id = `recap-${tournament.id}-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))}`
}
}
/**
* 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 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(round, poule, teams, problems) {
// Redraw the pool table
let table = document.getElementById(`table-${tournament.id}-${round}-${poule}`)
table.parentElement.parentElement.remove()
updatePouleTable(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(round, team, problem)
}
}
// Listen on websockets and process messages from the server
socket.addEventListener('message', e => {
// Parse received data as JSON
const data = JSON.parse(e.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(data.information)
break
case 'draw_start':
// Start the draw and update the interface
drawStart(data.trigrams)
break
case 'abort':
// Abort the current draw
drawAbort()
break
case 'dice':
// Update the interface after a dice launch
updateDiceInfo(data.team, data.result)
break
case 'dice_visibility':
// Update the dice button visibility
updateDiceVisibility(data.visible)
break
case 'box_visibility':
// Update the box button visibility
updateBoxVisibility(data.visible)
break
case 'buttons_visibility':
// Update the accept/reject buttons visibility
updateButtonsVisibility(data.visible)
break
case 'export_visibility':
// Update the export button visibility
updateExportVisibility(data.visible)
break
case 'continue_visibility':
// Update the continue button visibility for the final tournament
updateContinueVisibility(data.visible)
break
case 'set_poules':
// Set teams order and pools and update the interface
updatePoules(data.round, data.poules)
break
case 'set_active':
// Highlight the team that is selecting a problem
updateActiveRecap(data.round, data.poule, data.team)
break
case 'set_problem':
// Mark a problem as accepted and update the interface
setProblemAccepted(data.round, data.team, data.problem)
break
case 'reject_problem':
// Mark a problem as rejected and update the interface
setProblemRejected(data.round, data.team, data.rejected)
break
case 'reorder_poule':
// Reorder a pool and redraw the associated table
reorderPoule(data.round, data.poule, data.teams, data.problems)
break
}
})
// Manage errors
socket.addEventListener('close', e => {
console.error('Chat socket closed unexpectedly')
})
// 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({
'type': 'set_language',
'language': document.getElementsByName('language')[0].value,
}))
})
// 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({
'type': 'start_draw',
'fmt': document.getElementById('format-' + tournament.id).value
}))
})
}
}
})

View File

@ -0,0 +1,39 @@
{% extends "base.html" %}
{% load static %}
{% 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>
{% 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,329 @@
{% 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 %}
{# Volunteers can click on this button to abort the draw #}
<button id="abort-{{ tournament.id }}" class="badge rounded-pill text-bg-danger" onclick="abortDraw({{ tournament.id }})">
{% trans "Abort" %}
</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>
</div>

2
draw/tests.py Normal file
View File

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

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

40
draw/views.py Normal file
View File

@ -0,0 +1,40 @@
# 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.views.generic import TemplateView, DetailView
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:
# A participant can see its own tournament, or the final if necessary
tournaments = [reg.team.participation.tournament]
if reg.team.participation.final:
tournaments.append(Tournament.final_tournament())
context['tournaments'] = tournaments
# This will be useful for JavaScript data
context['tournaments_simplified'] = [{'id': t.id, 'name': t.name} for t in tournaments]
context['problems'] = settings.PROBLEMS
return context

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

135
participation/admin.py Normal file
View File

@ -0,0 +1,135 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament, Tweak
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ('name', 'trigram', 'tournament', 'valid', 'final',)
search_fields = ('name', 'trigram',)
list_filter = ('participation__valid', 'participation__tournament', 'participation__final',)
@admin.display(description=_("tournament"))
def tournament(self, record):
return record.participation.tournament
@admin.display(description=_("valid"), boolean=True)
def valid(self, team):
return team.participation.valid
@admin.display(description=_("selected for final"), boolean=True)
def final(self, team):
return team.participation.final
@admin.register(Participation)
class ParticipationAdmin(admin.ModelAdmin):
list_display = ('team', 'tournament', 'valid', 'final',)
search_fields = ('team__name', 'team__trigram',)
list_filter = ('valid',)
autocomplete_fields = ('team', 'tournament',)
@admin.register(Pool)
class PoolAdmin(admin.ModelAdmin):
list_display = ('__str__', 'tournament', 'round', 'letter', 'teams',)
list_filter = ('tournament', 'round', 'letter',)
search_fields = ('participations__team__name', 'participations__team__trigram',)
autocomplete_fields = ('tournament', 'participations', 'juries',)
@admin.display(description=_("teams"))
def teams(self, record: Pool):
return ', '.join(p.team.trigram for p in record.participations.all())
@admin.register(Passage)
class PassageAdmin(admin.ModelAdmin):
list_display = ('__str__', 'defender_trigram', 'solution_number', 'opponent_trigram', 'reporter_trigram',
'pool_abbr', 'tournament')
list_filter = ('pool__tournament', 'pool__round', 'pool__letter', 'solution_number',)
search_fields = ('pool__participations__team__name', 'pool__participations__team__trigram',)
autocomplete_fields = ('pool', 'defender', 'opponent', 'reporter',)
@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

@ -170,7 +170,7 @@ class SolutionForm(forms.ModelForm):
class PoolForm(forms.ModelForm): class PoolForm(forms.ModelForm):
class Meta: class Meta:
model = Pool model = Pool
fields = ('tournament', 'round', 'bbb_url', 'results_available', 'juries',) fields = ('tournament', 'round', 'letter', 'bbb_url', 'results_available', 'juries',)
widgets = { widgets = {
"juries": forms.SelectMultiple(attrs={ "juries": forms.SelectMultiple(attrs={
'class': 'selectpicker', 'class': 'selectpicker',
@ -231,6 +231,8 @@ class UploadNotesForm(forms.Form):
if len(line) < 19: if len(line) < 19:
continue continue
name = line[0] name = line[0]
if name in ["moyenne", "coefficient", "sous-total"]:
continue
notes = line[1:19] notes = line[1:19]
if not all(s.isnumeric() for s in notes): if not all(s.isnumeric() for s in notes):
continue continue

View File

View File

@ -30,7 +30,7 @@ class Command(BaseCommand):
else: else:
stat_file = os.stat("tfjm/static/logo.png") stat_file = os.stat("tfjm/static/logo.png")
with open("tfjm/static/logo.png", "rb") as f: with open("tfjm/static/logo.png", "rb") as f:
resp = (await Matrix.upload(f, filename="logo.png", content_type="image/png", resp = (await Matrix.upload(f, filename="../../../tfjm/static/logo.png", content_type="image/png",
filesize=stat_file.st_size))[0][0] filesize=stat_file.st_size))[0][0]
avatar_uri = resp.content_uri avatar_uri = resp.content_uri
with open(".matrix_avatar", "w") as f: with open(".matrix_avatar", "w") as f:

View File

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

View File

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

View File

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

View File

@ -124,6 +124,7 @@ class Team(models.Model):
class Meta: class Meta:
verbose_name = _("team") verbose_name = _("team")
verbose_name_plural = _("teams") verbose_name_plural = _("teams")
ordering = ('trigram',)
indexes = [ indexes = [
Index(fields=("trigram", )), Index(fields=("trigram", )),
] ]
@ -278,6 +279,13 @@ class Tournament(models.Model):
return Synthesis.objects.filter(final_solution=True) return Synthesis.objects.filter(final_solution=True)
return Synthesis.objects.filter(participation__tournament=self) return Synthesis.objects.filter(participation__tournament=self)
@property
def best_format(self):
n = len(self.participations.filter(valid=True).all())
fmt = [n] if n <= 5 else [3] * (n // 3 - 1) + [3 + n % 3]
return '+'.join(map(str, sorted(fmt, reverse=True)))
def get_absolute_url(self): def get_absolute_url(self):
return reverse_lazy("participation:tournament_detail", args=(self.pk,)) return reverse_lazy("participation:tournament_detail", args=(self.pk,))
@ -352,6 +360,16 @@ class Pool(models.Model):
] ]
) )
letter = models.PositiveSmallIntegerField(
choices=[
(1, 'A'),
(2, 'B'),
(3, 'C'),
(4, 'D'),
],
verbose_name=_('letter'),
)
participations = models.ManyToManyField( participations = models.ManyToManyField(
Participation, Participation,
related_name="pools", related_name="pools",
@ -387,6 +405,10 @@ class Pool(models.Model):
return sum(passage.average(participation) for passage in self.passages.all()) \ return sum(passage.average(participation) for passage in self.passages.all()) \
+ sum(tweak.diff for tweak in participation.tweaks.filter(pool=self).all()) + sum(tweak.diff for tweak in participation.tweaks.filter(pool=self).all())
async def aaverage(self, participation):
return sum([passage.average(participation) async for passage in self.passages.all()]) \
+ sum([tweak.diff async for tweak in participation.tweaks.filter(pool=self).all()])
def get_absolute_url(self): def get_absolute_url(self):
return reverse_lazy("participation:pool_detail", args=(self.pk,)) return reverse_lazy("participation:pool_detail", args=(self.pk,))
@ -399,6 +421,7 @@ class Pool(models.Model):
class Meta: class Meta:
verbose_name = _("pool") verbose_name = _("pool")
verbose_name_plural = _("pools") verbose_name_plural = _("pools")
ordering = ('round', 'letter',)
class Passage(models.Model): class Passage(models.Model):
@ -412,7 +435,7 @@ class Passage(models.Model):
solution_number = models.PositiveSmallIntegerField( solution_number = models.PositiveSmallIntegerField(
verbose_name=_("defended solution"), verbose_name=_("defended solution"),
choices=[ choices=[
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, settings.PROBLEM_COUNT + 1) (i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, len(settings.PROBLEMS) + 1)
], ],
) )
@ -566,7 +589,7 @@ class Solution(models.Model):
problem = models.PositiveSmallIntegerField( problem = models.PositiveSmallIntegerField(
verbose_name=_("problem"), verbose_name=_("problem"),
choices=[ choices=[
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, settings.PROBLEM_COUNT + 1) (i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, len(settings.PROBLEMS) + 1)
], ],
) )

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -76,13 +76,20 @@ class TournamentTable(tables.Table):
class PoolTable(tables.Table): class PoolTable(tables.Table):
teams = tables.LinkColumn( letter = tables.LinkColumn(
'participation:pool_detail', 'participation:pool_detail',
args=[tables.A('id')], args=[tables.A('id')],
verbose_name=_("pool").capitalize,
)
teams = tables.Column(
verbose_name=_("teams").capitalize, verbose_name=_("teams").capitalize,
empty_values=(), empty_values=(),
) )
def render_letter(self, record):
return format_lazy(_("Pool {letter}{round}"), letter=record.get_letter_display(), round=record.round)
def render_teams(self, record): def render_teams(self, record):
return ", ".join(participation.team.trigram for participation in record.participations.all()) \ return ", ".join(participation.team.trigram for participation in record.participations.all()) \
or _("No defined team") or _("No defined team")
@ -92,7 +99,7 @@ class PoolTable(tables.Table):
'class': 'table table-condensed table-striped', 'class': 'table table-condensed table-striped',
} }
model = Pool model = Pool
fields = ('teams', 'round', 'tournament',) fields = ('letter', 'teams', 'round', 'tournament',)
class PassageTable(tables.Table): class PassageTable(tables.Table):

View File

@ -124,7 +124,7 @@
initModal("updatePassage", "{% url "participation:passage_update" pk=passage.pk %}") initModal("updatePassage", "{% url "participation:passage_update" pk=passage.pk %}")
{% if my_note is not None %} {% if my_note is not None %}
initModal("updateNotesModal", "{% url "participation:update_notes" pk=my_note.pk %}") initModal("updateNotes", "{% url "participation:update_notes" pk=my_note.pk %}")
{% endif %} {% endif %}
{% elif user.registration.participates %} {% elif user.registration.participates %}
initModal("uploadSynthesis", "{% url "participation:upload_synthesis" pk=passage.pk %}") initModal("uploadSynthesis", "{% url "participation:upload_synthesis" pk=passage.pk %}")

View File

@ -15,6 +15,9 @@
<dt class="col-sm-3">{% trans "Round:" %}</dt> <dt class="col-sm-3">{% trans "Round:" %}</dt>
<dd class="col-sm-9">{{ pool.get_round_display }}</dd> <dd class="col-sm-9">{{ pool.get_round_display }}</dd>
<dt class="col-sm-3">{% trans "Letter:" %}</dt>
<dd class="col-sm-9">{{ pool.get_letter_display }}</dd>
<dt class="col-sm-3">{% trans "Teams:" %}</dt> <dt class="col-sm-3">{% trans "Teams:" %}</dt>
<dd class="col-sm-9"> <dd class="col-sm-9">
{% for participation in pool.participations.all %} {% for participation in pool.participations.all %}

View File

@ -0,0 +1,26 @@
{% extends request.content_only|yesno:"empty.html,base.html" %}
{% load crispy_forms_tags %}
{% load i18n %}
{% load static %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<div id="form-content">
<div class="alert alert-info">
{% if object.participations.count == 3 %}
<a class="alert-link" href="{% static "Fiche notations - 3 équipes.ods" %}">
{% elif object.participations.count == 4 %}
<a class="alert-link" href="{% static "Fiche notations - 4 équipes.ods" %}">
{% elif object.participations.count == 5 %}
<a class="alert-link" href="{% static "Fiche notations - 5 équipes.ods" %}">
{% endif %}
{% trans "Download empty notation sheet" %}
</a>
</div>
{% csrf_token %}
{{ form|crispy }}
</div>
<button class="btn btn-primary" type="submit">{% trans "Upload" %}</button>
</form>
{% endblock %}

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