plateforme-tfjm2/draw/models.py

534 lines
20 KiB
Python

# Copyright (C) 2023 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from asgiref.sync import sync_to_async
from django.conf import settings
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import QuerySet
from django.urls import reverse_lazy
from django.utils.text import format_lazy, slugify
from django.utils.translation import gettext_lazy as _
from participation.models import Participation, Passage, Pool as PPool, Tournament
class Draw(models.Model):
"""
A draw instance is linked to a :model:`participation.Tournament` and contains all information
about a draw.
"""
tournament = models.OneToOneField(
Tournament,
on_delete=models.CASCADE,
verbose_name=_('tournament'),
help_text=_("The associated tournament.")
)
current_round = models.ForeignKey(
'Round',
on_delete=models.CASCADE,
null=True,
default=None,
related_name='+',
verbose_name=_('current round'),
help_text=_("The current round where teams select their problems."),
)
last_message = models.TextField(
blank=True,
default="",
verbose_name=_("last message"),
help_text=_("The last message that is displayed on the drawing interface.")
)
def get_absolute_url(self):
return reverse_lazy('draw:index') + f'#{slugify(self.tournament.name)}'
@property
def exportable(self) -> bool:
"""
True if any pool of the draw is exportable, ie. can be exported to the tournament interface.
This operation is synchronous.
"""
return any(pool.exportable for r in self.round_set.all() for pool in r.pool_set.all())
async def is_exportable(self) -> bool:
"""
True if any pool of the draw is exportable, ie. can be exported to the tournament interface.
This operation is asynchronous.
"""
return any([await pool.is_exportable() async for r in self.round_set.all() async for pool in r.pool_set.all()])
def get_state(self) -> str:
"""
The current state of the draw.
Can be:
* **DICE_SELECT_POULES** if we are waiting for teams to launch their dice to determine pools and passage order ;
* **DICE_ORDER_POULE** if we are waiting for teams to launch their dice to determine the problem draw order ;
* **WAITING_DRAW_PROBLEM** if we are waiting for a team to draw a problem ;
* **WAITING_CHOOSE_PROBLEM** if we are waiting for a team to accept or reject a problem ;
* **WAITING_FINAL** if this is the final tournament and we are between the two rounds ;
* **DRAW_ENDED** if the draw is ended.
Warning: the current round and the current team must be prefetched in an async context.
"""
if self.current_round.current_pool is None:
return 'DICE_SELECT_POULES'
elif self.current_round.current_pool.current_team is None:
return 'DICE_ORDER_POULE'
elif self.current_round.current_pool.current_team.accepted is not None:
if self.current_round.number == 1:
# The last step can be the last problem acceptation after the first round
# only for the final between the two rounds
return 'WAITING_FINAL'
else:
return 'DRAW_ENDED'
elif self.current_round.current_pool.current_team.purposed is None:
return 'WAITING_DRAW_PROBLEM'
else:
return 'WAITING_CHOOSE_PROBLEM'
get_state.short_description = _('State')
@property
def information(self):
"""
The information header on the draw interface, which is defined according to the
current state.
Warning: this property is synchronous.
"""
s = ""
if self.last_message:
s += self.last_message + "<br><br>"
match self.get_state():
case 'DICE_SELECT_POULES':
# Waiting for dices to determine pools and passage order
if self.current_round.number == 1:
# Specific information for the first round
s += """Nous allons commencer le tirage des problèmes.<br>
Vous pouvez à tout moment poser toute question si quelque chose
n'est pas clair ou ne va pas.<br><br>
Nous allons d'abord tirer les poules et l'ordre de passage
pour le premier tour avec toutes les équipes puis pour chaque poule,
nous tirerons l'ordre de tirage pour le tour et les problèmes.<br><br>"""
s += """
Les capitaines, vous pouvez désormais toustes lancer un dé 100,
en cliquant sur le gros bouton. Les poules et l'ordre de passage
lors du premier tour sera l'ordre croissant des dés, c'est-à-dire
que le plus petit lancer sera le premier à passer dans la poule A."""
case 'DICE_ORDER_POULE':
# Waiting for dices to determine the choice order
s += f"""Nous passons au tirage des problèmes pour la poule
<strong>{self.current_round.current_pool}</strong>, entre les équipes
<strong>{', '.join(td.participation.team.trigram
for td in self.current_round.current_pool.teamdraw_set.all())}</strong>.
Les capitaines peuvent lancer un dé 100 en cliquant sur le gros bouton
pour déterminer l'ordre de tirage. L'équipe réalisant le plus gros score pourra
tirer en premier."""
case 'WAITING_DRAW_PROBLEM':
# Waiting for a problem draw
td = self.current_round.current_pool.current_team
s += f"""C'est au tour de l'équipe <strong>{td.participation.team.trigram}</strong>
de choisir son problème. Cliquez sur l'urne au milieu pour tirer un problème au sort."""
case 'WAITING_CHOOSE_PROBLEM':
# Waiting for the team that can accept or reject the problem
td = self.current_round.current_pool.current_team
s += f"""L'équipe <strong>{td.participation.team.trigram}</strong> a tiré le problème
<strong>{td.purposed} : {settings.PROBLEMS[td.purposed - 1]}</strong>. """
if td.purposed in td.rejected:
# The problem was previously rejected
s += """Elle a déjà refusé ce problème auparavant, elle peut donc le refuser sans pénalité et
tirer un nouveau problème immédiatement, ou bien revenir sur son choix."""
else:
# The problem can be rejected
s += "Elle peut décider d'accepter ou de refuser ce problème. "
if len(td.rejected) >= len(settings.PROBLEMS) - 5:
s += "Refuser ce problème ajoutera une nouvelle pénalité de 0.5 sur le coefficient de l'oral de la défense."
else:
s += f"Il reste {len(settings.PROBLEMS) - 5 - len(td.rejected)} refus sans pénalité."
case 'WAITING_FINAL':
# We are between the two rounds of the final tournament
s += "Le tirage au sort pour le tour 2 aura lieu à la fin du premier tour. Bon courage !"
case 'DRAW_ENDED':
# The draw is ended
s += "Le tirage au sort est terminé. Les solutions des autres équipes peuvent être trouvées dans l'onglet « Ma participation »."
s += "<br><br>" if s else ""
s += """Pour plus de détails sur le déroulement du tirage au sort,
le règlement est accessible sur
<a class="alert-link" href="https://tfjm.org/reglement">https://tfjm.org/reglement</a>."""
return s
async def ainformation(self) -> str:
"""
Asynchronous version to get the information header content.
"""
return await sync_to_async(lambda: self.information)()
def __str__(self):
return str(format_lazy(_("Draw of tournament {tournament}"), tournament=self.tournament.name))
class Meta:
verbose_name = _('draw')
verbose_name_plural = _('draws')
class Round(models.Model):
"""
This model is attached to a :model:`draw.Draw` and represents the draw
for one round of the :model:`participation.Tournament`.
"""
draw = models.ForeignKey(
Draw,
on_delete=models.CASCADE,
verbose_name=_('draw'),
)
number = models.PositiveSmallIntegerField(
choices=[
(1, _('Round 1')),
(2, _('Round 2')),
],
verbose_name=_('number'),
help_text=_("The number of the round, 1 or 2"),
validators=[MinValueValidator(1), MaxValueValidator(2)],
)
current_pool = models.ForeignKey(
'Pool',
on_delete=models.CASCADE,
null=True,
default=None,
related_name='+',
verbose_name=_('current pool'),
help_text=_("The current pool where teams select their problems."),
)
def get_absolute_url(self):
return reverse_lazy('draw:index') + f'#{slugify(self.draw.tournament.name)}'
@property
def team_draws(self) -> QuerySet["TeamDraw"]:
"""
Returns a query set ordered by pool and by passage index of all team draws.
"""
return self.teamdraw_set.order_by('pool__letter', 'passage_index').all()
async def next_pool(self):
"""
Returns the next pool of the round.
For example, after the pool A, we have the pool B.
"""
pool = self.current_pool
return await self.pool_set.aget(letter=pool.letter + 1)
def __str__(self):
return self.get_number_display()
class Meta:
verbose_name = _('round')
verbose_name_plural = _('rounds')
ordering = ('draw__tournament__name', 'number',)
class Pool(models.Model):
"""
A Pool is a collection of teams in a :model:`draw.Round` of a `draw.Draw`.
It has a letter (eg. A, B, C or D) and a size, between 3 and 5.
After the draw, the pool can be exported in a `participation.Pool` instance.
"""
round = models.ForeignKey(
Round,
on_delete=models.CASCADE,
)
letter = models.PositiveSmallIntegerField(
choices=[
(1, 'A'),
(2, 'B'),
(3, 'C'),
(4, 'D'),
],
verbose_name=_('letter'),
help_text=_("The letter of the pool: A, B, C or D."),
)
size = models.PositiveSmallIntegerField(
verbose_name=_('size'),
validators=[MinValueValidator(3), MaxValueValidator(5)],
help_text=_("The number of teams in this pool, between 3 and 5."),
)
current_team = models.ForeignKey(
'TeamDraw',
on_delete=models.CASCADE,
null=True,
default=None,
related_name='+',
verbose_name=_('current team'),
help_text=_("The current team that is selecting its problem."),
)
associated_pool = models.OneToOneField(
'participation.Pool',
on_delete=models.SET_NULL,
null=True,
default=None,
related_name='draw_pool',
verbose_name=_("associated pool"),
help_text=_("The full pool instance."),
)
def get_absolute_url(self):
return reverse_lazy('draw:index') + f'#{slugify(self.round.draw.tournament.name)}'
@property
def team_draws(self) -> QuerySet["TeamDraw"]:
"""
Returns a query set ordered by passage index of all team draws in this pool.
"""
return self.teamdraw_set.order_by('passage_index').all()
@property
def trigrams(self) -> list[str]:
"""
Returns a list of trigrams of the teams in this pool ordered by passage index.
This property is synchronous.
"""
return [td.participation.team.trigram for td in self.teamdraw_set.order_by('passage_index')
.prefetch_related('participation__team').all()]
async def atrigrams(self) -> list[str]:
"""
Returns a list of trigrams of the teams in this pool ordered by passage index.
This property is asynchronous.
"""
return [td.participation.team.trigram async for td in self.teamdraw_set.order_by('passage_index')
.prefetch_related('participation__team').all()]
async def next_td(self) -> "TeamDraw":
"""
Returns the next team draw after the current one, to know who should draw a new problem.
"""
td = self.current_team
current_index = (td.choose_index + 1) % self.size
td = await self.teamdraw_set.prefetch_related('participation__team').aget(choose_index=current_index)
while td.accepted:
# Ignore if the next team already accepted its problem
current_index += 1
current_index %= self.size
td = await self.teamdraw_set.prefetch_related('participation__team').aget(choose_index=current_index)
return td
@property
def exportable(self) -> bool:
"""
True if this pool is exportable, ie. can be exported to the tournament interface. That means that
each team selected its problem.
This operation is synchronous.
"""
return self.associated_pool_id is None and self.teamdraw_set.exists() \
and all(td.accepted is not None for td in self.teamdraw_set.all())
async def is_exportable(self) -> bool:
"""
True if this pool is exportable, ie. can be exported to the tournament interface. That means that
each team selected its problem.
This operation is asynchronous.
"""
return self.associated_pool_id is None and await self.teamdraw_set.aexists() \
and all([td.accepted is not None async for td in self.teamdraw_set.all()])
async def export(self) -> PPool:
"""
Translates this Pool instance in a :model:`participation.Pool` instance, with the passage orders.
"""
# Create the pool
self.associated_pool, _created = await PPool.objects.aget_or_create(
tournament=self.round.draw.tournament,
round=self.round.number,
letter=self.letter,
)
# Define the participations of the pool
tds = [td async for td in self.team_draws.prefetch_related('participation')]
await self.associated_pool.participations.aset([td.participation async for td in self.team_draws
.prefetch_related('participation')])
await self.asave()
# Define the passage matrix according to the number of teams
table = []
if self.size == 3:
table = [
[0, 1, 2],
[1, 2, 0],
[2, 0, 1],
]
elif self.size == 4:
table = [
[0, 1, 2, 3],
[1, 2, 3, 0],
[2, 3, 0, 1],
[3, 0, 1, 2],
]
elif self.size == 5:
table = [
[0, 3, 2],
[1, 4, 3],
[2, 0, 4],
[3, 1, 0],
[4, 2, 1],
]
for i, line in enumerate(table):
# Create the passage
passage = await Passage.objects.acreate(
pool=self.associated_pool,
position=i + 1,
solution_number=tds[line[0]].accepted,
defender=tds[line[0]].participation,
opponent=tds[line[1]].participation,
reporter=tds[line[2]].participation,
defender_penalties=tds[line[0]].penalty_int,
)
if self.size == 4:
# Add observer for 4-teams pools
passage.observer = tds[line[3]].participation
await passage.asave()
# Update Google Sheets
if os.getenv('GOOGLE_PRIVATE_KEY_ID', None):
await sync_to_async(self.associated_pool.update_spreadsheet)()
return self.associated_pool
def __str__(self):
return str(format_lazy(_("Pool {letter}{number}"), letter=self.get_letter_display(), number=self.round.number))
class Meta:
verbose_name = _('pool')
verbose_name_plural = _('pools')
ordering = ('round__draw__tournament__name', 'round__number', 'letter',)
class TeamDraw(models.Model):
"""
This model represents the state of the draw for a given team, including
its accepted problem or their rejected ones.
"""
participation = models.ForeignKey(
Participation,
on_delete=models.CASCADE,
verbose_name=_('participation'),
)
round = models.ForeignKey(
Round,
on_delete=models.CASCADE,
verbose_name=_('round'),
)
pool = models.ForeignKey(
Pool,
on_delete=models.CASCADE,
null=True,
default=None,
verbose_name=_('pool'),
)
passage_index = models.PositiveSmallIntegerField(
choices=zip(range(0, 5), range(0, 5)),
null=True,
default=None,
verbose_name=_('passage index'),
help_text=_("The passage order in the pool, between 0 and the size of the pool minus 1."),
validators=[MinValueValidator(0), MaxValueValidator(4)],
)
choose_index = models.PositiveSmallIntegerField(
choices=zip(range(0, 5), range(0, 5)),
null=True,
default=None,
verbose_name=_('choose index'),
help_text=_("The choice order in the pool, between 0 and the size of the pool minus 1."),
validators=[MinValueValidator(0), MaxValueValidator(4)],
)
accepted = models.PositiveSmallIntegerField(
choices=[
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, len(settings.PROBLEMS) + 1)
],
null=True,
default=None,
verbose_name=_("accepted problem"),
)
passage_dice = models.PositiveSmallIntegerField(
choices=zip(range(1, 101), range(1, 101)),
null=True,
default=None,
verbose_name=_("passage dice"),
)
choice_dice = models.PositiveSmallIntegerField(
choices=zip(range(1, 101), range(1, 101)),
null=True,
default=None,
verbose_name=_("choice dice"),
)
purposed = models.PositiveSmallIntegerField(
choices=[
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, len(settings.PROBLEMS) + 1)
],
null=True,
default=None,
verbose_name=_("purposed problem"),
)
rejected = models.JSONField(
default=list,
verbose_name=_('rejected problems'),
)
def get_absolute_url(self):
return reverse_lazy('draw:index') + f'#{slugify(self.round.draw.tournament.name)}'
@property
def last_dice(self):
"""
The last dice that was thrown.
"""
return self.passage_dice if self.round.draw.get_state() == 'DICE_SELECT_POULES' else self.choice_dice
@property
def penalty_int(self):
"""
The number of penalties, which is the number of rejected problems after the P - 5 free rejects,
where P is the number of problems.
"""
return max(0, len(self.rejected) - (len(settings.PROBLEMS) - 5))
@property
def penalty(self):
"""
The penalty multiplier on the defender oral, which is a malus of 0.5 for each penalty.
"""
return 0.5 * self.penalty_int
def __str__(self):
return str(format_lazy(_("Draw of the team {trigram} for the pool {letter}{number}"),
trigram=self.participation.team.trigram,
letter=self.pool.get_letter_display() if self.pool else "",
number=self.round.number))
class Meta:
verbose_name = _('team draw')
verbose_name_plural = _('team draws')
ordering = ('round__draw__tournament__name', 'round__number', 'pool__letter', 'passage_index',)