diff --git a/draw/consumers.py b/draw/consumers.py index c6427af..fc55ab7 100644 --- a/draw/consumers.py +++ b/draw/consumers.py @@ -1,4 +1,7 @@ -import json +# Copyright (C) 2023 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later + +from random import randint from asgiref.sync import sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer @@ -24,14 +27,16 @@ def ensure_orga(f): class DrawConsumer(AsyncJsonWebsocketConsumer): async def connect(self): tournament_id = self.scope['url_route']['kwargs']['tournament_id'] - self.tournament = await sync_to_async(Tournament.objects.get)(pk=tournament_id) + self.tournament = await Tournament.objects.filter(pk=tournament_id)\ + .prefetch_related('draw__current_round__current_pool__current_team').aget() - self.participations = await sync_to_async(lambda: list(Participation.objects\ - .filter(tournament=self.tournament, valid=True)\ - .prefetch_related('team').all()))() + self.participations = [] + async for participation in Participation.objects.filter(tournament=self.tournament, valid=True)\ + .prefetch_related('team'): + self.participations.append(participation) user = self.scope['user'] - reg = await sync_to_async(Registration.objects.get)(user=user) + reg = await Registration.objects.aget(user=user) self.registration = reg if reg.is_volunteer and not reg.is_admin and self.tournament not in reg.interesting_tournaments \ or not reg.is_volunteer and reg.team.participation.tournament != self.tournament: @@ -41,11 +46,19 @@ class DrawConsumer(AsyncJsonWebsocketConsumer): await self.accept() 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): 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) + else: + await self.channel_layer.group_discard(f"volunteer-{self.tournament.id}", self.channel_name) - async def alert(self, message: str, alert_type: str = 'info'): + async def alert(self, message: str, alert_type: str = 'info', **kwargs): return await self.send_json({'type': 'alert', 'alert_type': alert_type, 'message': str(message)}) async def receive_json(self, content, **kwargs): @@ -54,6 +67,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer): match content['type']: case 'start_draw': await self.start_draw(**content) + case 'dice': + await self.process_dice(**content) @ensure_orga async def start_draw(self, fmt, **kwargs): @@ -70,21 +85,204 @@ class DrawConsumer(AsyncJsonWebsocketConsumer): _("The sum must be equal to the number of teams: expected {len}, got {sum}")\ .format(len=len(self.participations), sum=sum(fmt)), 'danger') - draw = await sync_to_async(Draw.objects.create)(tournament=self.tournament) + draw = await Draw.objects.acreate(tournament=self.tournament) + r1 = None for i in [1, 2]: - r = await sync_to_async(Round.objects.create)(draw=draw, number=i) + r = await Round.objects.acreate(draw=draw, number=i) + if i == 1: + r1 = r + for j, f in enumerate(fmt): - await sync_to_async(Pool.objects.create)(round=r, letter=j + 1, size=f) + await Pool.objects.acreate(round=r, letter=j + 1, size=f) for participation in self.participations: - await sync_to_async(TeamDraw.objects.create)(participation=participation) + await TeamDraw.objects.acreate(participation=participation, round=r) + + draw.current_round = r1 + await sync_to_async(draw.save)() await self.alert(_("Draw started!"), 'success') 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}", + {'type': 'draw.set_info', 'draw': draw}) async def draw_start(self, content): 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]}) + + + async def process_dice(self, trigram: str | None = None, **kwargs): + if self.registration.is_volunteer: + participation = await Participation.objects.filter(team__trigram=trigram).prefetch_related('team').aget() + else: + participation = await Participation.objects.filter(team__participants=self.registration)\ + .prefetch_related('team').aget() + trigram = participation.team.trigram + + team_draw = await TeamDraw.objects.filter(participation=participation, + round_id=self.tournament.draw.current_round_id).aget() + + state = await sync_to_async(self.tournament.draw.get_state)() + match state: + case 'DICE_SELECT_POULES': + if team_draw.last_dice is not None: + return await self.alert(_("You've already launched the dice."), 'danger') + case 'DICE_ORDER_POULE': + if team_draw.last_dice is not None: + return await self.alert(_("You've already launched the dice."), 'danger') + if not await self.tournament.draw.current_round.current_pool.teamdraw_set\ + .filter(participation=participation).aexists(): + return await self.alert(_("It is not your turn."), 'danger') + case _: + return await self.alert(_("This is not the time for this."), 'danger') + + res = randint(1, 100) + team_draw.last_dice = res + await sync_to_async(team_draw.save)() + + 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, + last_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.last_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.last_dice == v] + + for dup in dups: + dup.last_dice = None + await sync_to_async(dup.save)() + 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: + return + + tds.sort(key=lambda td: td.last_dice) + tds_copy = tds.copy() + + async for p in Pool.objects.filter(round_id=self.tournament.draw.current_round_id).order_by('letter').all(): + while (c := await TeamDraw.objects.filter(pool=p).acount()) < p.size: + td = tds_copy.pop(0) + td.pool = p + td.passage_index = c + await sync_to_async(td.save)() + + if self.tournament.draw.current_round.number == 2 \ + and await self.tournament.draw.current_round.pool_set.acount() >= 2: + # Check that we don't have a same pool as the first day + async for p1 in Pool.objects.filter(round__draw=self.tournament.draw, number=1).all(): + async for p2 in Pool.objects.filter(round_id=self.tournament.draw.current_round_id).all(): + if set(await p1.teamdraw_set.avalues('id')) \ + == set(await p2.teamdraw_set.avalues('id')): + await TeamDraw.objects.filter(round=self.tournament.draw.current_round)\ + .aupdate(last_dice=None, pool=None, passage_index=None) + 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.alert', + 'message': _('Two pools are identical. Please relaunch your dices.'), + 'alert_type': 'warning'}) + return + + pool = await Pool.objects.filter(round=self.tournament.draw.current_round, letter=1).aget() + self.tournament.draw.current_round.current_pool = pool + await sync_to_async(self.tournament.draw.current_round.save)() + + await TeamDraw.objects.filter(round=self.tournament.draw.current_round).aupdate(last_dice=None) + 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"tournament-{self.tournament.id}", + {'type': 'draw.set_info', 'draw': self.tournament.draw}) + elif state == 'DICE_ORDER_POULE' and \ + not await TeamDraw.objects.filter(pool=self.tournament.draw.current_round.current_pool, + last_dice__isnull=True).aexists(): + pool = self.tournament.draw.current_round.current_pool + + tds = [] + async for td in TeamDraw.objects.filter(pool=pool)\ + .prefetch_related('participation__team'): + tds.append(td) + + dices = {td: td.last_dice for td in tds} + values = list(dices) + error = False + for v in set(values): + if values.count(v) > 1: + dups = [td for td in tds if td.last_dice == v] + + for dup in dups: + dup.last_dice = None + await sync_to_async(dup.save)() + 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: + return + + tds.sort(key=lambda x: -x.last_dice) + for i, td in enumerate(tds): + td.choose_index = i + await sync_to_async(td.save)() + + pool.current_team = tds[0] + await sync_to_async(pool.save)() + + await self.channel_layer.group_send(f"tournament-{self.tournament.id}", + {'type': 'draw.set_info', 'draw': self.tournament.draw}) + + async def draw_alert(self, content): + return await self.alert(**content) + + async def draw_notify(self, content): + await self.send_json({'type': 'notification', 'title': content['title'], 'body': content['body']}) + + async def draw_set_info(self, content): + await self.send_json({'type': 'set_info', 'information': await content['draw'].ainformation()}) + + async def draw_dice(self, content): + await self.send_json({'type': 'dice', 'team': content['team'], 'result': content['result']}) + + async def draw_dice_visibility(self, content): + await self.send_json({'type': 'dice_visibility', 'visible': content['visible']}) diff --git a/draw/migrations/0003_teamdraw_round.py b/draw/migrations/0003_teamdraw_round.py new file mode 100644 index 0000000..a34aed1 --- /dev/null +++ b/draw/migrations/0003_teamdraw_round.py @@ -0,0 +1,24 @@ +# Generated by Django 4.1.7 on 2023-03-22 21:39 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("draw", "0002_pool_size_alter_pool_letter_alter_round_number_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="teamdraw", + name="round", + field=models.ForeignKey( + default=1, + on_delete=django.db.models.deletion.CASCADE, + to="draw.round", + verbose_name="round", + ), + preserve_default=False, + ), + ] diff --git a/draw/migrations/0004_remove_teamdraw_index_teamdraw_choose_index_and_more.py b/draw/migrations/0004_remove_teamdraw_index_teamdraw_choose_index_and_more.py new file mode 100644 index 0000000..af904c3 --- /dev/null +++ b/draw/migrations/0004_remove_teamdraw_index_teamdraw_choose_index_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 4.1.7 on 2023-03-22 23:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("draw", "0003_teamdraw_round"), + ] + + operations = [ + migrations.RemoveField( + model_name="teamdraw", + name="index", + ), + migrations.AddField( + model_name="teamdraw", + name="choose_index", + field=models.PositiveSmallIntegerField( + choices=[(1, 1), (2, 2), (3, 3), (4, 4)], + default=None, + null=True, + verbose_name="choose index", + ), + ), + migrations.AddField( + model_name="teamdraw", + name="passage_index", + field=models.PositiveSmallIntegerField( + choices=[(1, 1), (2, 2), (3, 3), (4, 4)], + default=None, + null=True, + verbose_name="passage index", + ), + ), + ] diff --git a/draw/models.py b/draw/models.py index dcf66b2..0fcea76 100644 --- a/draw/models.py +++ b/draw/models.py @@ -1,5 +1,6 @@ # Copyright (C) 2023 by Animath # SPDX-License-Identifier: GPL-3.0-or-later +from asgiref.sync import sync_to_async from django.conf import settings from django.db import models from django.utils.text import format_lazy @@ -24,6 +25,52 @@ class Draw(models.Model): verbose_name=_('current round'), ) + def get_state(self): + if self.current_round.current_pool is None: + return 'DICE_SELECT_POULES' + elif self.current_round.current_pool.current_team is None: + return 'DICE_ORDER_POULE' + elif self.current_round.current_pool.current_team.purposed is None: + return 'WAITING_DRAW_PROBLEM' + elif self.current_round.current_pool.current_team.accepted is None: + return 'WAITING_CHOOSE_PROBLEM' + else: + return 'DRAW_ENDED' + + @property + def information(self): + s = "" + match self.get_state(): + case 'DICE_SELECT_POULES': + if self.current_round.number == 1: + 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.

