diff --git a/draw/consumers.py b/draw/consumers.py new file mode 100644 index 0000000..0f6f6e8 --- /dev/null +++ b/draw/consumers.py @@ -0,0 +1,45 @@ +import json + +from asgiref.sync import sync_to_async +from channels.generic.websocket import AsyncJsonWebsocketConsumer + +from participation.models import Tournament + + +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) + + user = self.scope['user'] + reg = user.registration + 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: + # This user may not have access to the drawing session + await self.close() + return + + await self.accept() + await self.channel_layer.group_add(f"tournament-{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) + + async def receive_json(self, content, **kwargs): + message = content["message"] + + print(self.scope) + + # TODO: Implement drawing system, instead of making a simple chatbot + await self.channel_layer.group_send( + f"tournament-{self.tournament.id}", + { + "type": "draw.message", + "username": self.scope["user"].username, + "message": message, + } + ) + + async def draw_message(self, event): + print(event) + await self.send_json({"message": event['message']}) diff --git a/draw/routing.py b/draw/routing.py new file mode 100644 index 0000000..c060dfa --- /dev/null +++ b/draw/routing.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import consumers + +websocket_urlpatterns = [ + path("ws/draw//", consumers.DrawConsumer.as_asgi()), +] diff --git a/draw/static/draw.js b/draw/static/draw.js new file mode 100644 index 0000000..e52c715 --- /dev/null +++ b/draw/static/draw.js @@ -0,0 +1,37 @@ +const tournaments = JSON.parse(document.getElementById('tournaments_list').textContent) +const sockets = {} + +for (let tournament of tournaments) { + let socket = new WebSocket( + 'ws://' + window.location.host + '/ws/draw/' + tournament.id + '/' + ) + sockets[tournament.id] = socket + + // TODO: For now, we only have a chatbot. Need to implementthe drawing interface + socket.onmessage = function(e) { + console.log(e.data) + const data = JSON.parse(e.data) + console.log(data) + document.querySelector('#chat-log-' + tournament.id).value += (data.message + '\n') + } + + socket.onclose = function(e) { + console.error('Chat socket closed unexpectedly') + } + + document.querySelector('#chat-message-' + tournament.id + '-input').focus(); + document.querySelector('#chat-message-' + tournament.id + '-input').onkeyup = function(e) { + if (e.keyCode === 13) { // enter, return + document.querySelector('#chat-message-' + tournament.id + '-submit').click(); + } + }; + + document.querySelector('#chat-message-' + tournament.id + '-submit').onclick = function(e) { + const messageInputDom = document.querySelector('#chat-message-' + tournament.id + '-input'); + const message = messageInputDom.value; + socket.send(JSON.stringify({ + 'message': message + })); + messageInputDom.value = ''; + }; +} diff --git a/draw/templates/draw/index.html b/draw/templates/draw/index.html index db1b70a..98f0dcf 100644 --- a/draw/templates/draw/index.html +++ b/draw/templates/draw/index.html @@ -1,5 +1,34 @@ {% extends "base.html" %} +{% load static %} + {% block content %} -Hello world! + + +
+ {% for tournament in tournaments %} +
+ {% include "draw/tournament_content.html" with tournament=tournament %} +
+ {% endfor %} +
+{% endblock %} + +{% block extrajavascript %} + {{ tournaments|json_script:'tournaments_list' }} + + {% endblock %} diff --git a/draw/templates/draw/tournament_content.html b/draw/templates/draw/tournament_content.html new file mode 100644 index 0000000..bd585da --- /dev/null +++ b/draw/templates/draw/tournament_content.html @@ -0,0 +1,6 @@ +{{ tournament.name }} + + + + + diff --git a/draw/views.py b/draw/views.py index 175e1b1..908b076 100644 --- a/draw/views.py +++ b/draw/views.py @@ -1,8 +1,27 @@ # Copyright (C) 2023 by Animath # SPDX-License-Identifier: GPL-3.0-or-later +from django.contrib.auth.mixins import LoginRequiredMixin +from django.db.models import Q from django.views.generic import TemplateView +from participation.models import Tournament -class DisplayView(TemplateView): + +class DisplayView(LoginRequiredMixin, TemplateView): template_name = 'draw/index.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + reg = self.request.user.registration + if reg.is_admin: + tournaments = Tournament.objects.all() + elif reg.is_volunteer: + tournaments = reg.interesting_tournaments + else: + tournaments = [reg.team.participation.tournament] + context['tournaments'] = [{'id': t.id, 'name': t.name} for t in tournaments] + + + return context diff --git a/requirements.txt b/requirements.txt index 7bdc0ce..b312b12 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +channels[daphne]~=4.0.0 crispy-bootstrap5~=0.7 Django>=4.1,<5.0 django-cas-server~=2.0 diff --git a/tfjm/asgi.py b/tfjm/asgi.py index 68b875c..938e79f 100644 --- a/tfjm/asgi.py +++ b/tfjm/asgi.py @@ -12,8 +12,20 @@ https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ import os +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.security.websocket import AllowedHostsOriginValidator from django.core.asgi import get_asgi_application +import draw.routing + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tfjm.settings') -application = get_asgi_application() +application = ProtocolTypeRouter( + { + "http": get_asgi_application(), + "websocket": AllowedHostsOriginValidator( + AuthMiddlewareStack(URLRouter(draw.routing.websocket_urlpatterns)) + ), + } +) diff --git a/tfjm/settings.py b/tfjm/settings.py index 01eb609..4b7489c 100644 --- a/tfjm/settings.py +++ b/tfjm/settings.py @@ -42,6 +42,8 @@ ALLOWED_HOSTS = ['*'] # Application definition INSTALLED_APPS = [ + 'daphne', + 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -51,6 +53,7 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'django.forms', + 'channels', 'crispy_forms', 'crispy_bootstrap5', 'django_filters', @@ -111,6 +114,7 @@ TEMPLATES = [ FORM_RENDERER = 'django.forms.renderers.TemplatesSetting' +ASGI_APPLICATION = 'tfjm.asgi.application' WSGI_APPLICATION = 'tfjm.wsgi.application' @@ -249,6 +253,13 @@ FORBIDDEN_TRIGRAMS = [ "SEX", ] +# TODO: Use a redis server in production +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels.layers.InMemoryChannelLayer" + } +} + if os.getenv("TFJM_STAGE", "dev") == "prod": # pragma: no cover from .settings_prod import * # noqa: F401,F403 else: diff --git a/tox.ini b/tox.ini index cf30d7f..28699c9 100644 --- a/tox.ini +++ b/tox.ini @@ -11,6 +11,7 @@ skipsdist = True sitepackages = False deps = coverage + channels[daphne]~=4.0.0 crispy-bootstrap5~=0.7 Django>=4.1,<5.0 django-crispy-forms~=1.14