diff --git a/draw/consumers.py b/draw/consumers.py index 404e7f3..65739cc 100644 --- a/draw/consumers.py +++ b/draw/consumers.py @@ -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"{td.participation.team.trigram} ({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"{td.participation.team.trigram} ({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 {trigram} a refusé le problème {problem} : " \ f"{settings.PROBLEMS[problem - 1]}. " @@ -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']}) diff --git a/draw/models.py b/draw/models.py index 9361ca4..3fb496c 100644 --- a/draw/models.py +++ b/draw/models.py @@ -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 + "

" 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.
Vous pouvez à tout moment poser toute question si quelque chose n'est pas clair ou ne va pas.

@@ -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 {self.current_round.current_pool}, entre les équipes {', '.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 {td.participation.team.trigram} 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 {td.participation.team.trigram} a tiré le problème - {td.purposed}. """ + {td.purposed} : {settings.PROBLEMS[td.purposed - 1]}. """ 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 += "

" if s else "" @@ -137,7 +162,10 @@ class Draw(models.Model): https://tfjm.org/reglement.""" 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): diff --git a/draw/static/draw.js b/draw/static/draw.js index a6c0c2f..3fe612f 100644 --- a/draw/static/draw.js +++ b/draw/static/draw.js @@ -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 = [ `