From ea8007aa07ece08d7dc0e5a80e9309fbd4e06556 Mon Sep 17 00:00:00 2001 From: Emmy D'Anello Date: Sat, 27 Apr 2024 08:57:01 +0200 Subject: [PATCH] Initialize chat interface Signed-off-by: Emmy D'Anello --- chat/consumers.py | 46 ++++++++++++++++ chat/static/chat.js | 52 +++++++++++++++++++ chat/templates/chat/chat.html | 12 +++++ chat/urls.py | 11 ++++ chat/views.py | 11 ++++ draw/routing.py | 10 ---- draw/tests.py | 4 +- .../templates/participation/chat.html | 13 ----- participation/urls.py | 2 - tfjm/asgi.py | 4 +- tfjm/routing.py | 11 ++++ tfjm/templates/navbar.html | 4 +- tfjm/urls.py | 2 +- 13 files changed, 150 insertions(+), 32 deletions(-) create mode 100644 chat/consumers.py create mode 100644 chat/static/chat.js create mode 100644 chat/templates/chat/chat.html delete mode 100644 draw/routing.py delete mode 100644 participation/templates/participation/chat.html create mode 100644 tfjm/routing.py diff --git a/chat/consumers.py b/chat/consumers.py new file mode 100644 index 0000000..7af3987 --- /dev/null +++ b/chat/consumers.py @@ -0,0 +1,46 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later + +from channels.generic.websocket import AsyncJsonWebsocketConsumer +from registration.models import Registration + + +class ChatConsumer(AsyncJsonWebsocketConsumer): + """ + This consumer manages the websocket of the chat interface. + """ + 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. + """ + + # Fetch the registration of the current user + user = self.scope['user'] + if user.is_anonymous: + # User is not authenticated + await self.close() + return + + reg = await Registration.objects.aget(user_id=user.id) + self.registration = reg + + # Accept the connection + await self.accept() + + async def disconnect(self, close_code) -> None: + """ + Called when the websocket got disconnected, for any reason. + :param close_code: The error code. + """ + if self.scope['user'].is_anonymous: + # User is not authenticated + return + + async def receive_json(self, content, **kwargs): + """ + 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. + """ + # TODO Process chat protocol diff --git a/chat/static/chat.js b/chat/static/chat.js new file mode 100644 index 0000000..5a45a83 --- /dev/null +++ b/chat/static/chat.js @@ -0,0 +1,52 @@ +(async () => { + // check notification permission + // This is useful to alert people that they should do something + await Notification.requestPermission() +})() + +/** + * 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', () => { + /** + * Process the received data from the server. + * @param data The received message + */ + function processMessage(data) { + // TODO Implement chat protocol + } + + function setupSocket(nextDelay = 1000) { + // Open a global websocket + socket = new WebSocket( + (document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/ws/chat/' + ) + + // Listen on websockets and process messages from the server + socket.addEventListener('message', e => { + // Parse received data as JSON + const data = JSON.parse(e.data) + + processMessage(data) + }) + + // Manage errors + socket.addEventListener('close', e => { + console.error('Chat socket closed unexpectedly, restarting…') + setTimeout(() => setupSocket(2 * nextDelay), nextDelay) + }) + } + + setupSocket() +}) diff --git a/chat/templates/chat/chat.html b/chat/templates/chat/chat.html new file mode 100644 index 0000000..cf7de49 --- /dev/null +++ b/chat/templates/chat/chat.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% load static %} +{% load i18n %} + +{% block content %} +{% endblock %} + +{% block extrajavascript %} + {# This script contains all data for the chat management #} + +{% endblock %} diff --git a/chat/urls.py b/chat/urls.py index 80ea069..77d52c2 100644 --- a/chat/urls.py +++ b/chat/urls.py @@ -1,2 +1,13 @@ # Copyright (C) 2024 by Animath # SPDX-License-Identifier: GPL-3.0-or-later + +from django.urls import path + +from .views import ChatView + + +app_name = 'chat' + +urlpatterns = [ + path('', ChatView.as_view(), name='chat'), +] diff --git a/chat/views.py b/chat/views.py index 80ea069..8c4ef96 100644 --- a/chat/views.py +++ b/chat/views.py @@ -1,2 +1,13 @@ # Copyright (C) 2024 by Animath # SPDX-License-Identifier: GPL-3.0-or-later + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic import TemplateView + + +class ChatView(LoginRequiredMixin, TemplateView): + """ + This view is the main interface of the chat system, which is working + with Javascript and websockets. + """ + template_name = "chat/chat.html" diff --git a/draw/routing.py b/draw/routing.py deleted file mode 100644 index 8ce6085..0000000 --- a/draw/routing.py +++ /dev/null @@ -1,10 +0,0 @@ -# Copyright (C) 2023 by Animath -# SPDX-License-Identifier: GPL-3.0-or-later - -from django.urls import path - -from . import consumers - -websocket_urlpatterns = [ - path("ws/draw/", consumers.DrawConsumer.as_asgi()), -] diff --git a/draw/tests.py b/draw/tests.py index 9f3ec6a..bbb209a 100644 --- a/draw/tests.py +++ b/draw/tests.py @@ -14,8 +14,8 @@ from django.contrib.sites.models import Site from django.test import TestCase from django.urls import reverse from participation.models import Team, Tournament +from tfjm import routing as websocket_routing -from . import routing from .models import Draw, Pool, Round, TeamDraw @@ -55,7 +55,7 @@ class TestDraw(TestCase): # Connect to Websocket headers = [(b'cookie', self.async_client.cookies.output(header='', sep='; ').encode())] - communicator = WebsocketCommunicator(AuthMiddlewareStack(URLRouter(routing.websocket_urlpatterns)), + communicator = WebsocketCommunicator(AuthMiddlewareStack(URLRouter(websocket_routing.websocket_urlpatterns)), "/ws/draw/", headers) connected, subprotocol = await communicator.connect() self.assertTrue(connected) diff --git a/participation/templates/participation/chat.html b/participation/templates/participation/chat.html deleted file mode 100644 index bf47704..0000000 --- a/participation/templates/participation/chat.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} - -{% load i18n %} - -{% block content %} -
- {% blocktrans trimmed %} - The chat feature is now out of usage. If you feel that having a chat - feature between participants is important, for example to build a - team, please contact us. - {% endblocktrans %} -
-{% endblock %} diff --git a/participation/urls.py b/participation/urls.py index 4125a4c..2c97587 100644 --- a/participation/urls.py +++ b/participation/urls.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: GPL-3.0-or-later from django.urls import path -from django.views.generic import TemplateView from .views import CreateTeamView, FinalNotationSheetTemplateView, GSheetNotificationsView, JoinTeamView, \ MyParticipationDetailView, MyTeamDetailView, NotationSheetsArchiveView, NoteUpdateView, ParticipationDetailView, \ @@ -74,5 +73,4 @@ urlpatterns = [ path("pools/passages//update/", PassageUpdateView.as_view(), name="passage_update"), path("pools/passages//solution/", SynthesisUploadView.as_view(), name="upload_synthesis"), path("pools/passages/notes//", NoteUpdateView.as_view(), name="update_notes"), - path("chat/", TemplateView.as_view(template_name="participation/chat.html"), name="chat") ] diff --git a/tfjm/asgi.py b/tfjm/asgi.py index 3bec7e0..417d54e 100644 --- a/tfjm/asgi.py +++ b/tfjm/asgi.py @@ -22,13 +22,13 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tfjm.settings') django_asgi_app = get_asgi_application() # useful since the import must be done after the application initialization -import draw.routing # noqa: E402, I202 +import tfjm.routing # noqa: E402, I202 application = ProtocolTypeRouter( { "http": django_asgi_app, "websocket": AllowedHostsOriginValidator( - AuthMiddlewareStack(URLRouter(draw.routing.websocket_urlpatterns)) + AuthMiddlewareStack(URLRouter(tfjm.routing.websocket_urlpatterns)) ), } ) diff --git a/tfjm/routing.py b/tfjm/routing.py new file mode 100644 index 0000000..08c54d8 --- /dev/null +++ b/tfjm/routing.py @@ -0,0 +1,11 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later + +import chat.consumers +from django.urls import path +import draw.consumers + +websocket_urlpatterns = [ + path("ws/chat/", chat.consumers.ChatConsumer.as_asgi()), + path("ws/draw/", draw.consumers.DrawConsumer.as_asgi()), +] diff --git a/tfjm/templates/navbar.html b/tfjm/templates/navbar.html index 8e0115d..ecdb4dc 100644 --- a/tfjm/templates/navbar.html +++ b/tfjm/templates/navbar.html @@ -62,8 +62,8 @@ {% endif %} {% endif %} - diff --git a/tfjm/urls.py b/tfjm/urls.py index a397f07..c506456 100644 --- a/tfjm/urls.py +++ b/tfjm/urls.py @@ -37,7 +37,7 @@ urlpatterns = [ path('search/', AdminSearchView.as_view(), name="haystack_search"), path('api/', include('api.urls')), - # path('chat/', include('chat.urls')), + path('chat/', include('chat.urls')), path('draw/', include('draw.urls')), path('participation/', include('participation.urls')), path('registration/', include('registration.urls')),