diff --git a/.gitignore b/.gitignore index e7caab5..a6028ad 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,4 @@ target/ # sqlite database *.sqlite3 +fear_tracker/migrations diff --git a/fear_tracker/routing.py b/fear_tracker/routing.py new file mode 100644 index 0000000..14a9695 --- /dev/null +++ b/fear_tracker/routing.py @@ -0,0 +1,16 @@ +from channels.http import AsgiHandler +from channels.routing import ProtocolTypeRouter, URLRouter + +from django.conf.urls import url + +from . import views + +application = ProtocolTypeRouter({ + "http": URLRouter([ + url(r'^(?P[a-zA-Z]{6})/', URLRouter([ + url(r"status/(?P[a-z0-9]{64})/", + views.StatusLongPollConsumer, name='status'), + ])), + url(r"", AsgiHandler), + ]), +}) diff --git a/fear_tracker/templates/game.html b/fear_tracker/templates/game.html index 9e35637..a05388f 100644 --- a/fear_tracker/templates/game.html +++ b/fear_tracker/templates/game.html @@ -360,13 +360,23 @@ el.addEventListener("change", formElementChanged); } - setInterval(function() { + function checkStatus() { if(activeRequests.size != 0) return; + // From https://stackoverflow.com/a/50101022 + + const abort = new AbortController(); + const signal = abort.signal; + + // 50 second timeout: + const timeoutId = setTimeout(() => abort.abort(), 50000); + fetch(new Request("{% url 'status' access_code=access_code %}" + (statusObj.hash != "" ? statusObj.hash + "/" - : ""))) + : "")), + {signal}) .then(response => { + clearTimeout(timeoutId); if(response.status === 304) { // TODO Just skip the next step? return statusObj; @@ -381,8 +391,14 @@ } else { statusObj = data; } + }) + .then(checkStatus) + .catch(() => { + // If something went wrong, wait a few seconds before retrying. + setTimeout(checkStatus, 5000); }); - }, 5000); + } + checkStatus(); }); {% endif %} diff --git a/fear_tracker/urls.py b/fear_tracker/urls.py index 725caf1..d88d903 100644 --- a/fear_tracker/urls.py +++ b/fear_tracker/urls.py @@ -27,7 +27,5 @@ urlpatterns = [ path('update/', views.update_game, name='update_game'), path('qr/', views.qr_code, name='qr_code'), path('status/', views.status, name='status'), - url('^status/(?P[a-z0-9]{64})/', - views.status, name='status'), - ])), + ])), ] diff --git a/fear_tracker/views.py b/fear_tracker/views.py index 7a7b468..3af103b 100644 --- a/fear_tracker/views.py +++ b/fear_tracker/views.py @@ -5,6 +5,11 @@ import hashlib import json import qrcode +from asgiref.sync import async_to_sync + +from channels.generic.http import AsyncHttpConsumer +from channels.layers import get_channel_layer + from django.db import transaction from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render @@ -288,12 +293,22 @@ def handle_game_request(request, game, update): res['value'] = current_value else: res = {'success': True} + async_to_sync(get_channel_layer().group_send)( + "%s_status" % game.access_code, + {"type": "fear_tracker.invalidate_status"}) return HttpResponse(json.dumps(res)) players = get_players_with_fear(game, current_phase, players) status_obj = game_status_object(game, current_phase, players) status_string = json.dumps(status_obj) + async_to_sync(get_channel_layer().group_send)( + "%s_status" % game.access_code, { + "type": "fear_tracker.hashcode_seen", + "hashcode": status_obj['hash'], + "status_string": status_string, + }) + for player in players.values(): info = player.fear if not info: @@ -324,4 +339,57 @@ def status(request, game, hashcode=None): return HttpResponse(status=HTTPStatus.NOT_MODIFIED) else: status_string = json.dumps(status_obj) + async_to_sync(get_channel_layer().group_send)( + "%s_status" % game.access_code, { + "type": "fear_tracker.hashcode_seen", + "hashcode": status_obj['hash'], + "status_string": status_string, + }) return HttpResponse(status_string) + + +class StatusLongPollConsumer(AsyncHttpConsumer): + async def handle(self, body): + self.access_code = self.scope["url_route"]["kwargs"]["access_code"] + self.hashcode = self.scope["url_route"]["kwargs"]["hashcode"] + + await self.channel_layer.group_add("%s_status" % self.access_code, + self.channel_name) + await self.channel_layer.group_send( + "%s_status" % self.access_code, { + "type": "fear_tracker.hashcode_seen", + "hashcode": self.hashcode, + }) + + async def http_request(self, message): + """ + Async entrypoint - concatenates body fragments and hands off control + to ``self.handle`` when the body has been completely received. + """ + if "body" in message: + self.body.append(message["body"]) + if not message.get("more_body"): + await self.handle(b"".join(self.body)) + + async def disconnect(self): + await self.channel_layer.group_discard("%s_status" % self.access_code, + self.channel_name) + + async def fear_tracker_hashcode_seen(self, event): + if self.hashcode != event["hashcode"]: + if event["status_string"]: + body = event["status_string"].encode('utf-8') + await self.send_response(200, body) + await self.disconnect() + await self.channel_layer.group_send( + "%s_status" % self.access_code, { + "type": "fear_tracker.invalidate_status", + }) + + async def fear_tracker_invalidate_status(self, event): + no_hash_status = reverse('status', + kwargs={'access_code': self.access_code}) + await self.send_response(302, b'', headers=[ + (b"Location", no_hash_status.encode('utf-8')) + ]) + await self.http_disconnect(None) diff --git a/fear_tracker_site/asgi.py b/fear_tracker_site/asgi.py new file mode 100644 index 0000000..f780290 --- /dev/null +++ b/fear_tracker_site/asgi.py @@ -0,0 +1,12 @@ +""" +ASGI entrypoint. Configures Django and then runs the application +defined in the ASGI_APPLICATION setting. +""" + +import os +import django +from channels.routing import get_default_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "fear_tracker_site.settings") +django.setup() +application = get_default_application() diff --git a/fear_tracker_site/routing.py b/fear_tracker_site/routing.py new file mode 100644 index 0000000..8528954 --- /dev/null +++ b/fear_tracker_site/routing.py @@ -0,0 +1,3 @@ +import fear_tracker.routing + +application = fear_tracker.routing.application diff --git a/fear_tracker_site/settings.py b/fear_tracker_site/settings.py index bdddc4c..1aa6d14 100644 --- a/fear_tracker_site/settings.py +++ b/fear_tracker_site/settings.py @@ -31,6 +31,7 @@ ALLOWED_HOSTS = [] # Application definition INSTALLED_APPS = [ + 'channels', 'fear_tracker', 'django.contrib.admin', 'django.contrib.auth', @@ -69,6 +70,7 @@ TEMPLATES = [ ] WSGI_APPLICATION = 'fear_tracker_site.wsgi.application' +ASGI_APPLICATION = 'fear_tracker_site.routing.application' # Database @@ -81,6 +83,12 @@ DATABASES = { } } +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels.layers.InMemoryChannelLayer" + } +} + # Password validation # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators