diff --git a/django/core/settings.py b/django/core/settings.py index 65b017c..f8244b3 100644 --- a/django/core/settings.py +++ b/django/core/settings.py @@ -98,6 +98,9 @@ STORAGES = { "staticfiles": { "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", }, + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, } diff --git a/django/library/views.py b/django/library/views.py index 0211216..36d77b9 100644 --- a/django/library/views.py +++ b/django/library/views.py @@ -15,20 +15,20 @@ from django.contrib.auth.models import User # Übersicht aller Quizze def overview_quiz(request): - - #Filter + # Filter form = QuizFilterForm(request.GET) - - try: - - filter_my_quizzes = QivipQuiz.objects.filter(user_id=request.user).annotate(max_amout_questions=Count('questions')) - except: + # Initialize querysets + if request.user.is_authenticated: + filter_my_quizzes = QivipQuiz.objects.filter(user_id=request.user).annotate(max_amout_questions=Count('questions')) + filter_other_quizzes = QivipQuiz.objects.exclude(user_id=request.user) + else: filter_my_quizzes = QivipQuiz.objects.none() - - filter_other_quizzes = QivipQuiz.objects.annotate(max_amout_questions=Count('questions')) - - + filter_other_quizzes = QivipQuiz.objects.all() + + # Apply common filters to other quizzes + filter_other_quizzes = filter_other_quizzes.annotate(max_amout_questions=Count('questions')) + filter_other_quizzes = filter_other_quizzes.filter(max_amout_questions__gte=1, status='öffentlich') if form.is_valid(): search = form.cleaned_data.get('search') @@ -41,10 +41,7 @@ def overview_quiz(request): if min_amout_questions: filter_other_quizzes = filter_other_quizzes.filter(max_amout_questions__gte=min_amout_questions) - try: - filter_my_quizzes =filter_my_quizzes.filter(max_amout_questions__gte=min_amout_questions) - except: - filter_my_quizzes = QivipQuiz.objects.none() + filter_my_quizzes = filter_my_quizzes.filter(max_amout_questions__gte=min_amout_questions) if filter_my_quizzes else QivipQuiz.objects.none() if max_amout_questions: filter_other_quizzes = filter_other_quizzes.filter(max_amout_questions__lte=max_amout_questions) @@ -69,15 +66,26 @@ def overview_quiz(request): - filter_other_quizzes = filter_other_quizzes.filter(max_amout_questions__gte=1).filter(status='öffentlich') - + # Pagination for my quizzes + my_quizzes_paginator = Paginator(filter_my_quizzes.order_by('-creation_date'), 8) # 8 quizzes per page + try: + my_quizzes = my_quizzes_paginator.page(request.GET.get('my_page', 1)) + except: + my_quizzes = my_quizzes_paginator.page(1) + + # Pagination for other quizzes + other_quizzes_paginator = Paginator(filter_other_quizzes.order_by('-creation_date'), 8) # 8 quizzes per page + try: + other_quizzes = other_quizzes_paginator.page(request.GET.get('other_page', 1)) + except: + other_quizzes = other_quizzes_paginator.page(1) + context = { 'show_search': True, - 'filter_my_quizzes': filter_my_quizzes.order_by('-creation_date'), - 'filter_other_quizzes': filter_other_quizzes.order_by('-creation_date'), + 'my_quizzes': my_quizzes, + 'other_quizzes': other_quizzes, 'form': form, } - return render(request, 'library/overview_quiz.html', context) diff --git a/django/play/consumers/lobby.py b/django/play/consumers/lobby.py index 2add1ca..cccbd9f 100644 --- a/django/play/consumers/lobby.py +++ b/django/play/consumers/lobby.py @@ -4,8 +4,9 @@ import asyncio from ..models import QuizGame, QuizGameParticipant from channels.db import database_sync_to_async -# Dictionary to track inactive check tasks per game +# Dictionary to track inactive check tasks and active connections per game game_check_tasks = {} +game_connections = {} class LobbyConsumer(AsyncWebsocketConsumer): @classmethod @@ -16,39 +17,20 @@ class LobbyConsumer(AsyncWebsocketConsumer): room_group_name = f'lobby_{join_code}' while True: try: - await asyncio.sleep(30) # Check every 30 seconds + await asyncio.sleep(10) # Check every 10 seconds to match frontend heartbeat # Get participants list and broadcast update try: from django.utils import timezone from datetime import timedelta game = await database_sync_to_async(QuizGame.objects.get)(join_code=join_code) - cutoff_time = timezone.now() - timedelta(minutes=1) - # Get previous active participants - prev_active = await database_sync_to_async(lambda: set( - game.participants.values_list('display_name', flat=True) - ))() - - # Get current active participants + cutoff_time = timezone.now() - timedelta(seconds=20) # Consider inactive after 20 seconds (2 missed heartbeats) + # Get current active participants with full info active_participants = await database_sync_to_async(lambda: list( game.participants.filter(last_heartbeat__gte=cutoff_time) - .values_list('display_name', flat=True) + .values('participant_id', 'display_name') ))() - # Find removed participants - removed = set(prev_active) - set(active_participants) - - # Send notifications for removed participants - for name in removed: - await channel_layer.group_send( - room_group_name, - { - 'type': 'player_left', - 'player_name': name, - 'was_kicked': True - } - ) - - # Broadcast update to all clients + # Send update to all clients await channel_layer.group_send( room_group_name, { @@ -76,6 +58,14 @@ class LobbyConsumer(AsyncWebsocketConsumer): # Get participant ID from session self.participant_id = self.scope['session'].get('participant_id') + # Track connection + if self.join_code not in game_connections: + game_connections[self.join_code] = set() + game_connections[self.join_code].add(self.channel_name) + + # Start inactive check task if not already running + await self.start_inactive_check(self.join_code, self.channel_layer) + # Join room group await self.channel_layer.group_add( self.room_group_name, @@ -87,9 +77,6 @@ class LobbyConsumer(AsyncWebsocketConsumer): # Send initial participants list await self.update_participants() - # Ensure inactive check is running for this game - await self.start_inactive_check(self.join_code, self.channel_layer) - async def disconnect(self, close_code): # Leave room group await self.channel_layer.group_discard( @@ -97,6 +84,17 @@ class LobbyConsumer(AsyncWebsocketConsumer): self.channel_name ) + # Remove from connection tracking + if self.join_code in game_connections: + game_connections[self.join_code].discard(self.channel_name) + if not game_connections[self.join_code]: + # Last connection closed + del game_connections[self.join_code] + if self.join_code in game_check_tasks: + # Cancel the inactive check task + game_check_tasks[self.join_code].cancel() + del game_check_tasks[self.join_code] + async def receive(self, text_data): data = json.loads(text_data) message_type = data.get('type') @@ -113,8 +111,7 @@ class LobbyConsumer(AsyncWebsocketConsumer): participant_id = data.get('participant_id') if participant_id: await self.update_participant_heartbeat(participant_id) - # Update participants list after heartbeat - await self.update_participants() + # Don't update participants list after heartbeat - let the background task handle it elif message_type in ['leave_game', 'kick_player']: participant_id = data.get('participant_id') if participant_id: @@ -200,7 +197,7 @@ class LobbyConsumer(AsyncWebsocketConsumer): from datetime import timedelta game = await database_sync_to_async(QuizGame.objects.get)(join_code=self.join_code) # Only return participants that have been seen in the last minute - cutoff_time = timezone.now() - timedelta(minutes=1) + cutoff_time = timezone.now() - timedelta(seconds=20) # Consider inactive after 20 seconds (2 missed heartbeats) participants = await database_sync_to_async(lambda: list( game.participants.filter(last_heartbeat__gte=cutoff_time) .values('participant_id', 'display_name') diff --git a/django/play/migrations/0010_alter_quizgameparticipant_last_heartbeat.py b/django/play/migrations/0010_alter_quizgameparticipant_last_heartbeat.py new file mode 100644 index 0000000..04f5530 --- /dev/null +++ b/django/play/migrations/0010_alter_quizgameparticipant_last_heartbeat.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.7 on 2025-04-06 17:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('play', '0009_quizanswer'), + ] + + operations = [ + migrations.AlterField( + model_name='quizgameparticipant', + name='last_heartbeat', + field=models.DateTimeField(auto_now_add=True), + ), + ] diff --git a/django/play/models.py b/django/play/models.py index adf9fee..33feaf1 100644 --- a/django/play/models.py +++ b/django/play/models.py @@ -43,7 +43,7 @@ class QuizGameParticipant(models.Model): quiz_game = models.ForeignKey(QuizGame, on_delete=models.CASCADE, related_name="participants") score = models.IntegerField(verbose_name="Punkte", default=0) avatar = models.CharField(max_length=200, blank=True, default="") - last_heartbeat = models.DateTimeField(auto_now=True) + last_heartbeat = models.DateTimeField(auto_now_add=True) last_answer = models.IntegerField(null=True, blank=True) last_answer_correct = models.BooleanField(null=True, blank=True) diff --git a/django/templates/library/overview_quiz.html b/django/templates/library/overview_quiz.html index 9278100..10d9a5e 100644 --- a/django/templates/library/overview_quiz.html +++ b/django/templates/library/overview_quiz.html @@ -1,236 +1,392 @@ {% extends 'base.html' %} -{% block content %} {% load static %} - - - - - +{% endblock %} - .swiper-slide { - text-align: center; - font-size: 18px; - background: #fff; - display: flex; - justify-content: center; - align-items: center; - } +{% block content %} - - - - -
- Neues Quiz erstellen -
-
- Suche und Filter zurücksetzen +
+ +
-
-
-
-

