Add a lot of comments

Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
This commit is contained in:
Emmy D'Anello 2023-04-04 19:52:44 +02:00
parent 82cda0b279
commit 9cfab53bd2
Signed by: ynerant
GPG Key ID: 3A75C55819C8CF85
8 changed files with 781 additions and 192 deletions

View File

@ -15,6 +15,10 @@ from registration.models import Registration
def ensure_orga(f):
"""
This decorator to an asynchronous receiver guarantees that the user is a volunteer.
If it is not the case, we send an alert and don't run the function.
"""
async def func(self, *args, **kwargs):
reg = self.registration
if reg.is_volunteer and not reg.is_admin and self.tournament not in reg.interesting_tournaments \
@ -27,15 +31,33 @@ def ensure_orga(f):
class DrawConsumer(AsyncJsonWebsocketConsumer):
async def connect(self):
"""
This consumer manages the websocket of the draw interface.
"""
def __int__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.tournament_id = None
self.tournament = None
self.participations = None
self.registration = None
async def connect(self) -> None:
"""
This function is called when a new websocket is trying to connect to the server.
We accept only if this is a user of a team of the associated tournament, or a volunteer
of the tournament.
"""
# Get the tournament from the URL
self.tournament_id = self.scope['url_route']['kwargs']['tournament_id']
self.tournament = await Tournament.objects.filter(pk=self.tournament_id)\
.prefetch_related('draw__current_round__current_pool__current_team__participation__team').aget()
# Fetch participations from the tournament
self.participations = []
async for participation in self.tournament.participations.filter(valid=True).prefetch_related('team'):
self.participations.append(participation)
# Fetch the registration of the current user
user = self.scope['user']
reg = await Registration.objects.aget(user=user)
self.registration = reg
@ -45,14 +67,22 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
await self.close()
return
# Accept the connection
await self.accept()
# Register to channel layers to get updates
await self.channel_layer.group_add(f"tournament-{self.tournament.id}", self.channel_name)
if not self.registration.is_volunteer:
await self.channel_layer.group_add(f"team-{self.registration.team.trigram}", self.channel_name)
else:
await self.channel_layer.group_add(f"volunteer-{self.tournament.id}", self.channel_name)
async def disconnect(self, close_code):
async def disconnect(self, close_code) -> None:
"""
Called when the websocket got disconnected, for any reason.
:param close_code: The error code.
"""
# Unregister from channel layers
await self.channel_layer.group_discard(f"tournament-{self.tournament.id}", self.channel_name)
if not self.registration.is_volunteer:
await self.channel_layer.group_discard(f"team-{self.registration.team.trigram}", self.channel_name)
@ -60,75 +90,106 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
await self.channel_layer.group_discard(f"volunteer-{self.tournament.id}", self.channel_name)
async def alert(self, message: str, alert_type: str = 'info', **kwargs):
"""
Send an alert message to the current user.
:param message: The body of the alert.
:param alert_type: The type of the alert, which is a bootstrap color (success, warning, info, danger,)
"""
return await self.send_json({'type': 'alert', 'alert_type': alert_type, 'message': str(message)})
async def receive_json(self, content, **kwargs):
print(content)
"""
Called when the client sends us some data, parsed as JSON.
:param content: The sent data, decoded from JSON text. Must content a `type` field.
"""
# Refresh tournament
self.tournament = await Tournament.objects.filter(pk=self.tournament_id)\
.prefetch_related('draw__current_round__current_pool__current_team__participation__team').aget()
match content['type']:
case 'set_language':
# Update the translation language
translation.activate(content['language'])
case 'start_draw':
# Start a new draw
await self.start_draw(**content)
case 'abort':
# Abort the current draw
await self.abort(**content)
case 'dice':
# Launch a dice
await self.process_dice(**content)
case 'draw_problem':
# Draw a new problem
await self.select_problem(**content)
case 'accept':
# Accept the proposed problem
await self.accept_problem(**content)
case 'reject':
# Reject the proposed problem
await self.reject_problem(**content)
case 'export':
# Export the current state of the draw
await self.export(**content)
case 'continue_final':
# Continue the draw for the final tournament
await self.continue_final(**content)
@ensure_orga
async def start_draw(self, fmt, **kwargs):
async def start_draw(self, fmt: str, **kwargs) -> None:
"""
Initialize a new draw, with a given format.
:param fmt: The format of the tournament, which is the size of each pool.
Sizes must be between 3 and 5, and the sum must be the number of teams.
"""
if await Draw.objects.filter(tournament=self.tournament).aexists():
return await self.alert(_("The draw is already started."), 'danger')
try:
fmt = sorted(map(int, fmt.split('+')), reverse=True)
except ValueError as e:
# Parse format from string
fmt: list[int] = sorted(map(int, fmt.split('+')), reverse=True)
except ValueError as _ignored:
return await self.alert(_("Invalid format"), 'danger')
# Ensure that the number of teams is good
if sum(fmt) != len(self.participations):
return await self.alert(
_("The sum must be equal to the number of teams: expected {len}, got {sum}")\
.format(len=len(self.participations), sum=sum(fmt)), 'danger')
# The drawing system works with a maximum of 1 pool of 5 teams, which is already the case in the TFJM²
if fmt.count(5) > 1:
return await self.alert(_("There can be at most one pool with 5 teams."), 'danger')
# Create the draw
draw = await Draw.objects.acreate(tournament=self.tournament)
r1 = None
for i in [1, 2]:
# Create the round
r = await Round.objects.acreate(draw=draw, number=i)
if i == 1:
r1 = r
for j, f in enumerate(fmt):
# Create the pool, and correspond the size with the wanted format
await Pool.objects.acreate(round=r, letter=j + 1, size=f)
for participation in self.participations:
# Create a team draw object per participation
await TeamDraw.objects.acreate(participation=participation, round=r)
# Send to clients the different pools
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.send_poules', 'round': r})
draw.current_round = r1
await draw.asave()
async for td in r1.teamdraw_set.prefetch_related('participation__team').all():
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'type': 'draw.dice_visibility', 'visible': True})
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
# Make dice box visible
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.dice_visibility', 'visible': True})
await self.alert(_("Draw started!"), 'success')
# Update user interface
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.start', 'fmt': fmt, 'draw': draw})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
@ -136,27 +197,60 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.set_active', 'draw': self.tournament.draw})
async def draw_start(self, content):
# Send notification to everyone
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.notify', 'title': 'Tirage au sort du TFJM²',
'body': "Le tirage au sort du tournoi de "
f"{self.tournament.name} a commencé !"})
async def draw_start(self, content) -> None:
"""
Send information to users that the draw has started.
"""
await self.alert(_("The draw for the tournament {tournament} will start.")\
.format(tournament=self.tournament.name), 'warning')
await self.send_json({'type': 'draw_start', 'fmt': content['fmt'],
'trigrams': [p.team.trigram for p in self.participations]})
@ensure_orga
async def abort(self, **kwargs):
async def abort(self, **kwargs) -> None:
"""
Abort the current draw and delete all associated information.
"""
if not await Draw.objects.filter(tournament=self.tournament).aexists():
return await self.alert(_("The draw has not started yet."), 'danger')
# Delete draw
# All associated data will be deleted by cascade
await self.tournament.draw.adelete()
# Send information to all users
await self.channel_layer.group_send(f"tournament-{self.tournament.id}", {'type': 'draw_abort'})
async def draw_abort(self, content):
async def draw_abort(self, content) -> None:
"""
Send information to users that the draw was aborted.
"""
await self.alert(_("The draw for the tournament {tournament} is aborted.")\
.format(tournament=self.tournament.name), 'danger')
await self.send_json({'type': 'abort'})
async def process_dice(self, trigram: str | None = None, **kwargs):
"""
Launch the dice for a team.
If we are in the first step, that determine the passage order and the pools of each team.
For the second step, that determines the order of the teams to draw problems.
:param trigram: The team that we want to force the launch. None if we launch for our team, or for the
first free team in the case of volunteers.
"""
if not await Draw.objects.filter(tournament=self.tournament).aexists():
return await self.alert(_("The draw has not started yet."), 'danger')
state = self.tournament.draw.get_state()
if self.registration.is_volunteer:
# A volunteer can either force the launch for a specific team,
# or launch for the first team that has not launched its dice.
if trigram:
participation = await Participation.objects.filter(team__trigram=trigram)\
.prefetch_related('team').aget()
@ -171,10 +265,12 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
.filter(teamdraw__round=self.tournament.draw.current_round,
teamdraw__passage_dice__isnull=True).prefetch_related('team').afirst()
else:
# Fetch the participation of the current user
participation = await Participation.objects.filter(team__participants=self.registration)\
.prefetch_related('team').aget()
if participation is None:
# Should not happen in normal cases
return await self.alert(_("This is not the time for this."), 'danger')
trigram = participation.team.trigram
@ -182,6 +278,9 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
team_draw = await TeamDraw.objects.filter(participation=participation,
round_id=self.tournament.draw.current_round_id).aget()
# Ensure that this is the right state to launch a dice and that the team didn't already launch the dice
# and that it can launch a dice yet.
# Prevent some async issues
match state:
case 'DICE_SELECT_POULES':
if team_draw.passage_dice is not None:
@ -195,6 +294,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
case _:
return await self.alert(_("This is not the time for this."), 'danger')
# Launch the dice and get the result
res = randint(1, 100)
if state == 'DICE_SELECT_POULES':
team_draw.passage_dice = res
@ -202,169 +302,222 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
team_draw.choice_dice = res
await team_draw.asave()
# Send the dice result to all users
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}", {'type': 'draw.dice', 'team': trigram, 'result': res})
if state == 'DICE_SELECT_POULES' and \
not await TeamDraw.objects.filter(round_id=self.tournament.draw.current_round_id,
passage_dice__isnull=True).aexists():
tds = []
async for td in TeamDraw.objects.filter(round_id=self.tournament.draw.current_round_id)\
.prefetch_related('participation__team'):
tds.append(td)
dices = {td: td.passage_dice for td in tds}
values = list(dices.values())
error = False
for v in set(values):
if values.count(v) > 1:
dups = [td for td in tds if td.passage_dice == v]
for dup in dups:
dup.passage_dice = None
await dup.asave()
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}",
{'type': 'draw.dice', 'team': dup.participation.team.trigram, 'result': None})
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}",
{'type': 'draw.alert',
'message': _('Dices from teams {teams} are identical. Please relaunch your dices.').format(
teams=', '.join(td.participation.team.trigram for td in dups)),
'alert_type': 'warning'})
error = True
if error:
# Check duplicates
if await self.check_duplicate_dices():
return
tds.sort(key=lambda td: td.passage_dice)
tds_copy = tds.copy()
async for p in Pool.objects.filter(round_id=self.tournament.draw.current_round_id).order_by('letter').all():
pool_tds = sorted(tds_copy[:p.size], key=lambda td: (td.passage_dice * 27) % 100)
tds_copy = tds_copy[p.size:]
for i, td in enumerate(pool_tds):
td.pool = p
td.passage_index = i
await td.asave()
tds_copy = tds.copy()
round2 = await self.tournament.draw.round_set.filter(number=2).aget()
round2_pools = [p async for p in Pool.objects.filter(round__draw__tournament=self.tournament, round=round2)\
.order_by('letter').all()]
current_pool_id, current_passage_index = 0, 0
for i, td in enumerate(tds_copy):
if i == len(tds) - 1 and round2_pools[0].size == 5:
current_pool_id = 0
current_passage_index = 4
td2 = await TeamDraw.objects.filter(participation=td.participation, round=round2).aget()
td2.pool = round2_pools[current_pool_id]
td2.passage_index = current_passage_index
current_pool_id += 1
if current_pool_id == len(round2_pools):
current_pool_id = 0
current_passage_index += 1
await td2.asave()
pool = await Pool.objects.filter(round=self.tournament.draw.current_round, letter=1).aget()
self.tournament.draw.current_round.current_pool = pool
await self.tournament.draw.current_round.asave()
msg = "Les résultats des dés sont les suivants : "
msg += ", ".join(f"<strong>{td.participation.team.trigram}</strong> ({td.passage_dice})" for td in tds)
msg += ". L'ordre de passage et les compositions des différentes poules sont affiché⋅es sur le côté. "
msg += "Attention : les ordres de passage sont déterminés à partir des scores des dés, mais ne sont pas "
msg += "directement l'ordre croissant des dés, afin d'avoir des poules mélangées."
self.tournament.draw.last_message = msg
await self.tournament.draw.asave()
for td in tds:
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}",
{'type': 'draw.dice', 'team': td.participation.team.trigram, 'result': None})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.dice_visibility', 'visible': False})
async for td in pool.teamdraw_set.prefetch_related('participation__team').all():
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'type': 'draw.dice_visibility', 'visible': True})
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
{'type': 'draw.dice_visibility', 'visible': True})
# First send the second pool to have the good team order
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.send_poules',
'round': await self.tournament.draw.round_set.filter(number=2).aget()})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.send_poules',
'round': self.tournament.draw.current_round})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.set_info', 'draw': self.tournament.draw})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.set_active', 'draw': self.tournament.draw})
# All teams launched their dice, we can process the result
await self.process_dice_select_poules()
elif state == 'DICE_ORDER_POULE' and \
not await TeamDraw.objects.filter(pool=self.tournament.draw.current_round.current_pool,
choice_dice__isnull=True).aexists():
pool = self.tournament.draw.current_round.current_pool
# Check duplicates
if await self.check_duplicate_dices():
return
# All teams launched their dice for the choice order, we can process the result
await self.process_dice_order_poule()
tds = []
async for td in TeamDraw.objects.filter(pool=pool)\
.prefetch_related('participation__team'):
tds.append(td)
async def check_duplicate_dices(self) -> bool:
"""
Check that all dices are distinct, and reset some dices if necessary.
:return: True if there are duplicate dices, False otherwise.
"""
state = self.tournament.draw.get_state()
# Get concerned TeamDraw objects
if state == 'DICE_SELECT_POULES':
tds = [td async for td in TeamDraw.objects.filter(round_id=self.tournament.draw.current_round_id) \
.prefetch_related('participation__team')]
dices = {td: td.passage_dice for td in tds}
else:
tds = [td async for td in TeamDraw.objects\
.filter(pool_id=self.tournament.draw.current_round.current_pool_id)\
.prefetch_related('participation__team')]
dices = {td: td.choice_dice for td in tds}
values = list(dices.values())
error = False
for v in set(values):
if values.count(v) > 1:
dups = [td for td in tds if td.choice_dice == v]
for dup in dups:
dup.choice_dice = None
await dup.asave()
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}",
{'type': 'draw.dice', 'team': dup.participation.team.trigram, 'result': None})
values = list(dices.values())
error = False
for v in set(values):
if values.count(v) > 1:
# v is a duplicate value
# Get all teams that have the same result
dups = [td for td in tds if td.passage_dice == v]
for dup in dups:
# Reset the dice
dup.passage_dice = None
await dup.asave()
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}",
{'type': 'draw.alert',
'message': _('Dices from teams {teams} are identical. Please relaunch your dices.').format(
teams=', '.join(td.participation.team.trigram for td in dups)),
'alert_type': 'warning'})
error = True
{'type': 'draw.dice', 'team': dup.participation.team.trigram, 'result': None})
if error:
return
# Send notification to concerned teams
await self.channel_layer.group_send(
f"team-{dup.participation.team.trigram}",
{'type': 'draw.notify', 'title': 'Tirage au sort du TFJM²',
'body': 'Votre score de dé est identique à celui de une ou plusieurs équipes. '
'Veuillez le relancer.'}
)
# Alert the tournament
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}",
{'type': 'draw.alert',
'message': _('Dices from teams {teams} are identical. Please relaunch your dices.').format(
teams=', '.join(td.participation.team.trigram for td in dups)),
'alert_type': 'warning'})
error = True
tds.sort(key=lambda x: -x.choice_dice)
for i, td in enumerate(tds):
td.choose_index = i
return error
async def process_dice_select_poules(self):
"""
Called when all teams launched their dice.
Place teams into pools and order their passage.
"""
r = self.tournament.draw.current_round
tds = [td async for td in TeamDraw.objects.filter(round=r).prefetch_related('participation__team')]
# Sort teams per dice results
tds.sort(key=lambda td: td.passage_dice)
tds_copy = tds.copy()
# For each pool of size N, put the N next teams into this pool
async for p in Pool.objects.filter(round_id=self.tournament.draw.current_round_id).order_by('letter').all():
# Fetch the N teams, then order them in a new order for the passages inside the pool
# We multiply the dice scores by 27 mod 100 (which order is 20 mod 100) for this new order
# This simulates a deterministic shuffle
pool_tds = sorted(tds_copy[:p.size], key=lambda td: (td.passage_dice * 27) % 100)
# Remove the head
tds_copy = tds_copy[p.size:]
for i, td in enumerate(pool_tds):
# Set the pool and the passage index for each team of the pool
td.pool = p
td.passage_index = i
await td.asave()
pool.current_team = tds[0]
await pool.asave()
# The passages of the second round are determined from the scores of the dices
# The team that has the lowest dice score goes to the first pool, then the team
# that has the second-lowest score goes to the second pool, etc.
# This also determines the passage order, in the natural order this time.
# If there is a 5-teams pool, we force the last team to be in the first pool,
# which is this specific pool since they are ordered by decreasing size.
tds_copy = tds.copy()
round2 = await self.tournament.draw.round_set.filter(number=2).aget()
round2_pools = [p async for p in Pool.objects.filter(round__draw__tournament=self.tournament, round=round2) \
.order_by('letter').all()]
current_pool_id, current_passage_index = 0, 0
for i, td in enumerate(tds_copy):
if i == len(tds) - 1 and round2_pools[0].size == 5:
current_pool_id = 0
current_passage_index = 4
self.tournament.draw.last_message = ""
await self.tournament.draw.asave()
td2 = await TeamDraw.objects.filter(participation=td.participation, round=round2).aget()
td2.pool = round2_pools[current_pool_id]
td2.passage_index = current_passage_index
current_pool_id += 1
if current_pool_id == len(round2_pools):
current_pool_id = 0
current_passage_index += 1
await td2.asave()
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.set_info', 'draw': self.tournament.draw})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.set_active', 'draw': self.tournament.draw})
# The current pool is the first pool of the current (first) round
pool = await Pool.objects.filter(round=self.tournament.draw.current_round, letter=1).aget()
self.tournament.draw.current_round.current_pool = pool
await self.tournament.draw.current_round.asave()
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.dice_visibility', 'visible': False})
# Display dice result in the header of the information alert
msg = "Les résultats des dés sont les suivants : "
msg += ", ".join(f"<strong>{td.participation.team.trigram}</strong> ({td.passage_dice})" for td in tds)
msg += ". L'ordre de passage et les compositions des différentes poules sont affiché⋅es sur le côté. "
msg += "Attention : les ordres de passage sont déterminés à partir des scores des dés, mais ne sont pas "
msg += "directement l'ordre croissant des dés, afin d'avoir des poules mélangées."
self.tournament.draw.last_message = msg
await self.tournament.draw.asave()
trigram = pool.current_team.participation.team.trigram
await self.channel_layer.group_send(f"team-{trigram}",
{'type': 'draw.box_visibility', 'visible': True})
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
{'type': 'draw.box_visibility', 'visible': True})
# Reset team dices
for td in tds:
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}",
{'type': 'draw.dice', 'team': td.participation.team.trigram, 'result': None})
# Hide dice interface
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.dice_visibility', 'visible': False})
# Display dice interface only for the teams in the first pool, and for volunteers
async for td in pool.teamdraw_set.prefetch_related('participation__team').all():
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'type': 'draw.dice_visibility', 'visible': True})
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
{'type': 'draw.dice_visibility', 'visible': True})
# First send the second pool to have the good team order
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.send_poules',
'round': await self.tournament.draw.round_set.filter(number=2).aget()})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.send_poules',
'round': self.tournament.draw.current_round})
# Update information header and the active team on the recap menu
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.set_info', 'draw': self.tournament.draw})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.set_active', 'draw': self.tournament.draw})
async def process_dice_order_poule(self):
"""
Called when all teams of the current launched their dice to determine the choice order.
Place teams into pools and order their passage.
"""
pool = self.tournament.draw.current_round.current_pool
tds = [td async for td in TeamDraw.objects.filter(pool=pool).prefetch_related('participation__team')]
# Order teams by decreasing dice score
tds.sort(key=lambda x: -x.choice_dice)
for i, td in enumerate(tds):
td.choose_index = i
await td.asave()
# The first team to draw its problem is the team that has the highest dice score
pool.current_team = tds[0]
await pool.asave()
self.tournament.draw.last_message = ""
await self.tournament.draw.asave()
# Update information header
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.set_info', 'draw': self.tournament.draw})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.set_active', 'draw': self.tournament.draw})
# Hide dice button to everyone
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.dice_visibility', 'visible': False})
# Display the box button to the first team and to volunteers
trigram = pool.current_team.participation.team.trigram
await self.channel_layer.group_send(f"team-{trigram}",
{'type': 'draw.box_visibility', 'visible': True})
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
{'type': 'draw.box_visibility', 'visible': True})
# Notify the team that it can draw a problem
self.channel_layer.group_send(f"team-{tds[0].participation.team.trigram}",
{'type': 'draw.notify', 'title': "À votre tour !",
'body': "C'est à vous de tirer un nouveau problème !"})
async def select_problem(self, **kwargs):
"""
Called when a team draws a problem.
We choose randomly a problem that is available and propose it to the current team.
"""
state = self.tournament.draw.get_state()
if state != 'WAITING_DRAW_PROBLEM':
@ -372,25 +525,32 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
pool = self.tournament.draw.current_round.current_pool
td = pool.current_team
if not self.registration.is_volunteer:
participation = await Participation.objects.filter(team__participants=self.registration)\
.prefetch_related('team').aget()
# Ensure that the user can draws a problem at this time
if participation.id != td.participation_id:
return await self.alert("This is not your turn.", 'danger')
while True:
# Choose a random problem
problem = randint(1, len(settings.PROBLEMS))
# Check that the user didn't already accept this problem for the first round
# if this is the second round
if await TeamDraw.objects.filter(participation_id=td.participation_id,
round__draw__tournament=self.tournament,
round__number=1,
purposed=problem).aexists():
continue
# Check that the problem is not already chosen once (or twice for a 5-teams pool)
if await pool.teamdraw_set.filter(accepted=problem).acount() < (2 if pool.size == 5 else 1):
break
td.purposed = problem
await td.asave()
# Update interface
trigram = td.participation.team.trigram
await self.channel_layer.group_send(f"team-{trigram}",
{'type': 'draw.box_visibility', 'visible': False})
@ -409,6 +569,14 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
{'type': 'draw.set_info', 'draw': self.tournament.draw})
async def accept_problem(self, **kwargs):
"""
Called when a team accepts a problem.
We pass to the next team is there is one, or to the next pool, or the next round, or end the draw.
"""
if not await Draw.objects.filter(tournament=self.tournament).aexists():
return await self.alert(_("The draw has not started yet."), 'danger')
state = self.tournament.draw.get_state()
if state != 'WAITING_CHOOSE_PROBLEM':
@ -420,6 +588,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
if not self.registration.is_volunteer:
participation = await Participation.objects.filter(team__participants=self.registration)\
.prefetch_related('team').aget()
# Ensure that the user can accept a problem at this time
if participation.id != td.participation_id:
return await self.alert("This is not your turn.", 'danger')
@ -437,6 +606,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
self.tournament.draw.last_message = msg
await self.tournament.draw.asave()
# Send the accepted problem to the users
await self.channel_layer.group_send(f"team-{trigram}",
{'type': 'draw.buttons_visibility', 'visible': False})
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
@ -448,7 +618,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
'problem': td.accepted})
if await pool.teamdraw_set.filter(accepted__isnull=True).aexists():
# Continue
# Continue this pool since there is at least one team that does not have selected its problem
# Get next team
next_td = await pool.next_td()
pool.current_team = next_td
await pool.asave()
@ -458,6 +629,11 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
{'type': 'draw.box_visibility', 'visible': True})
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
{'type': 'draw.box_visibility', 'visible': True})
# Notify the team that it can draw a problem
self.channel_layer.group_send(f"team-{new_trigram}",
{'type': 'draw.notify', 'title': "À votre tour !",
'body': "C'est à vous de tirer un nouveau problème !"})
else:
# Pool is ended
if pool.size == 5:
@ -482,8 +658,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
p_index += 1
await tds[0].asave()
print(p_index)
# Send the reordered pool
await self.channel_layer.group_send(f"tournament-{self.tournament.id}", {
'type': 'draw.reorder_pool',
'round': r.number,
@ -497,12 +672,22 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
f"Le tableau récapitulatif est en bas."
self.tournament.draw.last_message = msg
await self.tournament.draw.asave()
if await r.teamdraw_set.filter(accepted__isnull=True).aexists():
# Next pool
# There is a pool that does not have selected its problem, so we continue to the next pool
next_pool = await r.next_pool()
r.current_pool = next_pool
await r.asave()
async for td in next_pool.team_draws.prefetch_related('participation__team').all():
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'type': 'draw.dice_visibility', 'visible': True})
# Notify the team that it can draw a dice
self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'type': 'draw.notify', 'title': "À votre tour !",
'body': "C'est à vous de lancer le dé !"})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.dice_visibility', 'visible': True})
else:
@ -520,6 +705,11 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
f"tournament-{self.tournament.id}",
{'type': 'draw.dice', 'team': participation.team.trigram, 'result': None})
# Notify the team that it can draw a dice
self.channel_layer.group_send(f"team-{participation.team.trigram}",
{'type': 'draw.notify', 'title': "À votre tour !",
'body': "C'est à vous de lancer le dé !"})
# Reorder dices
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.send_poules',
@ -551,6 +741,13 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
{'type': 'draw.set_active', 'draw': self.tournament.draw})
async def reject_problem(self, **kwargs):
"""
Called when a team accepts a problem.
We pass then to the next team.
"""
if not await Draw.objects.filter(tournament=self.tournament).aexists():
return await self.alert(_("The draw has not started yet."), 'danger')
state = self.tournament.draw.get_state()
if state != 'WAITING_CHOOSE_PROBLEM':
@ -562,9 +759,11 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
if not self.registration.is_volunteer:
participation = await Participation.objects.filter(team__participants=self.registration)\
.prefetch_related('team').aget()
# Ensure that the user can reject a problem at this time
if participation.id != td.participation_id:
return await self.alert("This is not your turn.", 'danger')
# Add the problem to the rejected problems list
problem = td.purposed
already_refused = problem in td.rejected
if not already_refused:
@ -574,6 +773,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
remaining = len(settings.PROBLEMS) - 5 - len(td.rejected)
# Update messages
trigram = td.participation.team.trigram
msg = f"L'équipe <strong>{trigram}</strong> a refusé le problème <strong>{problem} : " \
f"{settings.PROBLEMS[problem - 1]}</strong>. "
@ -587,6 +787,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
self.tournament.draw.last_message = msg
await self.tournament.draw.asave()
# Update interface
await self.channel_layer.group_send(f"team-{trigram}",
{'type': 'draw.buttons_visibility', 'visible': False})
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
@ -596,8 +797,10 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
'round': r.number, 'team': trigram, 'rejected': td.rejected})
if already_refused:
# The team already refused this problem, and can immediately draw a new one
next_td = td
else:
# We pass to the next team
next_td = await pool.next_td()
pool.current_team = next_td
@ -614,8 +817,21 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.set_active', 'draw': self.tournament.draw})
# Notify the team that it can draw a problem
self.channel_layer.group_send(f"team-{new_trigram}",
{'type': 'draw.notify', 'title': "À votre tour !",
'body': "C'est à vous de tirer un nouveau problème !"})
@ensure_orga
async def export(self, **kwargs):
"""
Exports the draw information in the participation app, for the solutions and notes management
"""
if not await Draw.objects.filter(tournament=self.tournament).aexists():
return await self.alert(_("The draw has not started yet."), 'danger')
# Export each exportable pool
async for r in self.tournament.draw.round_set.all():
async for pool in r.pool_set.all():
if await pool.is_exportable():
@ -626,6 +842,12 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
@ensure_orga
async def continue_final(self, **kwargs):
"""
For the final tournament, continue the draw for the second round
"""
if not await Draw.objects.filter(tournament=self.tournament).aexists():
return await self.alert(_("The draw has not started yet."), 'danger')
if not self.tournament.final:
return await self.alert(_("This is only available for the final tournament."), 'danger')
@ -636,17 +858,21 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
self.tournament.draw.last_message = msg
await self.tournament.draw.asave()
# Set the first pool of the second round as the active pool
pool = await Pool.objects.filter(round=self.tournament.draw.current_round, letter=1).aget()
r2.current_pool = pool
await r2.asave()
# Fetch notes from the first round
notes = dict()
async for participation in self.tournament.participations.filter(valid=True).prefetch_related('team').all():
notes[participation] = sum([await pool.aaverage(participation)
async for pool in self.tournament.pools.filter(participations=participation)\
.prefetch_related('passages').prefetch_related('tweaks')
if pool.results_available])
# Sort notes in a decreasing order
ordered_participations = sorted(notes.keys(), key=lambda x: -notes[x])
# Define pools and passage orders from the ranking of the first round
async for pool in r2.pool_set.order_by('letter').all():
for i in range(pool.size):
participation = ordered_participations.pop(0)
@ -654,16 +880,26 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
td.pool = pool
td.passage_index = i
await td.asave()
# Send pools to users
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'type': 'draw.send_poules', 'round': r2})
# Reset dices and update interface
for participation in self.participations:
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}",
{'type': 'draw.dice', 'team': participation.team.trigram, 'result': None})
await self.channel_layer.group_send(f"team-{participation.team.trigram}",
async for td in r2.current_pool.team_draws.prefetch_related('participation__team'):
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'type': 'draw.dice_visibility', 'visible': True})
# Notify the team that it can draw a problem
self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'type': 'draw.notify', 'title': "À votre tour !",
'body': "C'est à vous de tirer un nouveau problème !"})
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
{'type': 'draw.dice_visibility', 'visible': True})
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
@ -675,38 +911,71 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
{'type': 'draw.set_active', 'draw': self.tournament.draw})
async def draw_alert(self, content):
"""
Send alert to the current user.
"""
return await self.alert(**content)
async def draw_notify(self, content):
"""
Send a notification (with title and body) to the current user.
"""
await self.send_json({'type': 'notification', 'title': content['title'], 'body': content['body']})
async def draw_set_info(self, content):
"""
Set the information banner to the current user.
"""
await self.send_json({'type': 'set_info', 'information': await content['draw'].ainformation()})
async def draw_dice(self, content):
"""
Update the dice of a given team for the current user interface.
"""
await self.send_json({'type': 'dice', 'team': content['team'], 'result': content['result']})
async def draw_dice_visibility(self, content):
"""
Update the visibility of the dice button for the current user.
"""
await self.send_json({'type': 'dice_visibility', 'visible': content['visible']})
async def draw_box_visibility(self, content):
"""
Update the visibility of the box button for the current user.
"""
await self.send_json({'type': 'box_visibility', 'visible': content['visible']})
async def draw_buttons_visibility(self, content):
"""
Update the visibility of the accept/reject buttons for the current user.
"""
await self.send_json({'type': 'buttons_visibility', 'visible': content['visible']})
async def draw_export_visibility(self, content):
"""
Update the visibility of the export button for the current user.
"""
await self.send_json({'type': 'export_visibility', 'visible': content['visible']})
async def draw_continue_visibility(self, content):
"""
Update the visibility of the continue button for the current user.
"""
await self.send_json({'type': 'continue_visibility', 'visible': content['visible']})
async def draw_send_poules(self, content):
"""
Send the pools and the teams to the current user to update the interface.
"""
await self.send_json({'type': 'set_poules', 'round': content['round'].number,
'poules': [{'letter': pool.get_letter_display(), 'teams': await pool.atrigrams()}
async for pool in content['round'].pool_set.order_by('letter').all()]})
async def draw_set_active(self, content):
"""
Update the user interface to highlight the current team.
"""
r = content['draw'].current_round
await self.send_json({
'type': 'set_active',
@ -717,14 +986,23 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
})
async def draw_set_problem(self, content):
"""
Send the accepted problem of a team to the current user.
"""
await self.send_json({'type': 'set_problem', 'round': content['round'],
'team': content['team'], 'problem': content['problem']})
async def draw_reject_problem(self, content):
"""
Send the rejected problems of a team to the current user.
"""
await self.send_json({'type': 'reject_problem', 'round': content['round'],
'team': content['team'], 'rejected': content['rejected']})
async def draw_reorder_pool(self, content):
"""
Send the new order of a pool to the current user.
"""
await self.send_json({'type': 'reorder_poule', 'round': content['round'],
'poule': content['pool'], 'teams': content['teams'],
'problems': content['problems']})