+ Nous allons d'abord tirer les poules et l'ordre de passage + pour le premier tour avec toutes les équipes puis pour chaque poule, + nous tirerons l'ordre de tirage pour le tour et les problèmes.

""" + s += """ + Les capitaines, vous pouvez désormais toustes lancer un dé 100, + en cliquant sur le gros bouton. Les poules et l'ordre de passage + lors du premier tour sera l'ordre croissant des dés, c'est-à-dire + que le plus petit lancer sera le premier à passer dans la poule A.""" + case 'DICE_ORDER_POULE': + 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 + for td in self.current_round.current_pool.teamdraw_set.all())}. + Les capitaines peuvent lancer un dé 100 en cliquant sur le gros bouton + pour déterminer l'ordre de tirage. L'équipe réalisant le plus gros score pourra + tirer en premier.""" + + s += """

Pour plus de détails sur le déroulement du tirage au sort, + le règlement est accessible sur + https://tfjm.org/reglement.""" + return s + + async def ainformation(self): + return await sync_to_async(lambda: self.information)() + class Meta: verbose_name = _('draw') verbose_name_plural = _('draws') @@ -89,8 +136,12 @@ class Pool(models.Model): verbose_name=_('current team'), ) + @property + def trigrams(self): + return set(td.participation.team.trigram for td in self.teamdraw_set.all()) + def __str__(self): - return f"{self.letter}{self.round}" + return f"{self.get_letter_display()}{self.round.number}" class Meta: verbose_name = _('pool') @@ -104,6 +155,12 @@ class TeamDraw(models.Model): verbose_name=_('participation'), ) + round = models.ForeignKey( + Round, + on_delete=models.CASCADE, + verbose_name=_('round'), + ) + pool = models.ForeignKey( Pool, on_delete=models.CASCADE, @@ -112,11 +169,18 @@ class TeamDraw(models.Model): verbose_name=_('pool'), ) - index = models.PositiveSmallIntegerField( - choices=zip(range(1, 6), range(1, 6)), + passage_index = models.PositiveSmallIntegerField( + choices=zip(range(1, 5), range(1, 5)), null=True, default=None, - verbose_name=_('index'), + verbose_name=_('passage index'), + ) + + choose_index = models.PositiveSmallIntegerField( + choices=zip(range(1, 5), range(1, 5)), + null=True, + default=None, + verbose_name=_('choose index'), ) accepted = models.PositiveSmallIntegerField( @@ -149,6 +213,9 @@ class TeamDraw(models.Model): verbose_name=_('rejected problems'), ) + def current(self): + return TeamDraw.objects.get(participation=self.participation, round=self.round.draw.current_round) + class Meta: verbose_name = _('team draw') verbose_name_plural = _('team draws') diff --git a/draw/static/draw.js b/draw/static/draw.js index 2d097f2..2fd9a57 100644 --- a/draw/static/draw.js +++ b/draw/static/draw.js @@ -1,8 +1,23 @@ +(async () => { + // check notification permission + await Notification.requestPermission() +})() + const tournaments = JSON.parse(document.getElementById('tournaments_list').textContent) const sockets = {} const messages = document.getElementById('messages') +function drawDice(tid, trigram = null) { + sockets[tid].send(JSON.stringify({'type': 'dice', 'trigram': trigram})) +} + +function showNotification(title, body, timeout = 5000) { + let notif = new Notification(title, {'body': body, 'icon': "/static/tfjm.svg"}) + if (timeout) + setTimeout(() => notif.close(), timeout) +} + document.addEventListener('DOMContentLoaded', () => { if (document.location.hash) { document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(elem => { @@ -18,7 +33,8 @@ document.addEventListener('DOMContentLoaded', () => { for (let tournament of tournaments) { let socket = new WebSocket( - 'ws://' + window.location.host + '/ws/draw/' + tournament.id + '/' + (document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + + '/ws/draw/' + tournament.id + '/' ) sockets[tournament.id] = socket @@ -35,11 +51,37 @@ document.addEventListener('DOMContentLoaded', () => { setTimeout(() => wrapper.remove(), timeout) } - function draw_start(data) { + function setInfo(info) { + document.getElementById(`messages-${tournament.id}`).innerHTML = info + } + + function drawStart() { document.getElementById(`banner-not-started-${tournament.id}`).classList.add('d-none') document.getElementById(`draw-content-${tournament.id}`).classList.remove('d-none') } + function updateDiceInfo(trigram, result) { + let elem = document.getElementById(`dice-${tournament.id}-${trigram}`) + if (result === null) { + elem.classList.remove('text-bg-success') + elem.classList.add('text-bg-warning') + elem.innerText = `${trigram} 🎲 ??` + } + else { + elem.classList.remove('text-bg-warning') + elem.classList.add('text-bg-success') + elem.innerText = `${trigram} 🎲 ${result}` + } + } + + function updateDiceVisibility(visible) { + let div = document.getElementById(`launch-dice-${tournament.id}`) + if (visible) + div.classList.remove('d-none') + else + div.classList.add('d-none') + } + socket.addEventListener('message', e => { const data = JSON.parse(e.data) console.log(data) @@ -48,8 +90,20 @@ document.addEventListener('DOMContentLoaded', () => { case 'alert': addMessage(data.message, data.alert_type) break + case 'notification': + showNotification(data.title, data.body) + case 'set_info': + setInfo(data.information) + break case 'draw_start': - draw_start(data) + drawStart() + break + case 'dice': + updateDiceInfo(data.team, data.result) + break + case 'dice_visibility': + updateDiceVisibility(data.visible) + break } }) @@ -59,14 +113,16 @@ document.addEventListener('DOMContentLoaded', () => { socket.addEventListener('open', e => {}) - document.getElementById('format-form-' + tournament.id) - .addEventListener('submit', function (e) { - e.preventDefault() + let format_form = document.getElementById('format-form-' + tournament.id) + if (format_form !== null) { + format_form.addEventListener('submit', function (e) { + e.preventDefault() - socket.send(JSON.stringify({ - 'type': 'start_draw', - 'fmt': document.getElementById('format-' + tournament.id).value - })) - }) + socket.send(JSON.stringify({ + 'type': 'start_draw', + 'fmt': document.getElementById('format-' + tournament.id).value + })) + }) + } } }) diff --git a/draw/templates/draw/tournament_content.html b/draw/templates/draw/tournament_content.html index e14c400..0f7e6bc 100644 --- a/draw/templates/draw/tournament_content.html +++ b/draw/templates/draw/tournament_content.html @@ -31,7 +31,13 @@
{% for participation in tournament.participations.all %}
-
{{ participation.team.trigram }} 🎲 ??
+
+ {{ participation.team.trigram }} 🎲 {{ participation.teamdraw_set.all.first.current.last_dice|default:'??' }} +
{% endfor %}
@@ -149,12 +155,13 @@
- Information + {{ tournament.draw.information|safe }}
-
+
-