Filter

- Minimale Anzahl an Fragen {{ form.min_amout_questions }} - Maximale Anzahl an Fragen {{ form.max_amout_questions }} - von User {{ form.user }} - - +
+
+ +
+ + {{ form.min_amout_questions }} +
+
+ + {{ form.max_amout_questions }} +
+
+ + {{ form.user }} +
+
+ +
- -{% if filter_my_quizzes %} -
-

Eigene Quiz

+ + +
+
+

Eigene Quiz

+
+
-
-
- {% for quiz in filter_my_quizzes %} -
-
-
- -

{{ quiz.name }}

- -

- {{ quiz.description }}
- Schwierigkeit: {{ quiz.difficulty }}
- Anzahl der Fragen: {{ quiz.question.count }}
- Status: {{ quiz.status }}
- Bewertung: - {% for i in '12345'|make_list %} - {% if forloop.counter <= quiz.average_rating|floatformat:0|add:0 %} - - {% else %} - - {% endif %} - {% endfor %} - ({{ quiz.rating_count }}) - - - -

- {% if quiz.authors.all %} - Autor(en): - {% for author in quiz.authors.all %} - - {{ author.username }}{% if not forloop.last %}, {% endif %} - {% endfor %} -

+
+
+ {% for quiz in my_quizzes %} +
+ + +
+ +