View File

@ -3,7 +3,9 @@
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 _
@ -12,10 +14,16 @@ from participation.models import Passage, Participation, Pool as PPool, Tourname
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(
@ -25,12 +33,14 @@ class Draw(models.Model):
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):
@ -40,7 +50,6 @@ class Draw(models.Model):
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())
@ -48,7 +57,6 @@ class Draw(models.Model):
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()])
@ -73,6 +81,8 @@ class Draw(models.Model):
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'
@ -83,13 +93,21 @@ class Draw(models.Model):
@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>
@ -102,6 +120,7 @@ class Draw(models.Model):
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
@ -110,25 +129,31 @@ class Draw(models.Model):
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}</strong>. """
<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 ""
@ -137,7 +162,10 @@ class Draw(models.Model):
<a class="alert-link" href="https://tfjm.org/reglement">https://tfjm.org/reglement</a>."""
return s
async def ainformation(self):
async def ainformation(self) -> str:
"""
Asynchronous version to get the information header content.
"""
return await sync_to_async(lambda: self.information)()
def __str__(self):
@ -149,6 +177,10 @@ class Draw(models.Model):
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,
@ -161,6 +193,8 @@ class Round(models.Model):
(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(
@ -170,13 +204,21 @@ class Round(models.Model):
default=None,
related_name='+',
verbose_name=_('current pool'),
help_text=_("The current pool where teams select their problems."),
)
@property
def team_draws(self):
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)
@ -190,6 +232,11 @@ class Round(models.Model):
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,
@ -203,10 +250,13 @@ class Pool(models.Model):
(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(
@ -216,6 +266,7 @@ class Pool(models.Model):
default=None,
related_name='+',
verbose_name=_('current team'),
help_text=_("The current team that is selecting its problem."),
)
associated_pool = models.OneToOneField(
@ -225,66 +276,98 @@ class Pool(models.Model):
default=None,
related_name='draw_pool',
verbose_name=_("associated pool"),
help_text=_("The full pool instance."),
)
@property
def team_draws(self):
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):
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):
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):
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):
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):
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):
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,
)
await self.associated_pool.juries.aset(self.round.draw.tournament.organizers.all())
# 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()
if len(tds) == 3:
# 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 len(tds) == 4:
elif self.size == 4:
table = [
[0, 1, 2],
[1, 2, 3],
[2, 3, 0],
[3, 0, 1],
]
elif len(tds) == 5:
elif self.size == 5:
table = [
[0, 2, 3],
[1, 3, 4],
@ -294,6 +377,7 @@ class Pool(models.Model):
]
for line in table:
# Create the passage
await Passage.objects.acreate(
pool=self.associated_pool,
solution_number=tds[line[0]].accepted,
@ -303,6 +387,8 @@ class Pool(models.Model):
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))
@ -313,6 +399,10 @@ class Pool(models.Model):
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,
@ -334,17 +424,21 @@ class TeamDraw(models.Model):
)
passage_index = models.PositiveSmallIntegerField(
choices=zip(range(1, 6), range(1, 6)),
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(1, 6), range(1, 6)),
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(
@ -386,14 +480,24 @@ class TeamDraw(models.Model):
@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):

