diff --git a/.gitignore b/.gitignore index 1fb9832..3352d05 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ tailwindcss.exe t-style.css dump.rdb media -node_modules \ No newline at end of file +node_modules +staticfiles \ No newline at end of file diff --git a/django/core/asgi.py b/django/core/asgi.py index e718260..057f91a 100644 --- a/django/core/asgi.py +++ b/django/core/asgi.py @@ -8,9 +8,21 @@ https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ """ import os - from django.core.asgi import get_asgi_application +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.auth import AuthMiddlewareStack +import django os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') +django.setup() -application = get_asgi_application() +from play.routing import websocket_urlpatterns + +application = ProtocolTypeRouter({ + 'http': get_asgi_application(), + 'websocket': AuthMiddlewareStack( + URLRouter( + websocket_urlpatterns + ) + ), +}) diff --git a/django/core/settings.py b/django/core/settings.py index a79c5ec..65b017c 100644 --- a/django/core/settings.py +++ b/django/core/settings.py @@ -42,10 +42,12 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'channels', ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -73,6 +75,13 @@ TEMPLATES = [ ] WSGI_APPLICATION = 'core.wsgi.application' +ASGI_APPLICATION = 'core.asgi.application' + +CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels.layers.InMemoryChannelLayer' + } +} # Database @@ -85,6 +94,12 @@ DATABASES = { } } +STORAGES = { + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} + # Password validation # https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators @@ -121,6 +136,7 @@ USE_TZ = True # https://docs.djangoproject.com/en/5.1/howto/static-files/ STATIC_URL = '/static/' +STATIC_ROOT = BASE_DIR / "staticfiles" STATICFILES_DIRS = [BASE_DIR / 'static'] # Default primary key field type diff --git a/django/play/consumers.py b/django/play/consumers.py new file mode 100644 index 0000000..bf44a27 --- /dev/null +++ b/django/play/consumers.py @@ -0,0 +1,117 @@ +import json +import asyncio +from channels.generic.websocket import AsyncWebsocketConsumer +from channels.db import database_sync_to_async +from django.utils import timezone +from datetime import timedelta +from .models import QuizGame, QuizGameParticipant + +class LobbyConsumer(AsyncWebsocketConsumer): + async def connect(self): + self.join_code = self.scope['url_route']['kwargs']['join_code'] + self.room_group_name = f'game_{self.join_code}' + self.heartbeat_task = None + + # Join room group + await self.channel_layer.group_add( + self.room_group_name, + self.channel_name + ) + await self.accept() + + # Start heartbeat check + self.heartbeat_task = asyncio.create_task(self.check_participants_heartbeat()) + + # Send current participants list + participants = await self.get_participants() + await self.send(text_data=json.dumps({ + 'type': 'participant_list', + 'participants': participants + })) + + async def disconnect(self, close_code): + # Cancel heartbeat task + if self.heartbeat_task: + self.heartbeat_task.cancel() + try: + await self.heartbeat_task + except asyncio.CancelledError: + pass + + # Leave room group + await self.channel_layer.group_discard( + self.room_group_name, + self.channel_name + ) + + async def receive(self, text_data): + text_data_json = json.loads(text_data) + message_type = text_data_json['type'] + + if message_type == 'heartbeat': + # Update participant's last activity timestamp + participant_id = text_data_json.get('participant_id') + if participant_id: + await self._update_participant_heartbeat(participant_id) + + elif message_type == 'update_participants': + participants = await self.get_participants() + await self.channel_layer.group_send( + self.room_group_name, + { + 'type': 'participant_list_update', + 'participants': participants + } + ) + + async def participant_list_update(self, event): + participants = event['participants'] + await self.send(text_data=json.dumps({ + 'type': 'participant_list', + 'participants': participants + })) + + @database_sync_to_async + def get_participants(self): + game = QuizGame.objects.get(join_code=self.join_code) + participants = QuizGameParticipant.objects.filter(quiz_game=game) + return [{'id': str(p.participant_id), 'name': p.display_name} for p in participants] + + @database_sync_to_async + def _update_participant_heartbeat(self, participant_id): + try: + participant = QuizGameParticipant.objects.get(participant_id=participant_id) + participant.save() # This will update last_heartbeat due to auto_now=True + except QuizGameParticipant.DoesNotExist: + pass + + @database_sync_to_async + def _remove_inactive_participants(self): + # Remove participants who haven't sent a heartbeat in the last 30 seconds + timeout = timezone.now() - timedelta(seconds=30) + quiz_game = QuizGame.objects.get(join_code=self.join_code) + QuizGameParticipant.objects.filter( + quiz_game=quiz_game, + last_heartbeat__lt=timeout + ).delete() + + async def check_participants_heartbeat(self): + while True: + try: + await asyncio.sleep(10) # Check every 10 seconds + await self._remove_inactive_participants() + + # Send updated participant list + participants = await self.get_participants() + await self.channel_layer.group_send( + self.room_group_name, + { + 'type': 'participant_list_update', + 'participants': participants + } + ) + except asyncio.CancelledError: + break + except Exception as e: + print(f'Error in heartbeat check: {e}') + await asyncio.sleep(10) # Wait before retrying diff --git a/django/play/migrations/0004_remove_quizgame_host_user_quizgame_host_id.py b/django/play/migrations/0004_remove_quizgame_host_user_quizgame_host_id.py new file mode 100644 index 0000000..c6ad606 --- /dev/null +++ b/django/play/migrations/0004_remove_quizgame_host_user_quizgame_host_id.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.7 on 2025-04-05 10:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('play', '0003_quizgameparticipant_participant_id_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='quizgame', + name='host_user', + ), + migrations.AddField( + model_name='quizgame', + name='host_id', + field=models.CharField(default=None, max_length=200, unique=True), + preserve_default=False, + ), + ] diff --git a/django/play/migrations/0005_quizgameparticipant_last_heartbeat.py b/django/play/migrations/0005_quizgameparticipant_last_heartbeat.py new file mode 100644 index 0000000..dfc049f --- /dev/null +++ b/django/play/migrations/0005_quizgameparticipant_last_heartbeat.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.7 on 2025-04-05 12:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('play', '0004_remove_quizgame_host_user_quizgame_host_id'), + ] + + operations = [ + migrations.AddField( + model_name='quizgameparticipant', + name='last_heartbeat', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/django/play/models.py b/django/play/models.py index 547af30..e3e0a72 100644 --- a/django/play/models.py +++ b/django/play/models.py @@ -6,13 +6,13 @@ import random, string # Create your models here. class QuizGame(models.Model): - host_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='host_user') + host_id = models.CharField(max_length=200, unique=True) join_code = models.CharField(max_length=6, unique=True, blank=True) quiz_id = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE) def save(self, *args, **kwargs): if not self.join_code: - self.join_code = self.generate_unique_code() + self.join_code = self.generate_unique_code() super().save(*args, **kwargs) def generate_unique_code(self): @@ -21,12 +21,19 @@ class QuizGame(models.Model): if not QuizGame.objects.filter(join_code=new_code).exists(): return new_code + def generate_unique_id(self): + for i in range(10): + new_id = ''.join(random.choices(string.digits + string.ascii_lowercase, k=60)) + if not QuizGameParticipant.objects.filter(participant_id=new_id).exists(): + return new_id + class QuizGameParticipant(models.Model): participant_id = models.CharField(max_length=200, unique=True) display_name = models.CharField(verbose_name="Anzeigename", max_length=15) quiz_game = models.ForeignKey(QuizGame, on_delete=models.CASCADE, related_name="participant") score = models.IntegerField(verbose_name="Punkte", default=0) avatar = models.CharField(max_length=200, blank=True, default="") + last_heartbeat = models.DateTimeField(auto_now=True) def save(self, *args, **kwargs): if not self.participant_id: diff --git a/django/play/routing.py b/django/play/routing.py new file mode 100644 index 0000000..8eaabe6 --- /dev/null +++ b/django/play/routing.py @@ -0,0 +1,6 @@ +from django.urls import re_path +from . import consumers + +websocket_urlpatterns = [ + re_path(r'ws/game/(?P\w+)/$', consumers.LobbyConsumer.as_asgi()), +] diff --git a/django/play/urls.py b/django/play/urls.py index 1cd6093..8a9e692 100644 --- a/django/play/urls.py +++ b/django/play/urls.py @@ -4,9 +4,10 @@ from . import views app_name = 'play' urlpatterns = [ + path('game/new/', views.create_game, name='create_game'), path('game/', views.lobby, name='lobby'), path('game//participate', views.create_participant, name='create_participant'), path('join', views.join_game, name='join_game'), path('game//', views.question, name='question'), - path('game///participant', views.question_participant, name='question_participant'), + path('game///participant', views.question_participant, name='question_participant'), ] \ No newline at end of file diff --git a/django/play/views.py b/django/play/views.py index abf4ae0..4a04bda 100644 --- a/django/play/views.py +++ b/django/play/views.py @@ -2,34 +2,52 @@ from django.shortcuts import render from django.shortcuts import render, redirect, get_object_or_404, reverse from play.models import QuizGame, QuizGameParticipant from library.models import QivipQuiz, QivipQuestion -from django.http import HttpResponseRedirect +from django.http import HttpResponseRedirect, HttpResponseNotFound import json # Create your views here. def lobby(request, join_code): - if not "participant_id" in request.COOKIES: - return redirect('play:create_participant', join_code=join_code) - participant_id = request.COOKIES['participant_id'] quiz_game = get_object_or_404(QuizGame, join_code=join_code) quiz = get_object_or_404(QivipQuiz, pk=quiz_game.quiz_id.id) - participant = get_object_or_404(QuizGameParticipant, participant_id=participant_id) - - if not participant.quiz_game.join_code == join_code: - participant.quiz_game = quiz_game - participant.save() - context = { - 'debug': "test", - 'participant': participant, 'quiz': quiz, + 'quiz_game': quiz_game, } - + + if "host_id" in request.COOKIES: + host_id = request.COOKIES['host_id'] + if host_id == quiz_game.host_id: + context['host_id'] = host_id + return render(request, 'play/lobby.html', context=context) + + if not "participant_id" in request.COOKIES: + return redirect('play:create_participant', join_code=join_code) + + participant_id = request.COOKIES['participant_id'] + try: + participant = QuizGameParticipant.objects.get(participant_id=participant_id) + if not participant.quiz_game.join_code == join_code: # Teilnehmer dem richtigen Spiel hinzufügen + participant.quiz_game = quiz_game + participant.save() + except QuizGameParticipant.DoesNotExist: + return redirect('play:create_participant', join_code=join_code) + + context['participant'] = participant return render(request, 'play/lobby.html', context=context) def create_participant(request, join_code): - if "participant_id" in request.COOKIES: # Umleiten, falls Teilnehmer bereits erstellt - return redirect('play:lobby', join_code=join_code) + if "participant_id" in request.COOKIES: + # Prüfe ob der Teilnehmer noch existiert + participant_id = request.COOKIES['participant_id'] + try: + participant = QuizGameParticipant.objects.get(participant_id=participant_id) + return redirect('play:lobby', join_code=join_code) + except QuizGameParticipant.DoesNotExist: + # Cookie löschen wenn Teilnehmer nicht mehr existiert + response = HttpResponseRedirect(request.path) + response.delete_cookie('participant_id') + return response if request.method == 'POST': display_name = request.POST.get('display_name') join_code = request.POST.get('join_code') @@ -50,6 +68,22 @@ def create_participant(request, join_code): return render(request, 'play/initialize_participant.html', {'join_code': join_code}) +def create_game(request, quiz_id): + try: + quiz = QivipQuiz.objects.get(id=quiz_id) + game = QuizGame() + game.quiz_id = quiz + game.host_id = game.generate_unique_id() + game.save() + + response = HttpResponseRedirect(reverse('play:lobby', kwargs={'join_code': game.join_code})) + response.set_cookie('host_id', game.host_id, max_age=3600) + + return response + except QivipQuiz.DoesNotExist: + return HttpResponseNotFound("Quiz nicht gefunden") + + def join_game(request): if request.method == 'POST': join_code = request.POST.get('game_code') diff --git a/django/templates/library/overview_quiz.html b/django/templates/library/overview_quiz.html index 0cb1313..a5807d5 100644 --- a/django/templates/library/overview_quiz.html +++ b/django/templates/library/overview_quiz.html @@ -85,7 +85,7 @@
- Spiel starten + Spiel starten
@@ -145,7 +145,7 @@
- Spiel starten + Spiel starten @@ -176,7 +176,7 @@ {% endif %} {% if not filter_other_quizzes and not filter_my_quizzes %} -
Es wurde kein Quiz passendes gefunden!
+
Es wurde kein passendes Quiz gefunden!
{% endif %} diff --git a/django/templates/play/lobby.html b/django/templates/play/lobby.html index 6227d01..ff188de 100644 --- a/django/templates/play/lobby.html +++ b/django/templates/play/lobby.html @@ -2,20 +2,73 @@ {% block content %}
-

{{ quiz.name }}

+

{{ quiz.name }}

+

{{ quiz_game.join_code }}

+ {% if participant %}

Sichtbar als {{ participant.display_name }}

-
-
    -
  • Noch keine Spieler gefunden.
  • + {% endif %} +
    +

    Teilnehmer:

    +
      +
    • Warte auf Teilnehmer...
    - {{debug}}
    - + {% if host_id %} + + {% endif %}
+{% endblock %} +{% block extra_js %} {% endblock %} \ No newline at end of file