+ {{ quiz.name }} +

+ +

{{ quiz.description }}

+ + +
+ +
+ + + + {{ quiz.difficulty }} +
+ + +
+ + + + {{ quiz.questions.count }} Fragen +
+ + +
+ + + + {{ quiz.status }} +
+ + +
+ {% for i in '12345'|make_list %} + {% if forloop.counter <= quiz.average_rating|floatformat:0|add:0 %} + + {% else %} + + {% endif %} + {% endfor %} + ({{ quiz.rating_count }}) +
+
+ + + {% if quiz.authors.all %} +
+ + + + + {% for author in quiz.authors.all %} + {{ author.username }}{% if not forloop.last %}, {% endif %} + {% endfor %} + +
+ {% endif %} + + +
+ + + + + + Spielen + +
+ + + + + + + {% if quiz.user == request.user %} + + + + + + + + + + + {% endif %} +
+
+
+ {% endfor %}
- {% if filter_my_quizzes|length > 1 %} -
-
- {% endif %} -
-{% endif %} - - -{% if filter_other_quizzes %} -
-

Quiz von anderen Nutzern

-
- -
-
- - - {% for quiz in filter_other_quizzes %} -
-
-
- -

{{ quiz.name }}

- -

- {{ quiz.description }}
- Schwierigkeit: {{ quiz.difficulty }}
- Anzahl der Fragen: {{ quiz.question.count }}
- Erstellt am: {{ quiz.creation_date }} -

- {% if quiz.original_creator %} -

Ursprünglich erstellt von: {{ quiz.original_creator }}

- {% endif %} - - - - -
+ + {% if my_quizzes.paginator.num_pages > 1 %} +
+ {% if my_quizzes.has_previous %} + + + + + + {% endif %} + + Seite {{ my_quizzes.number }} von {{ my_quizzes.paginator.num_pages }} + + {% if my_quizzes.has_next %} + + + + + + {% endif %}
- {% endfor %} - -
- - - - {% if filter_other_quizzes|length > 1 %} -
-
{% endif %}
+ + +
+
+