View File

@ -1,49 +1,92 @@
(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()
@ -51,18 +94,26 @@ document.addEventListener('DOMContentLoaded', () => {
})
}
// 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
function addMessage(message, type, timeout = 0) {
/**
* 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">`,
@ -75,16 +126,27 @@ document.addEventListener('DOMContentLoaded', () => {
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)
@ -93,7 +155,7 @@ document.addEventListener('DOMContentLoaded', () => {
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
// 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} 🎲 ??`
@ -101,6 +163,9 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
/**
* 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')
@ -114,6 +179,12 @@ document.addEventListener('DOMContentLoaded', () => {
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) {
@ -128,6 +199,10 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
/**
* Display or hide the dice button.
* @param visible The visibility status
*/
function updateDiceVisibility(visible) {
let div = document.getElementById(`launch-dice-${tournament.id}`)
if (visible)
@ -136,6 +211,10 @@ document.addEventListener('DOMContentLoaded', () => {
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)
@ -144,6 +223,10 @@ document.addEventListener('DOMContentLoaded', () => {
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)
@ -152,6 +235,10 @@ document.addEventListener('DOMContentLoaded', () => {
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)
@ -160,6 +247,10 @@ document.addEventListener('DOMContentLoaded', () => {
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)
@ -168,11 +259,18 @@ document.addEventListener('DOMContentLoaded', () => {
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')
@ -195,6 +293,7 @@ document.addEventListener('DOMContentLoaded', () => {
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')
@ -212,6 +311,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
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}`)
@ -222,6 +322,7 @@ document.addEventListener('DOMContentLoaded', () => {
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')
@ -230,6 +331,7 @@ document.addEventListener('DOMContentLoaded', () => {
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) {
@ -240,6 +342,7 @@ document.addEventListener('DOMContentLoaded', () => {
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) {
@ -256,6 +359,7 @@ document.addEventListener('DOMContentLoaded', () => {
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)
@ -275,11 +379,18 @@ document.addEventListener('DOMContentLoaded', () => {
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}`)
@ -315,6 +426,7 @@ document.addEventListener('DOMContentLoaded', () => {
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')
@ -342,10 +454,12 @@ document.addEventListener('DOMContentLoaded', () => {
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)
@ -355,6 +469,7 @@ document.addEventListener('DOMContentLoaded', () => {
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
@ -372,10 +487,7 @@ document.addEventListener('DOMContentLoaded', () => {
reporterTd.classList.add('text-center')
reporterTd.innerText = 'Rap'
let emptyTd = document.createElement('td')
let emptyTd2 = document.createElement('td')
// 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:
@ -390,6 +502,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
else if (poule.teams.length === 4) {
let emptyTd = document.createElement('td')
switch (i) {
case 0:
teamTr.append(defenderTd, emptyTd, reporterTd, opponentTd)
@ -406,6 +519,8 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
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)
@ -428,7 +543,14 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
/**
* 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}"]`)
@ -436,35 +558,53 @@ document.addEventListener('DOMContentLoaded', () => {
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 >= 4) {
// TODO Fix this static value
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')
@ -472,16 +612,26 @@ document.addEventListener('DOMContentLoaded', () => {
penaltyDiv.classList.add('badge', 'rounded-pill', 'text-bg-info')
recapDiv.parentNode.append(penaltyDiv)
}
penaltyDiv.textContent = `${0.5 * (rejected.length - 3)}`
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]
@ -490,66 +640,85 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
// Listen on websockets and process messages from the server
socket.addEventListener('message', e => {
// Parse received data as JSON
const data = JSON.parse(e.data)
console.log(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',
@ -557,6 +726,7 @@ document.addEventListener('DOMContentLoaded', () => {
}))
})
// Manage the start form
let format_form = document.getElementById('format-form-' + tournament.id)
if (format_form !== null) {
format_form.addEventListener('submit', function (e) {

View File

@ -3,6 +3,7 @@
{% 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">
@ -17,6 +18,7 @@
</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"
@ -28,7 +30,10 @@
{% 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

@ -1,15 +1,18 @@
{% 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 }}"
@ -22,6 +25,7 @@
</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">
@ -29,11 +33,13 @@
</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:'??' }}
@ -49,6 +55,7 @@
<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>
@ -57,6 +64,7 @@
<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 }}">
@ -64,6 +72,7 @@
<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 }}">
@ -71,18 +80,22 @@
<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 }}
@ -104,12 +117,15 @@
<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 }})">
🎲
@ -123,6 +139,7 @@
<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 }})">
🗳️
@ -136,7 +153,8 @@
<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 %}>
<div class="d-grid">
{# 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" %}
@ -149,6 +167,7 @@
</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 }})">
@ -156,6 +175,7 @@
</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 }})">
@ -169,6 +189,7 @@
</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">
@ -178,6 +199,7 @@
</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">
@ -188,6 +210,7 @@
<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>
@ -216,6 +239,7 @@
</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>

View File

@ -3,12 +3,11 @@
from django.urls import path
from .views import DisplayContentView, DisplayView
from .views import DisplayView
app_name = "draw"
urlpatterns = [
path('', DisplayView.as_view(), name='index'),
path('content/<int:pk>/', DisplayContentView.as_view()),
]

View File

@ -1,6 +1,7 @@
# 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
@ -8,6 +9,10 @@ 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):
@ -15,18 +20,21 @@ class DisplayView(LoginRequiredMixin, TemplateView):
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
class DisplayContentView(LoginRequiredMixin, DetailView):
model = Tournament
template_name = 'draw/tournament_content.html'

View File

@ -45,6 +45,7 @@ INSTALLED_APPS = [
'daphne',
'django.contrib.admin',
'django.contrib.admindocs',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',