Quiz von anderen Nutzern

+
+
+
+ +{% if other_quizzes %} +
+
+ {% for quiz in other_quizzes %} +
+ +
+ {% if quiz.image %} +
+ + {% else %} +
+ {% endif %} +
+ + +
+ +

+ {{ quiz.name }} +

+ +

{{ quiz.description }}

+ + +
+ +
+ + + + {{ quiz.difficulty }} +
+ + +
+ + + + {{ quiz.questions.count }} Fragen +
+ + +
+ + + + {{ quiz.status }} +
+ + +
+ {% for i in '12345'|make_list %} + {% if forloop.counter <= quiz.average_rating|floatformat:0|add:0 %} + + {% else %} + + {% endif %} + {% endfor %} + ({{ quiz.rating_count }}) +
+
+ + + {% if quiz.authors.all %} +
+ + + + + {% for author in quiz.authors.all %} + {{ author.username }}{% if not forloop.last %}, {% endif %} + {% endfor %} + +
+ {% endif %} + + + +
+
+ {% endfor %} +
+ + + {% if other_quizzes.paginator.num_pages > 1 %} +
+ {% if other_quizzes.has_previous %} + + + + + + {% endif %} + + Seite {{ other_quizzes.number }} von {{ other_quizzes.paginator.num_pages }} + + {% if other_quizzes.has_next %} + + + + + + {% endif %} +
+ {% endif %} +
+{% else %} +
+

Keine öffentlichen Quiz gefunden.

+
{% endif %} - -{% if not filter_other_quizzes and not filter_my_quizzes %} -
Es wurde kein passendes Quiz gefunden!
-{% endif %} - - - - - - - - -{% endblock %} +{% endblock content %} diff --git a/django/templates/play/lobby.html b/django/templates/play/lobby.html index 29c6922..1731e6b 100644 --- a/django/templates/play/lobby.html +++ b/django/templates/play/lobby.html @@ -70,11 +70,10 @@ const maxReconnectAttempts = 5; const pingDelay = 30000; // 30 seconds const heartbeatDelay = 10000; // 10 seconds + let heartbeatInterval = null; function startPingInterval() { - if (pingInterval) { - clearInterval(pingInterval); - } + stopPingInterval(); pingInterval = setInterval(() => { if (lobbySocket && lobbySocket.readyState === WebSocket.OPEN) { lobbySocket.send(JSON.stringify({ type: 'ping' })); @@ -89,6 +88,25 @@ } } + function startHeartbeat() { + stopHeartbeat(); + heartbeatInterval = setInterval(() => { + if (lobbySocket && lobbySocket.readyState === WebSocket.OPEN) { + lobbySocket.send(JSON.stringify({ + type: 'heartbeat', + participant_id: '{{ participant.participant_id }}' + })); + } + }, heartbeatDelay); + } + + function stopHeartbeat() { + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + } + function connectWebSocket() { if (lobbySocket && (lobbySocket.readyState === WebSocket.CONNECTING || lobbySocket.readyState === WebSocket.OPEN)) { return lobbySocket; // Already connected or connecting @@ -109,6 +127,9 @@ console.log('Lobby socket connected'); reconnectAttempts = 0; startPingInterval(); + {% if participant %} + startHeartbeat(); + {% endif %} // Request initial participants list lobbySocket.send(JSON.stringify({ type: 'update_participants' @@ -118,10 +139,12 @@ lobbySocket.onclose = function(e) { wsUtil.handleClose(e); stopPingInterval(); + stopHeartbeat(); lobbySocket = null; reconnectAttempts++; if (reconnectAttempts < maxReconnectAttempts) { - setTimeout(connectWebSocket, 1000 * Math.min(reconnectAttempts, 3)); + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000); // Exponential backoff, max 10s + setTimeout(connectWebSocket, delay); } }; @@ -165,17 +188,7 @@ } } - {% if participant %} - // Send heartbeat every 10 seconds - setInterval(function() { - if (lobbySocket && lobbySocket.readyState === WebSocket.OPEN) { - lobbySocket.send(JSON.stringify({ - type: 'heartbeat', - participant_id: '{{ participant.participant_id }}' - })); - } - }, heartbeatDelay); - {% endif %} + // Initial connection lobbySocket = connectWebSocket();