From 3637edce33fe99c3076767e2739a9c3a4e0e9ebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhn-Vitus=20Sa=C3=9F?= Date: Sun, 6 Apr 2025 15:57:27 +0200 Subject: [PATCH 1/6] Initial Game Logic --- .../0031_alter_qivipquestion_quiz_id.py | 19 + ..._rating_qivipquiz_rating_count_and_more.py | 37 ++ django/play/consumers/__init__.py | 3 + django/play/consumers/game.py | 492 ++++++++++++++++++ django/play/consumers/lobby.py | 285 ++++++++++ ...uizgame_current_question_index_and_more.py | 28 + ...007_alter_quizgameparticipant_quiz_game.py | 19 + ...uizgameparticipant_last_answer_and_more.py | 23 + django/play/migrations/0009_quizanswer.py | 29 ++ .../templates/play/game/common_scripts.html | 89 ++++ django/templates/play/game/finished.html | 201 +++++++ 11 files changed, 1225 insertions(+) create mode 100644 django/library/migrations/0031_alter_qivipquestion_quiz_id.py create mode 100644 django/library/migrations/0032_qivipquiz_average_rating_qivipquiz_rating_count_and_more.py create mode 100644 django/play/consumers/__init__.py create mode 100644 django/play/consumers/game.py create mode 100644 django/play/consumers/lobby.py create mode 100644 django/play/migrations/0006_quizgame_current_question_index_and_more.py create mode 100644 django/play/migrations/0007_alter_quizgameparticipant_quiz_game.py create mode 100644 django/play/migrations/0008_quizgameparticipant_last_answer_and_more.py create mode 100644 django/play/migrations/0009_quizanswer.py create mode 100644 django/templates/play/game/common_scripts.html create mode 100644 django/templates/play/game/finished.html diff --git a/django/library/migrations/0031_alter_qivipquestion_quiz_id.py b/django/library/migrations/0031_alter_qivipquestion_quiz_id.py new file mode 100644 index 0000000..fc46a2f --- /dev/null +++ b/django/library/migrations/0031_alter_qivipquestion_quiz_id.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.7 on 2025-04-05 17:45 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0030_remove_qivipquiz_creator_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='qivipquestion', + name='quiz_id', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='library.qivipquiz'), + ), + ] diff --git a/django/library/migrations/0032_qivipquiz_average_rating_qivipquiz_rating_count_and_more.py b/django/library/migrations/0032_qivipquiz_average_rating_qivipquiz_rating_count_and_more.py new file mode 100644 index 0000000..2c3d06a --- /dev/null +++ b/django/library/migrations/0032_qivipquiz_average_rating_qivipquiz_rating_count_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.7 on 2025-04-05 19:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0031_alter_qivipquestion_quiz_id'), + ] + + operations = [ + migrations.AddField( + model_name='qivipquiz', + name='average_rating', + field=models.FloatField(default=3.0), + ), + migrations.AddField( + model_name='qivipquiz', + name='rating_count', + field=models.IntegerField(default=0), + ), + migrations.CreateModel( + name='QuizRating', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('participant_id', models.CharField(max_length=200)), + ('rating', models.IntegerField(choices=[(1, '1'), (2, '2'), (3, '3'), (4, '4'), (5, '5')])), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to='library.qivipquiz')), + ], + options={ + 'unique_together': {('quiz', 'participant_id')}, + }, + ), + ] diff --git a/django/play/consumers/__init__.py b/django/play/consumers/__init__.py new file mode 100644 index 0000000..7e9d7f6 --- /dev/null +++ b/django/play/consumers/__init__.py @@ -0,0 +1,3 @@ +from .game import GameConsumer + +__all__ = ['GameConsumer'] diff --git a/django/play/consumers/game.py b/django/play/consumers/game.py new file mode 100644 index 0000000..841a8a9 --- /dev/null +++ b/django/play/consumers/game.py @@ -0,0 +1,492 @@ +import json +import asyncio +from channels.generic.websocket import AsyncWebsocketConsumer +from channels.db import database_sync_to_async +from django.utils import timezone +from django.urls import reverse +from django.db import models +from datetime import timedelta + +from play.models import QuizGame, QuizGameParticipant, QuizAnswer + +# Dictionary to track inactive check tasks per game +game_check_tasks = {} + + +class GameConsumer(AsyncWebsocketConsumer): + @classmethod + async def start_inactive_check(cls, join_code, channel_layer): + """Start the inactive player check for a game if not already running.""" + if join_code not in game_check_tasks or game_check_tasks[join_code].done(): + async def check_inactive_players(): + game_group_name = f'game_{join_code}' + while True: + try: + await asyncio.sleep(30) # Check every 30 seconds + try: + game = await database_sync_to_async(QuizGame.objects.get)(join_code=join_code) + cutoff_time = timezone.now() - timedelta(minutes=1) + + # Get active and inactive participants + all_participants = await database_sync_to_async(lambda: list( + game.participants.all().values('participant_id', 'display_name', 'last_heartbeat') + ))() + + # Konvertiere datetime zu ISO Format String + for p in all_participants: + if p['last_heartbeat']: + p['last_heartbeat'] = p['last_heartbeat'].isoformat() + + active_participants = [p for p in all_participants if p['last_heartbeat'] and timezone.datetime.fromisoformat(p['last_heartbeat']) >= cutoff_time] + inactive_participants = [p for p in all_participants if not p['last_heartbeat'] or timezone.datetime.fromisoformat(p['last_heartbeat']) < cutoff_time] + + # Remove inactive participants + if inactive_participants: + for p in inactive_participants: + # Benachrichtige andere über den gekickten Spieler + await channel_layer.group_send( + game_group_name, + { + 'type': 'player_left', + 'player_name': p['display_name'], + 'was_kicked': True + } + ) + # Lösche den inaktiven Teilnehmer + await database_sync_to_async(QuizGameParticipant.objects.filter( + participant_id=p['participant_id'] + ).delete)() + + # Broadcast update to all clients + if active_participants or inactive_participants: + await channel_layer.group_send( + game_group_name, + { + 'type': 'participant_list_update', + 'participants': active_participants + } + ) + except QuizGame.DoesNotExist: + break # Stop checking if game no longer exists + except Exception as e: + print(f'Error checking inactive players in game: {e}') + await asyncio.sleep(5) # Wait before retry + except asyncio.CancelledError: + break + except Exception as e: + print(f'Error in game inactive check loop: {e}') + await asyncio.sleep(5) + + game_check_tasks[join_code] = asyncio.create_task(check_inactive_players()) + + async def connect(self): + self.join_code = self.scope['url_route']['kwargs']['join_code'] + self.game_group_name = f'game_{self.join_code}' + + # Join game group + await self.channel_layer.group_add( + self.game_group_name, + self.channel_name + ) + + await self.accept() + + # 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 game group + await self.channel_layer.group_discard( + self.game_group_name, + self.channel_name + ) + + # Check if game should be deleted + await self.cleanup_game() + + async def receive(self, text_data): + text_data_json = json.loads(text_data) + message_type = text_data_json['type'] + + if message_type == 'ping': + # Respond with pong to keep connection alive + await self.send(text_data=json.dumps({ + 'type': 'pong' + })) + return + + if message_type == 'leave_game': + participant_id = text_data_json.get('participant_id') + if participant_id: + try: + # Get participant info before deletion + participant = await database_sync_to_async(QuizGameParticipant.objects.get)(participant_id=participant_id) + display_name = participant.display_name + join_code = self.join_code + + # Delete participant + await database_sync_to_async(participant.delete)() + + # Update participants list + participants = await self.get_participants() + await self.channel_layer.group_send( + self.game_group_name, + { + 'type': 'participant_list_update', + 'participants': participants + } + ) + + # Send redirect to home + await self.send(text_data=json.dumps({ + 'type': 'redirect', + 'url': '/' + })) + except QuizGameParticipant.DoesNotExist: + pass + return + + if message_type == 'heartbeat': + participant_id = text_data_json.get('participant_id') + if participant_id: + await self.update_participant_heartbeat(participant_id) + # Update participants list after heartbeat + participants = await self.get_participants() + await self.channel_layer.group_send( + self.game_group_name, + { + 'type': 'participant_list_update', + 'participants': participants + } + ) + return + + if message_type == 'submit_rating': + participant_id = text_data_json['participant_id'] + rating = text_data_json['rating'] + success = await self.save_rating(participant_id, rating) + if success: + await self.send(text_data=json.dumps({ + 'type': 'rating_confirmed' + })) + + elif message_type == 'submit_answer': + participant_id = text_data_json['participant_id'] + answer = text_data_json['answer'] + time_remaining = text_data_json.get('time_remaining', 0) + + # Save answer and update participant score + await self.save_answer(participant_id, answer, time_remaining) + + # Notify host about the new answer + await self.channel_layer.group_send( + self.game_group_name, + { + 'type': 'participant_answer', + 'answer': answer + } + ) + + elif message_type == 'start_game': + host_id = text_data_json['host_id'] + if await self.verify_host(host_id): + await self.advance_to_next_question() + + elif message_type == 'get_answer_stats': + stats = await self.get_answer_stats() + await self.send(text_data=json.dumps({ + 'type': 'answer_stats', + 'stats': stats + })) + + elif message_type == 'update_participants': + participants = await self.get_participants() + await self.channel_layer.group_send( + self.game_group_name, + { + 'type': 'participant_list_update', + 'participants': participants + } + ) + + elif message_type == 'next_question': + host_id = text_data_json['host_id'] + if await self.verify_host(host_id): + await self.advance_to_next_question() + + elif message_type == 'finish_game': + host_id = text_data_json['host_id'] + if await self.verify_host(host_id): + await self.finish_game() + + elif message_type == 'advance_to_scores': + host_id = text_data_json['host_id'] + if await self.verify_host(host_id): + await self.show_scores() + + async def participant_answer(self, event): + await self.send(text_data=json.dumps({ + 'type': 'participant_answer', + 'answer': event['answer'] + })) + + async def participant_list_update(self, event): + await self.send(text_data=json.dumps({ + 'type': 'participant_list_update', + 'participants': event['participants'] + })) + + async def player_left(self, event): + await self.send(text_data=json.dumps({ + 'type': 'player_left', + 'player_name': event['player_name'], + 'was_kicked': event.get('was_kicked', False) + })) + + async def game_state_update(self, event): + await self.send(text_data=json.dumps({ + 'type': 'game_state_update', + 'action': event['action'], + 'redirect_url': event.get('redirect_url') + })) + + async def update_participant_heartbeat(self, participant_id): + """Update the last heartbeat timestamp for a participant.""" + try: + participant = await database_sync_to_async(QuizGameParticipant.objects.get)( + participant_id=participant_id + ) + participant.last_heartbeat = timezone.now() + await database_sync_to_async(participant.save)() + except QuizGameParticipant.DoesNotExist: + pass + + @database_sync_to_async + def verify_host(self, host_id): + try: + quiz_game = QuizGame.objects.get(join_code=self.join_code) + return quiz_game.host_id == host_id + except QuizGame.DoesNotExist: + return False + + @database_sync_to_async + def save_answer(self, participant_id, answer_index, time_remaining): + try: + participant = QuizGameParticipant.objects.get( + quiz_game__join_code=self.join_code, + participant_id=participant_id + ) + quiz_game = participant.quiz_game + current_question = quiz_game.quiz_id.questions.all()[quiz_game.current_question_index] + + # Check if answer is correct + is_correct = False + if answer_index >= 0: # -1 means timeout + question_data = json.loads(current_question.data) + is_correct = question_data['options'][answer_index]['is_correct'] + + # Calculate score based on correctness and time + score = 0 + if is_correct: + base_score = 1000 + time_factor = time_remaining / 30000 # 30 seconds max + score = int(base_score * (0.5 + 0.5 * time_factor)) + + # Update or create answer in QuizAnswer table + answer_obj, created = QuizAnswer.objects.get_or_create( + participant=participant, + question_index=quiz_game.current_question_index, + defaults={ + 'answer_index': answer_index, + 'is_correct': is_correct, + 'score': score, + 'time_remaining': time_remaining + } + ) + + if not created: + answer_obj.answer_index = answer_index + answer_obj.is_correct = is_correct + answer_obj.score = score + answer_obj.time_remaining = time_remaining + answer_obj.save() + + participant.score += score + participant.last_answer_correct = is_correct + participant.save() + + return True + except (QuizGameParticipant.DoesNotExist, IndexError): + return False + + @database_sync_to_async + def get_answer_stats(self): + try: + quiz_game = QuizGame.objects.get(join_code=self.join_code) + + # Count answers for each option in the current question + stats = {} + answers = QuizAnswer.objects.filter( + participant__quiz_game=quiz_game, + question_index=quiz_game.current_question_index + ) + + for answer in answers: + if answer.answer_index >= 0: # Ignore timeouts (-1) + stats[answer.answer_index] = stats.get(answer.answer_index, 0) + 1 + + return stats + except QuizGame.DoesNotExist: + return {} + + @database_sync_to_async + def save_rating(self, participant_id, rating): + from library.models import QuizRating + try: + game = QuizGame.objects.get(join_code=self.join_code) + participant = QuizGameParticipant.objects.get( + quiz_game=game, + participant_id=participant_id + ) + + # Update or create rating + rating_obj, created = QuizRating.objects.get_or_create( + quiz=game.quiz_id, + participant_id=participant_id, + defaults={'rating': rating} + ) + + if not created: + rating_obj.rating = rating + rating_obj.save() + + return True + except (QuizGame.DoesNotExist, QuizGameParticipant.DoesNotExist): + return False + + @database_sync_to_async + def cleanup_game(self): + try: + game = QuizGame.objects.get(join_code=self.join_code) + + # Get all active participants (heartbeat within last minute) + from django.utils import timezone + active_participants = QuizGameParticipant.objects.filter( + quiz_game=game, + last_heartbeat__gte=timezone.now() - timezone.timedelta(minutes=1) + ).count() + + # If no active participants and game is finished for more than 5 minutes, delete it + if active_participants == 0 and game.current_state == 'finished': + if game.question_start_time and (timezone.now() - game.question_start_time).total_seconds() > 300: + game.delete() + except QuizGame.DoesNotExist: + pass + + @database_sync_to_async + def get_participants(self): + try: + quiz_game = QuizGame.objects.get(join_code=self.join_code) + participants = QuizGameParticipant.objects.filter(quiz_game=quiz_game) + return [ + { + 'id': p.participant_id, + 'display_name': p.display_name, + 'score': p.score + } + for p in participants + ] + except QuizGame.DoesNotExist: + return [] + + @database_sync_to_async + def _advance_to_next_question(self): + try: + quiz_game = QuizGame.objects.get(join_code=self.join_code) + quiz = quiz_game.quiz_id + + # Move to next question + quiz_game.current_question_index += 1 + + # Check if we've reached the end + if quiz_game.current_question_index >= quiz.questions.count(): + quiz_game.current_state = 'finished' + quiz_game.save() + return 'finished' + else: + # Update game state and start time + quiz_game.current_state = 'question' + quiz_game.question_start_time = timezone.now() + quiz_game.save() + return 'question' + except QuizGame.DoesNotExist: + return None + + async def advance_to_next_question(self): + result = await self._advance_to_next_question() + if result == 'finished': + # Notify clients to redirect to finished page + await self.channel_layer.group_send( + self.game_group_name, + { + 'type': 'game_state_update', + 'action': 'finish_game', + 'redirect_url': reverse('play:finished', kwargs={'join_code': self.join_code}) + } + ) + elif result == 'question': + # Notify clients to redirect to next question + await self.channel_layer.group_send( + self.game_group_name, + { + 'type': 'game_state_update', + 'action': 'next_question', + 'redirect_url': reverse('play:question', kwargs={'join_code': self.join_code}) + } + ) + + @database_sync_to_async + def _show_scores(self): + try: + quiz_game = QuizGame.objects.get(join_code=self.join_code) + quiz_game.current_state = 'scores' + quiz_game.save() + return True + except QuizGame.DoesNotExist: + return False + + async def show_scores(self): + success = await self._show_scores() + if success: + # Notify clients to redirect to scores page + await self.channel_layer.group_send( + self.game_group_name, + { + 'type': 'game_state_update', + 'action': 'show_scores', + 'redirect_url': reverse('play:scores', kwargs={'join_code': self.join_code}) + } + ) + + @database_sync_to_async + def _finish_game(self): + try: + quiz_game = QuizGame.objects.get(join_code=self.join_code) + quiz_game.current_state = 'finished' + quiz_game.save() + return True + except QuizGame.DoesNotExist: + return False + + async def finish_game(self): + success = await self._finish_game() + if success: + # Notify clients to redirect to finished page + await self.channel_layer.group_send( + self.game_group_name, + { + 'type': 'game_state_update', + 'action': 'finish_game', + 'redirect_url': reverse('play:finished', kwargs={'join_code': self.join_code}) + } + ) diff --git a/django/play/consumers/lobby.py b/django/play/consumers/lobby.py new file mode 100644 index 0000000..2add1ca --- /dev/null +++ b/django/play/consumers/lobby.py @@ -0,0 +1,285 @@ +from channels.generic.websocket import AsyncWebsocketConsumer +import json +import asyncio +from ..models import QuizGame, QuizGameParticipant +from channels.db import database_sync_to_async + +# Dictionary to track inactive check tasks per game +game_check_tasks = {} + +class LobbyConsumer(AsyncWebsocketConsumer): + @classmethod + async def start_inactive_check(cls, join_code, channel_layer): + """Start the inactive player check for a game if not already running.""" + if join_code not in game_check_tasks or game_check_tasks[join_code].done(): + async def check_inactive_players(): + room_group_name = f'lobby_{join_code}' + while True: + try: + await asyncio.sleep(30) # Check every 30 seconds + # 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 + active_participants = await database_sync_to_async(lambda: list( + game.participants.filter(last_heartbeat__gte=cutoff_time) + .values_list('display_name', flat=True) + ))() + + # 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 + await channel_layer.group_send( + room_group_name, + { + 'type': 'participants_list_update', + 'participants': active_participants + } + ) + except QuizGame.DoesNotExist: + break # Stop checking if game no longer exists + except Exception as e: + print(f'Error checking inactive players: {e}') + await asyncio.sleep(5) # Wait before retry + except asyncio.CancelledError: + break + except Exception as e: + print(f'Error in inactive check loop: {e}') + await asyncio.sleep(5) + + game_check_tasks[join_code] = asyncio.create_task(check_inactive_players()) + + async def connect(self): + self.join_code = self.scope['url_route']['kwargs']['join_code'] + self.room_group_name = f'lobby_{self.join_code}' + + # Get participant ID from session + self.participant_id = self.scope['session'].get('participant_id') + + # Join room group + await self.channel_layer.group_add( + self.room_group_name, + self.channel_name + ) + + await self.accept() + + # 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( + self.room_group_name, + self.channel_name + ) + + async def receive(self, text_data): + data = json.loads(text_data) + message_type = data.get('type') + print(f'Received message type: {message_type}') + + if message_type == 'ping': + # Respond with pong to keep connection alive + await self.send(text_data=json.dumps({ + 'type': 'pong' + })) + return + + if message_type == 'heartbeat': + 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() + elif message_type in ['leave_game', 'kick_player']: + participant_id = data.get('participant_id') + if participant_id: + try: + # Get participant info before deletion + participant = await database_sync_to_async(QuizGameParticipant.objects.get)(participant_id=participant_id) + display_name = participant.display_name + + # For kick_player, verify that the request comes from the host + if message_type == 'kick_player': + game = await database_sync_to_async(lambda: participant.quiz_game)() + # Get host_id from message + host_id = data.get('host_id') + print(f'Kick attempt - Game host_id: {game.host_id}, Message host_id: {host_id}') + if not host_id: + print('No host_id in message') + return + if str(game.host_id) != str(host_id): + print(f'Host ID mismatch: {game.host_id} != {host_id}') + return + + # Also verify against cookie as backup + cookie_host_id = self.scope.get('cookies', {}).get('host_id') + if not cookie_host_id or str(cookie_host_id) != str(host_id): + print(f'Cookie host_id mismatch: {cookie_host_id} != {host_id}') + return + + # Delete the participant + await database_sync_to_async(participant.delete)() + + # Notify other players + await self.channel_layer.group_send( + self.room_group_name, + { + 'type': 'player_left', + 'player_name': display_name, + 'was_kicked': message_type == 'kick_player', + 'participant_id': participant_id + } + ) + + # Update participants list + await self.update_participants() + + # If it's the kicked player's connection, send them home + if participant_id == self.participant_id: + await self.send(text_data=json.dumps({ + 'type': 'redirect', + 'url': '/' + })) + except QuizGameParticipant.DoesNotExist: + pass + elif message_type == 'update_participants': + await self.update_participants() + elif message_type == 'start_game': + host_id = data.get('host_id') + if host_id: + success = await self.start_game(host_id) + if success: + await self.channel_layer.group_send( + self.room_group_name, + { + 'type': 'game_state_update', + 'action': 'start_game', + 'redirect_url': f'/play/game/{self.join_code}/question' + } + ) + + @database_sync_to_async + def update_participant_heartbeat(self, participant_id): + try: + participant = QuizGameParticipant.objects.get(participant_id=participant_id) + from django.utils import timezone + participant.last_heartbeat = timezone.now() + participant.save() + return True + except QuizGameParticipant.DoesNotExist: + return False + + async def get_participants_list(self): + try: + from django.utils import timezone + 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) + participants = await database_sync_to_async(lambda: list( + game.participants.filter(last_heartbeat__gte=cutoff_time) + .values('participant_id', 'display_name') + ))() + + + # Send join notification for new participants + try: + prev_names = set(p['display_name'] for p in self.last_participants) if hasattr(self, 'last_participants') else set() + current_names = set(p['display_name'] for p in participants) + new_participants = current_names - prev_names + + for name in new_participants: + await self.channel_layer.group_send( + self.room_group_name, + { + 'type': 'player_joined', + 'player_name': name + } + ) + except Exception as e: + print(f'Error sending join notifications: {e}') + + self.last_participants = participants + return participants + except QuizGame.DoesNotExist: + return [] + + async def update_participants(self): + participants = await self.get_participants_list() + await self.channel_layer.group_send( + self.room_group_name, + { + 'type': 'participants_list_update', + 'participants': participants + } + ) + + async def participants_list_update(self, event): + await self.send(text_data=json.dumps({ + 'type': 'participants_list_update', + 'participants': event['participants'] + })) + + @database_sync_to_async + def start_game(self, host_id): + try: + game = QuizGame.objects.get(join_code=self.join_code) + if str(game.host_id) == str(host_id): + from django.utils import timezone + game.current_state = 'question' + game.question_start_time = timezone.now() + game.save() + return True + except QuizGame.DoesNotExist: + pass + return False + + async def game_state_update(self, event): + await self.send(text_data=json.dumps({ + 'type': 'game_state_update', + 'action': event['action'], + 'redirect_url': event.get('redirect_url') + })) + + async def player_joined(self, event): + await self.send(text_data=json.dumps({ + 'type': 'player_joined', + 'player_name': event['player_name'] + })) + + async def player_left(self, event): + message = { + 'type': 'player_left', + 'player_name': event['player_name'], + 'was_kicked': event.get('was_kicked', False) + } + if event.get('was_kicked') and event.get('participant_id'): + message['participant_id'] = event['participant_id'] + await self.send(text_data=json.dumps(message)) + + diff --git a/django/play/migrations/0006_quizgame_current_question_index_and_more.py b/django/play/migrations/0006_quizgame_current_question_index_and_more.py new file mode 100644 index 0000000..693cb23 --- /dev/null +++ b/django/play/migrations/0006_quizgame_current_question_index_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.7 on 2025-04-05 17:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('play', '0005_quizgameparticipant_last_heartbeat'), + ] + + operations = [ + migrations.AddField( + model_name='quizgame', + name='current_question_index', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='quizgame', + name='current_state', + field=models.CharField(choices=[('lobby', 'In Lobby'), ('question', 'Frage läuft'), ('scores', 'Punkteübersicht'), ('finished', 'Beendet')], default='lobby', max_length=20), + ), + migrations.AddField( + model_name='quizgame', + name='question_start_time', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/django/play/migrations/0007_alter_quizgameparticipant_quiz_game.py b/django/play/migrations/0007_alter_quizgameparticipant_quiz_game.py new file mode 100644 index 0000000..ed911fc --- /dev/null +++ b/django/play/migrations/0007_alter_quizgameparticipant_quiz_game.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.7 on 2025-04-05 18:27 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('play', '0006_quizgame_current_question_index_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='quizgameparticipant', + name='quiz_game', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='play.quizgame'), + ), + ] diff --git a/django/play/migrations/0008_quizgameparticipant_last_answer_and_more.py b/django/play/migrations/0008_quizgameparticipant_last_answer_and_more.py new file mode 100644 index 0000000..ab3ad74 --- /dev/null +++ b/django/play/migrations/0008_quizgameparticipant_last_answer_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.7 on 2025-04-05 18:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('play', '0007_alter_quizgameparticipant_quiz_game'), + ] + + operations = [ + migrations.AddField( + model_name='quizgameparticipant', + name='last_answer', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='quizgameparticipant', + name='last_answer_correct', + field=models.BooleanField(blank=True, null=True), + ), + ] diff --git a/django/play/migrations/0009_quizanswer.py b/django/play/migrations/0009_quizanswer.py new file mode 100644 index 0000000..02f8c26 --- /dev/null +++ b/django/play/migrations/0009_quizanswer.py @@ -0,0 +1,29 @@ +# Generated by Django 5.1.7 on 2025-04-05 20:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('play', '0008_quizgameparticipant_last_answer_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='QuizAnswer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('question_index', models.IntegerField()), + ('answer_index', models.IntegerField()), + ('is_correct', models.BooleanField()), + ('score', models.IntegerField()), + ('time_remaining', models.IntegerField()), + ('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='play.quizgameparticipant')), + ], + options={ + 'unique_together': {('participant', 'question_index')}, + }, + ), + ] diff --git a/django/templates/play/game/common_scripts.html b/django/templates/play/game/common_scripts.html new file mode 100644 index 0000000..2c59b37 --- /dev/null +++ b/django/templates/play/game/common_scripts.html @@ -0,0 +1,89 @@ +{% load static %} + + + + diff --git a/django/templates/play/game/finished.html b/django/templates/play/game/finished.html new file mode 100644 index 0000000..b0300e1 --- /dev/null +++ b/django/templates/play/game/finished.html @@ -0,0 +1,201 @@ +{% extends 'base.html' %} +{% load static %} + +{% block content %} +
+
+
+
+

Quiz beendet!

+

Vielen Dank fürs Mitspielen!

+
+ + {% if not is_host %} +
+

Wie hat dir das Quiz gefallen?

+
+ + + + + +
+

+
+ {% endif %} + + +
+
+
+ +{% if not is_host %} +{% include 'play/game/common_scripts.html' %} + + + +{% endif %} +{% endblock %} From 3f07ce4ec75a727af2465e123b0f4024cd99ba09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhn-Vitus=20Sa=C3=9F?= Date: Sun, 6 Apr 2025 15:57:42 +0200 Subject: [PATCH 2/6] Initial Game Logic 2 --- django/library/models.py | 26 +- django/library/views.py | 4 +- django/play/consumers.py | 410 ++++++++++++++++++ django/play/models.py | 31 +- django/play/routing.py | 6 +- django/play/urls.py | 6 +- django/play/views.py | 198 +++++++-- django/templates/base.html | 44 ++ django/templates/library/overview_quiz.html | 24 +- django/templates/play/game/question_host.html | 152 +++++-- .../play/game/question_participant.html | 132 +++++- .../templates/play/game/score_overview.html | 131 +++++- .../play/game/wait_for_other_players.html | 56 ++- .../play/initialize_participant.html | 66 ++- django/templates/play/lobby.html | 371 ++++++++++++++-- 15 files changed, 1506 insertions(+), 151 deletions(-) diff --git a/django/library/models.py b/django/library/models.py index f49d071..ceb2eb5 100644 --- a/django/library/models.py +++ b/django/library/models.py @@ -38,6 +38,8 @@ class QivipQuiz(models.Model): description = models.TextField(validators=[validate_description],max_length=200,blank=False, default="In dem Quiz geht es um ...") difficulty= models.CharField(max_length=13, choices=list(DIFFICULTY_VALUES.items()), default="nicht gesetzt", help_text="1: niedrigste Schwierigkeit und 5: höchste Schwierigkeit") credits=models.TextField(blank=True, editable=True) + average_rating = models.FloatField(default=3.0) + rating_count = models.IntegerField(default=0) def __str__(self): return self.name @@ -47,7 +49,7 @@ class QivipQuiz(models.Model): # Create your models here. class QivipQuestion(models.Model): uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) - quiz_id = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE, related_name='question') + quiz_id = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE, related_name='questions') creation_date = models.DateTimeField(auto_now_add=True) update_date = models.DateTimeField(auto_now=True) data = models.TextField() @@ -61,6 +63,28 @@ class Tag(models.Model): def __str__(self): return self.name +class QuizRating(models.Model): + quiz = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE, related_name='ratings') + participant_id = models.CharField(max_length=200) + rating = models.IntegerField(choices=[(i, str(i)) for i in range(1, 6)]) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('quiz', 'participant_id') + + def save(self, *args, **kwargs): + is_new = self.pk is None + super().save(*args, **kwargs) + + if is_new: + # Update quiz average rating + quiz = self.quiz + total_ratings = quiz.ratings.count() + avg_rating = quiz.ratings.aggregate(models.Avg('rating'))['rating__avg'] + quiz.average_rating = round(avg_rating, 1) if avg_rating else 3.0 + quiz.rating_count = total_ratings + quiz.save() + class QuizCategory(models.Model): name = models.CharField(max_length=100, unique=True) description = models.TextField(blank=True, null=True) diff --git a/django/library/views.py b/django/library/views.py index e05de20..0211216 100644 --- a/django/library/views.py +++ b/django/library/views.py @@ -22,11 +22,11 @@ def overview_quiz(request): try: - filter_my_quizzes = QivipQuiz.objects.filter(user_id=request.user).annotate(max_amout_questions=Count('question')) + filter_my_quizzes = QivipQuiz.objects.filter(user_id=request.user).annotate(max_amout_questions=Count('questions')) except: filter_my_quizzes = QivipQuiz.objects.none() - filter_other_quizzes = QivipQuiz.objects.annotate(max_amout_questions=Count('question')) + filter_other_quizzes = QivipQuiz.objects.annotate(max_amout_questions=Count('questions')) diff --git a/django/play/consumers.py b/django/play/consumers.py index bf44a27..090d975 100644 --- a/django/play/consumers.py +++ b/django/play/consumers.py @@ -4,8 +4,263 @@ from channels.generic.websocket import AsyncWebsocketConsumer from channels.db import database_sync_to_async from django.utils import timezone from datetime import timedelta +from django.urls import reverse from .models import QuizGame, QuizGameParticipant +class GameConsumer(AsyncWebsocketConsumer): + async def connect(self): + self.join_code = self.scope['url_route']['kwargs']['join_code'] + self.game_group_name = f'game_{self.join_code}' + + # Join game group + await self.channel_layer.group_add( + self.game_group_name, + self.channel_name + ) + + await self.accept() + + async def disconnect(self, close_code): + # Leave game group + await self.channel_layer.group_discard( + self.game_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 == 'submit_answer': + participant_id = text_data_json['participant_id'] + answer = text_data_json['answer'] + time_remaining = text_data_json.get('time_remaining', 0) + + # Save answer and update participant score + await self.save_answer(participant_id, answer, time_remaining) + + # Notify host about the new answer + await self.channel_layer.group_send( + self.game_group_name, + { + 'type': 'participant_answer', + 'answer': answer + } + ) + + elif message_type == 'get_answer_stats': + stats = await self.get_answer_stats() + await self.send(text_data=json.dumps({ + 'type': 'answer_stats', + 'stats': stats + })) + + elif message_type == 'update_participants': + participants = await self.get_participants() + await self.channel_layer.group_send( + self.game_group_name, + { + 'type': 'participant_list_update', + 'participants': participants + } + ) + + elif message_type == 'next_question': + host_id = text_data_json['host_id'] + if await self.verify_host(host_id): + await self.advance_to_next_question() + + elif message_type == 'finish_game': + host_id = text_data_json['host_id'] + if await self.verify_host(host_id): + await self.finish_game() + + elif message_type == 'advance_to_scores': + host_id = text_data_json['host_id'] + if await self.verify_host(host_id): + await self.show_scores() + + async def participant_answer(self, event): + await self.send(text_data=json.dumps({ + 'type': 'participant_answer', + 'answer': event['answer'] + })) + + async def participant_list_update(self, event): + await self.send(text_data=json.dumps({ + 'type': 'participant_list_update', + 'participants': event['participants'] + })) + + async def game_state_update(self, event): + await self.send(text_data=json.dumps({ + 'type': 'game_state_update', + 'action': event['action'], + 'redirect_url': event.get('redirect_url') + })) + + @database_sync_to_async + def verify_host(self, host_id): + try: + quiz_game = QuizGame.objects.get(join_code=self.join_code) + return quiz_game.host_id == host_id + except QuizGame.DoesNotExist: + return False + + @database_sync_to_async + def save_answer(self, participant_id, answer_index, time_remaining): + try: + participant = QuizGameParticipant.objects.get( + quiz_game__join_code=self.join_code, + participant_id=participant_id + ) + quiz_game = participant.quiz_game + current_question = quiz_game.quiz_id.questions.all()[quiz_game.current_question_index] + + # Check if answer is correct + is_correct = False + if answer_index >= 0: # -1 means timeout + is_correct = current_question.data['options'][answer_index]['is_correct'] + + # Calculate score based on correctness and time + score = 0 + if is_correct: + base_score = 1000 + time_factor = time_remaining / 30000 # 30 seconds max + score = int(base_score * (0.5 + 0.5 * time_factor)) + + participant.score += score + participant.last_answer_correct = is_correct + participant.save() + + return True + except (QuizGameParticipant.DoesNotExist, IndexError): + return False + + @database_sync_to_async + def get_answer_stats(self): + try: + quiz_game = QuizGame.objects.get(join_code=self.join_code) + participants = QuizGameParticipant.objects.filter(quiz_game=quiz_game) + + # Count answers for each option + stats = {} + for participant in participants: + if participant.last_answer is not None: + stats[participant.last_answer] = stats.get(participant.last_answer, 0) + 1 + + return stats + except QuizGame.DoesNotExist: + return {} + + @database_sync_to_async + def get_participants(self): + try: + quiz_game = QuizGame.objects.get(join_code=self.join_code) + participants = QuizGameParticipant.objects.filter(quiz_game=quiz_game) + return [ + { + 'id': p.participant_id, + 'display_name': p.display_name, + 'score': p.score + } + for p in participants + ] + except QuizGame.DoesNotExist: + return [] + + @database_sync_to_async + def _advance_to_next_question(self): + try: + quiz_game = QuizGame.objects.get(join_code=self.join_code) + quiz = quiz_game.quiz_id + + # Move to next question + quiz_game.current_question_index += 1 + + # Check if we've reached the end + if quiz_game.current_question_index >= quiz.questions.count(): + quiz_game.current_state = 'finished' + quiz_game.save() + return 'finished' + else: + # Update game state and start time + quiz_game.current_state = 'question' + quiz_game.question_start_time = timezone.now() + quiz_game.save() + return 'question' + except QuizGame.DoesNotExist: + return None + + async def advance_to_next_question(self): + result = await self._advance_to_next_question() + if result == 'finished': + # Notify clients to redirect to finished page + await self.channel_layer.group_send( + self.game_group_name, + { + 'type': 'game_state_update', + 'action': 'finish_game', + 'redirect_url': reverse('play:finished', kwargs={'join_code': self.join_code}) + } + ) + elif result == 'question': + # Notify clients to redirect to next question + await self.channel_layer.group_send( + self.game_group_name, + { + 'type': 'game_state_update', + 'action': 'next_question', + 'redirect_url': reverse('play:question', kwargs={'join_code': self.join_code}) + } + ) + + @database_sync_to_async + def _show_scores(self): + try: + quiz_game = QuizGame.objects.get(join_code=self.join_code) + quiz_game.current_state = 'scores' + quiz_game.save() + return True + except QuizGame.DoesNotExist: + return False + + async def show_scores(self): + success = await self._show_scores() + if success: + # Notify clients to redirect to scores page + await self.channel_layer.group_send( + self.game_group_name, + { + 'type': 'game_state_update', + 'action': 'show_scores', + 'redirect_url': reverse('play:scores', kwargs={'join_code': self.join_code}) + } + ) + + @database_sync_to_async + def _finish_game(self): + try: + quiz_game = QuizGame.objects.get(join_code=self.join_code) + quiz_game.current_state = 'finished' + quiz_game.save() + return True + except QuizGame.DoesNotExist: + return False + + async def finish_game(self): + success = await self._finish_game() + if success: + # Notify clients to redirect to finished page + await self.channel_layer.group_send( + self.game_group_name, + { + 'type': 'game_state_update', + 'action': 'finish_game', + 'redirect_url': reverse('play:finished', kwargs={'join_code': self.join_code}) + } + ) + class LobbyConsumer(AsyncWebsocketConsumer): async def connect(self): self.join_code = self.scope['url_route']['kwargs']['join_code'] @@ -63,6 +318,96 @@ class LobbyConsumer(AsyncWebsocketConsumer): 'participants': participants } ) + + elif message_type == 'start_game': + # Verify sender is host + sender_id = text_data_json.get('host_id') + if await self._is_host(sender_id): + # Update game state and notify all participants + await self._start_game() + await self.channel_layer.group_send( + self.room_group_name, + { + 'type': 'game_state_update', + 'action': 'start_game', + 'redirect_url': f'/play/game/{self.join_code}/0' + } + ) + + async def game_state_update(self, event): + # Send game state update to WebSocket + await self.send(text_data=json.dumps({ + 'type': 'game_state_update', + 'action': event['action'], + 'redirect_url': event.get('redirect_url') + })) + + @database_sync_to_async + def _start_game(self): + game = QuizGame.objects.get(join_code=self.join_code) + game.current_state = 'question' + game.current_question_index = 0 + game.question_start_time = timezone.now() + game.save() + + 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 + } + ) + + elif message_type == 'start_game': + # Verify sender is host + sender_id = text_data_json.get('host_id') + if await self._is_host(sender_id): + # Update game state and notify all participants + await self._start_game() + await self.channel_layer.group_send( + self.room_group_name, + { + 'type': 'game_state_update', + 'action': 'start_game', + 'redirect_url': f'/play/game/{self.join_code}/0' + } + ) + + elif message_type == 'submit_answer': + participant_id = text_data_json.get('participant_id') + answer = text_data_json.get('answer') + time_remaining = text_data_json.get('time_remaining') + if participant_id: + score = await self._process_answer(participant_id, answer, time_remaining) + await self.send(text_data=json.dumps({ + 'type': 'answer_processed', + 'score': score + })) + + elif message_type == 'next_question': + sender_id = text_data_json.get('host_id') + if await self._is_host(sender_id): + next_state = await self._advance_game_state() + await self.channel_layer.group_send( + self.room_group_name, + { + 'type': 'game_state_update', + 'action': next_state['action'], + 'redirect_url': next_state['redirect_url'] + } + ) async def participant_list_update(self, event): participants = event['participants'] @@ -84,6 +429,71 @@ class LobbyConsumer(AsyncWebsocketConsumer): participant.save() # This will update last_heartbeat due to auto_now=True except QuizGameParticipant.DoesNotExist: pass + + @database_sync_to_async + def _is_host(self, host_id): + try: + return QuizGame.objects.filter(join_code=self.join_code, host_id=host_id).exists() + except QuizGame.DoesNotExist: + return False + + @database_sync_to_async + def _start_game(self): + game = QuizGame.objects.get(join_code=self.join_code) + game.current_state = 'question' + game.current_question_index = 0 + game.question_start_time = timezone.now() + game.save() + + @database_sync_to_async + def _process_answer(self, participant_id, answer, time_remaining): + game = QuizGame.objects.get(join_code=self.join_code) + participant = QuizGameParticipant.objects.get(participant_id=participant_id) + question = game.quiz_id.questions.all()[game.current_question_index] + + # Load question data + question_data = json.loads(question.data) + correct_answer = question_data.get('correct_answer') + + # Calculate score based on correctness and time + score = 0 + if answer == correct_answer: + # Base score for correct answer + bonus for speed + score = 1000 + int(time_remaining * 10) # 10 points per remaining second + + participant.score += score + participant.save() + return score + + @database_sync_to_async + def _advance_game_state(self): + game = QuizGame.objects.get(join_code=self.join_code) + total_questions = game.quiz_id.questions.count() + + if game.current_state == 'question': + game.current_state = 'scores' + game.save() + return { + 'action': 'show_scores', + 'redirect_url': f'/play/game/{self.join_code}/scores' + } + elif game.current_state == 'scores': + if game.current_question_index + 1 < total_questions: + game.current_state = 'question' + game.current_question_index += 1 + game.question_start_time = timezone.now() + game.save() + return { + 'action': 'next_question', + 'redirect_url': f'/play/game/{self.join_code}/{game.current_question_index}' + } + else: + game.current_state = 'finished' + game.save() + return { + 'action': 'game_finished', + 'redirect_url': f'/play/game/{self.join_code}/finished' + } @database_sync_to_async def _remove_inactive_participants(self): diff --git a/django/play/models.py b/django/play/models.py index e3e0a72..adf9fee 100644 --- a/django/play/models.py +++ b/django/play/models.py @@ -6,9 +6,19 @@ import random, string # Create your models here. class QuizGame(models.Model): + GAME_STATES = [ + ('lobby', 'In Lobby'), + ('question', 'Frage läuft'), + ('scores', 'Punkteübersicht'), + ('finished', 'Beendet') + ] + 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) + current_state = models.CharField(max_length=20, choices=GAME_STATES, default='lobby') + current_question_index = models.IntegerField(default=0) + question_start_time = models.DateTimeField(null=True, blank=True) def save(self, *args, **kwargs): if not self.join_code: @@ -30,15 +40,34 @@ class QuizGame(models.Model): 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") + 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_answer = models.IntegerField(null=True, blank=True) + last_answer_correct = models.BooleanField(null=True, blank=True) def save(self, *args, **kwargs): if not self.participant_id: self.participant_id = self.generate_unique_id() super().save(*args, **kwargs) + + 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 QuizAnswer(models.Model): + participant = models.ForeignKey(QuizGameParticipant, on_delete=models.CASCADE, related_name='answers') + question_index = models.IntegerField() + answer_index = models.IntegerField() + is_correct = models.BooleanField() + score = models.IntegerField() + time_remaining = models.IntegerField() + + class Meta: + unique_together = ['participant', 'question_index'] def generate_unique_id(self): for i in range(10): diff --git a/django/play/routing.py b/django/play/routing.py index 8eaabe6..59f7b46 100644 --- a/django/play/routing.py +++ b/django/play/routing.py @@ -1,6 +1,8 @@ from django.urls import re_path -from . import consumers +from .consumers.game import GameConsumer +from .consumers.lobby import LobbyConsumer websocket_urlpatterns = [ - re_path(r'ws/game/(?P\w+)/$', consumers.LobbyConsumer.as_asgi()), + re_path(r'ws/game/(?P\w+)/$', GameConsumer.as_asgi()), + re_path(r'ws/play/lobby/(?P\w+)/$', LobbyConsumer.as_asgi()), ] diff --git a/django/play/urls.py b/django/play/urls.py index 8a9e692..81f59e8 100644 --- a/django/play/urls.py +++ b/django/play/urls.py @@ -8,6 +8,8 @@ urlpatterns = [ 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//question', views.question, name='question'), + path('game//waiting', views.waiting_room, name='waiting'), + path('game//scores', views.scores, name='scores'), + path('game//finished', views.finished, name='finished'), ] \ No newline at end of file diff --git a/django/play/views.py b/django/play/views.py index 4a04bda..8feb7a8 100644 --- a/django/play/views.py +++ b/django/play/views.py @@ -29,6 +29,7 @@ def lobby(request, join_code): 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.score = 0 # Reset score when joining new game participant.save() except QuizGameParticipant.DoesNotExist: return redirect('play:create_participant', join_code=join_code) @@ -37,34 +38,41 @@ def lobby(request, join_code): return render(request, 'play/lobby.html', context=context) def create_participant(request, join_code): + quiz_game = get_object_or_404(QuizGame, 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) + if participant.quiz_game.join_code != join_code: + participant.quiz_game = quiz_game + participant.score = 0 # Reset score when joining new game + participant.save() 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') - try: - quiz = QuizGame.objects.get(join_code=join_code) - participant = QuizGameParticipant() - participant.display_name = display_name - participant.quiz_game = quiz - participant.save() + if not display_name: + return render(request, 'play/initialize_participant.html', { + 'join_code': join_code, + 'error': 'Bitte geben Sie einen Namen ein' + }) - response = HttpResponseRedirect(reverse('play:lobby', kwargs={'join_code': join_code})) - response.set_cookie('participant_id', participant.participant_id, max_age=3600) + participant = QuizGameParticipant() + participant.display_name = display_name + participant.quiz_game = quiz_game + participant.score = 0 # Initialize score + participant.save() - return response - except QuizGame.DoesNotExist: - # TODO: Fehlermeldung fuer nicht-existierendes Quiz - pass + response = HttpResponseRedirect(reverse('play:lobby', kwargs={'join_code': join_code})) + response.set_cookie('participant_id', participant.participant_id, max_age=3600) + return response return render(request, 'play/initialize_participant.html', {'join_code': join_code}) @@ -89,32 +97,162 @@ def join_game(request): join_code = request.POST.get('game_code') try: quiz = QuizGame.objects.get(join_code=join_code) + # Redirect to create_participant if no participant cookie exists + if not "participant_id" in request.COOKIES: + return redirect('play:create_participant', join_code=join_code) return redirect('play:lobby', join_code=join_code) except QuizGame.DoesNotExist: # TODO: Mit message eine Fehlermeldung weitergeben (und im entsprechenden Template Designen) pass return render(request, 'play/join_game.html') -def question(request, join_code, question_id): - question = get_object_or_404(QivipQuestion, pk=question_id) +def finished(request, join_code): + try: + game = QuizGame.objects.get(join_code=join_code) + participant = None + participant_id = request.session.get('participant_id') + if participant_id: + participant = QuizGameParticipant.objects.filter( + quiz_game=game, + participant_id=participant_id + ).first() - if question.data: - question.data = json.loads(question.data) + context = { + 'join_code': join_code, + 'is_host': str(game.host_id) == str(request.session.get('host_id')), + 'participant_id': participant_id if participant else None + } + return render(request, 'play/game/finished.html', context) + except QuizGame.DoesNotExist: + return redirect('home') - context = { - 'question': question, - } +def scores(request, join_code): + quiz_game = get_object_or_404(QuizGame, join_code=join_code) + + # Verify game state + if quiz_game.current_state != 'scores': + return redirect('play:lobby', join_code=join_code) + + # Get participants sorted by score + participants = QuizGameParticipant.objects.filter(quiz_game=quiz_game).order_by('-score') + + # Check if user is host + is_host = False + if 'host_id' in request.COOKIES and request.COOKIES['host_id'] == quiz_game.host_id: + is_host = True + + # Get current question for context + questions = quiz_game.quiz_id.questions.all() + current_question = questions[quiz_game.current_question_index] + question_data = json.loads(current_question.data) + + return render(request, 'play/game/score_overview.html', { + 'quiz_game': quiz_game, + 'participants': participants, + 'is_host': is_host, + 'host_id': quiz_game.host_id if is_host else None, + 'is_last_question': quiz_game.current_question_index + 1 >= len(questions), + 'question_data': question_data, + 'question_index': quiz_game.current_question_index, + 'total_questions': len(questions) + }) - return render(request, 'play/game/question_host.html', {'question': question, 'debug': "debug"}) +def finished(request, join_code): + quiz_game = get_object_or_404(QuizGame, join_code=join_code) + + # Verify game state + if quiz_game.current_state != 'finished': + return redirect('play:lobby', join_code=join_code) + + # Get participants sorted by score + participants = QuizGameParticipant.objects.filter(quiz_game=quiz_game).order_by('-score') + + # Check if user is host + is_host = False + if 'host_id' in request.COOKIES and request.COOKIES['host_id'] == quiz_game.host_id: + is_host = True -def question_participant(request, join_code, question_id): - question = get_object_or_404(QivipQuestion, pk=question_id) + # Get participant if exists + participant_id = request.COOKIES.get('participant_id') + participant = None + if participant_id: + participant = QuizGameParticipant.objects.filter( + quiz_game=quiz_game, + participant_id=participant_id + ).first() + + return render(request, 'play/game/finished.html', { + 'quiz_game': quiz_game, + 'participants': participants, + 'winner': participants.first() if participants.exists() else None, + 'join_code': join_code, + 'is_host': is_host, + 'participant_id': participant_id if participant else None + }) - if question.data: - question.data = json.loads(question.data) +def question(request, join_code): + quiz_game = get_object_or_404(QuizGame, join_code=join_code) + + # Verify game state + if quiz_game.current_state != 'question': + return redirect('play:lobby', join_code=join_code) + + # Get current question + questions = quiz_game.quiz_id.questions.all() + if quiz_game.current_question_index >= len(questions): + return redirect('play:lobby', join_code=join_code) + + current_question = questions[quiz_game.current_question_index] + question_data = json.loads(current_question.data) + + # Check if user is host + if 'host_id' in request.COOKIES and request.COOKIES['host_id'] == quiz_game.host_id: + return render(request, 'play/game/question_host.html', { + 'quiz_game': quiz_game, + 'question_data': question_data, + 'host_id': quiz_game.host_id, + 'start_time': int(quiz_game.question_start_time.timestamp() * 1000), + 'question_index': quiz_game.current_question_index, + 'total_questions': len(questions) + }) + + # Handle participant view + 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) + except QuizGameParticipant.DoesNotExist: + return redirect('play:create_participant', join_code=join_code) + + return render(request, 'play/game/question_participant.html', { + 'quiz_game': quiz_game, + 'question_data': question_data, + 'participant': participant, + 'start_time': int(quiz_game.question_start_time.timestamp() * 1000), + 'question_index': quiz_game.current_question_index, + 'total_questions': len(questions) + }) - context = { - 'question': question, - } - - return render(request, 'play/game/question_participant.html', {'question': question, 'debug': "debug"}) \ No newline at end of file +def waiting_room(request, join_code): + quiz_game = get_object_or_404(QuizGame, join_code=join_code) + + # Check if user is host + if 'host_id' in request.COOKIES and request.COOKIES['host_id'] == quiz_game.host_id: + return redirect('play:question', join_code=join_code) + + # Handle participant view + 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) + except QuizGameParticipant.DoesNotExist: + return redirect('play:create_participant', join_code=join_code) + + return render(request, 'play/game/wait_for_other_players.html', { + 'quiz_game': quiz_game, + 'participant': participant + }) \ No newline at end of file diff --git a/django/templates/base.html b/django/templates/base.html index 9340c71..3ec89de 100644 --- a/django/templates/base.html +++ b/django/templates/base.html @@ -6,12 +6,56 @@ + qivip {% include 'partials/_nav.html' %} {% block content%}{% endblock %} {% include 'partials/_footer.html' %} + + +
+ + + {% block extra_js %}{% endblock %} \ No newline at end of file diff --git a/django/templates/library/overview_quiz.html b/django/templates/library/overview_quiz.html index d64a02c..9278100 100644 --- a/django/templates/library/overview_quiz.html +++ b/django/templates/library/overview_quiz.html @@ -57,17 +57,27 @@
{% for quiz in filter_my_quizzes %}
-

{{ quiz.name }}

-

+

{{ quiz.description }}
Schwierigkeit: {{ quiz.difficulty }}
- Anzahl der Fragen: {{ quiz.question.count }}
- Status: {{ quiz.status }} + 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 }}) +

@@ -85,14 +95,14 @@

- Spiel starten + Spiel starten
- + - + diff --git a/django/templates/play/game/question_host.html b/django/templates/play/game/question_host.html index c3c78bf..7a4f17c 100644 --- a/django/templates/play/game/question_host.html +++ b/django/templates/play/game/question_host.html @@ -1,35 +1,129 @@ -{% load static %} - - - - - - - qivip - - -
-
-

Frage X

- -

Frage: {{ question.data.question }}

-
-
-

Verbleibende Zeit:

-

36

-
-
-

7/14 Antworten

-
+{% extends 'base.html' %} + +{% block content %} +
+
+

Frage {{ question_index|add:1 }} von {{ total_questions }}

+

{{ question_data.question }}

+ +
+
+

Verbleibende Zeit

+

30

+
+
+

Antworten

+

0/0

-
- {% for option in question.data.options %} -
-

{{ option.value }}

+ +
+ {% for option in question_data.options %} +
+

{{ option.value }}

+
{% endfor %}
- - \ No newline at end of file +
+{% endblock %} + +{% block extra_js %} +{% include 'play/game/common_scripts.html' %} + +{% endblock %} \ No newline at end of file diff --git a/django/templates/play/game/question_participant.html b/django/templates/play/game/question_participant.html index 0449a28..2caf10d 100644 --- a/django/templates/play/game/question_participant.html +++ b/django/templates/play/game/question_participant.html @@ -1,19 +1,115 @@ -{% load static %} - - - - - - - qivip - - -
- {% for option in question.data.options %} -
-

{{ option.value }}

-
- {% endfor %} +{% extends 'base.html' %} + +{% block content %} +
+
+
- - \ No newline at end of file +
+

Frage {{ question_index|add:1 }} von {{ total_questions }}

+

{{ question_data.question }}

+ +
+

Verbleibende Zeit

+

30

+
+ +
+ {% for option in question_data.options %} + + {% endfor %} +
+
+
+{% endblock %} + +{% block extra_js %} +{% include 'play/game/common_scripts.html' %} + +{% endblock %} \ No newline at end of file diff --git a/django/templates/play/game/score_overview.html b/django/templates/play/game/score_overview.html index f90e7b5..ae201ac 100644 --- a/django/templates/play/game/score_overview.html +++ b/django/templates/play/game/score_overview.html @@ -1,5 +1,128 @@ - - Spieler sehen ihre Punktzahl, Platzierung und Richtig/Falsch +{% extends 'base.html' %} - Host: Scoreboard, Button: 'Weiter' - \ No newline at end of file +{% block content %} +
+
+

Ergebnisse

+ +
+

Aktuelle Frage

+

{{ question_data.question }}

+
+ {% for option in question_data.options %} +
+

{{ option.value }}

+

0 Antworten

+
+ {% endfor %} +
+
+ +
+

Punktestand

+
+ {% for participant in participants %} +
+
+

{{ participant.display_name }}

+

{{ participant.score }} Punkte

+
+ {% if participant.last_answer_correct %} + + {% elif participant.last_answer_correct == False %} + + {% endif %} +
+ {% endfor %} +
+
+ + {% if is_host %} + {% if is_last_question %} + + {% else %} + + {% endif %} + {% endif %} +
+
+{% endblock %} + +{% block extra_js %} +{% include 'play/game/common_scripts.html' %} + +{% endblock %} \ No newline at end of file diff --git a/django/templates/play/game/wait_for_other_players.html b/django/templates/play/game/wait_for_other_players.html index 8094ed8..9d18009 100644 --- a/django/templates/play/game/wait_for_other_players.html +++ b/django/templates/play/game/wait_for_other_players.html @@ -1,5 +1,53 @@ - - Spieler: Countdown, automatische Weiterleitung +{% extends 'base.html' %} - (Host: Frage) - \ No newline at end of file +{% block content %} +
+
+

Warte auf andere Spieler...

+ +
+

Die nächste Frage beginnt in

+

5

+
+ +
+

Bitte warte, bis alle Spieler bereit sind

+
+
+
+{% endblock %} + +{% block extra_js %} +{% include 'play/game/common_scripts.html' %} + +{% endblock %} \ No newline at end of file diff --git a/django/templates/play/initialize_participant.html b/django/templates/play/initialize_participant.html index 2bc4acc..9e92f51 100644 --- a/django/templates/play/initialize_participant.html +++ b/django/templates/play/initialize_participant.html @@ -1,13 +1,63 @@ {% extends 'base.html' %} +{% load static %} {% block content %} -

User for a Game:

-
-
- {% csrf_token %} - - - -
+
+ + {% endblock %} \ No newline at end of file diff --git a/django/templates/play/lobby.html b/django/templates/play/lobby.html index ff188de..29c6922 100644 --- a/django/templates/play/lobby.html +++ b/django/templates/play/lobby.html @@ -1,23 +1,62 @@ {% extends 'base.html' %} {% block content %} -
-

{{ quiz.name }}

-

{{ quiz_game.join_code }}

- {% if participant %} -
-

Sichtbar als {{ participant.display_name }}

+
+ +
+ +
+

{{ quiz.name }}

+
+

{{ quiz_game.join_code }}

+

Spiel-Code

+
+
+ + + {% if participant %} +
+
+ + Angemeldet als {{ participant.display_name }} +
+
+ {% endif %} + + +
+

+ + + + Teilnehmer +

+
    +
  • Warte auf Teilnehmer...
  • +
+
+ + +
+ {% if host_id %} + + {% endif %} + {% if participant %} + + {% endif %} +
- {% endif %} -
-

Teilnehmer:

-
    -
  • Warte auf Teilnehmer...
  • -
-
- {% if host_id %} - - {% endif %}
{% endblock %} @@ -25,50 +64,296 @@ {% endblock %} \ No newline at end of file From cf7a2fb8bbc4906927ccdbab6dd889970e239391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhn-Vitus=20Sa=C3=9F?= Date: Sun, 6 Apr 2025 19:49:46 +0200 Subject: [PATCH 3/6] UI Refactoring and Fix of image issue when adding an image to a quiz --- django/core/settings.py | 3 + django/library/views.py | 48 +- django/play/consumers/lobby.py | 59 +- ...lter_quizgameparticipant_last_heartbeat.py | 18 + django/play/models.py | 2 +- django/templates/library/overview_quiz.html | 578 +++++++++++------- django/templates/play/lobby.html | 43 +- 7 files changed, 473 insertions(+), 278 deletions(-) create mode 100644 django/play/migrations/0010_alter_quizgameparticipant_last_heartbeat.py 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 %} - - - - -
-
- 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(); From d9f3d07dd7b1964607867fd8c2e4205b67646cee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhn-Vitus=20Sa=C3=9F?= Date: Sun, 6 Apr 2025 20:18:30 +0200 Subject: [PATCH 4/6] Filter fix --- django/static/css/t-input.css | 9 ++++++ django/templates/library/overview_quiz.html | 36 +++++++-------------- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/django/static/css/t-input.css b/django/static/css/t-input.css index 002f4e0..5dbd930 100644 --- a/django/static/css/t-input.css +++ b/django/static/css/t-input.css @@ -101,6 +101,15 @@ div.qp-answer-container-host div p { @apply font-black text-xl p-4; } +/* Filter Panel Styles */ +#filterPanel { + @apply hidden transition-all duration-300 ease-in-out; +} + +#filterPanel.show { + @apply block; +} + div.qp-question-container { @apply text-center text-3xl px-4 py-9 h-screen; } diff --git a/django/templates/library/overview_quiz.html b/django/templates/library/overview_quiz.html index 10d9a5e..955cb27 100644 --- a/django/templates/library/overview_quiz.html +++ b/django/templates/library/overview_quiz.html @@ -18,18 +18,6 @@ align-items: center; } - #filterPanel { - transition: all 0.3s ease-in-out; - max-height: 0; - overflow: hidden; - opacity: 0; - } - - #filterPanel.show { - max-height: 500px; - opacity: 1; - } - .custom-outline { -webkit-text-stroke: 0.2px rgb(0, 0, 0); } @@ -39,7 +27,7 @@ {% block content %}
-
-
+
@@ -88,18 +76,8 @@
- +{% if user.is_authenticated %}

Eigene Quiz

@@ -248,6 +226,7 @@ document.addEventListener('DOMContentLoaded', function() {
{% endif %}
+{% endif %}
@@ -389,4 +368,11 @@ document.addEventListener('DOMContentLoaded', function() {

Keine öffentlichen Quiz gefunden.

{% endif %} + {% endblock content %} From 95f2a8253293e59b93c044005a3267114ff84b24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhn-Vitus=20Sa=C3=9F?= Date: Sun, 6 Apr 2025 22:15:10 +0200 Subject: [PATCH 5/6] Exclude t-input as it causes problems when deployed --- django/core/settings.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/django/core/settings.py b/django/core/settings.py index f8244b3..d9525dd 100644 --- a/django/core/settings.py +++ b/django/core/settings.py @@ -142,6 +142,9 @@ STATIC_URL = '/static/' STATIC_ROOT = BASE_DIR / "staticfiles" STATICFILES_DIRS = [BASE_DIR / 'static'] +# Exclude development CSS files from collectstatic +STATICFILES_IGNORE_PATTERNS = ['t-input.css'] + # Default primary key field type # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field From f264d10cb844167eaf1c538080a9a2faaccea2b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juhn-Vitus=20Sa=C3=9F?= Date: Sun, 6 Apr 2025 22:24:15 +0200 Subject: [PATCH 6/6] Ignore Missing Files --- django/core/settings.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/django/core/settings.py b/django/core/settings.py index d9525dd..162b0f8 100644 --- a/django/core/settings.py +++ b/django/core/settings.py @@ -142,8 +142,11 @@ STATIC_URL = '/static/' STATIC_ROOT = BASE_DIR / "staticfiles" STATICFILES_DIRS = [BASE_DIR / 'static'] -# Exclude development CSS files from collectstatic -STATICFILES_IGNORE_PATTERNS = ['t-input.css'] +# WhiteNoise configuration +STATICFILES_STORAGE = 'whitenoise.storage.CompressedStaticFilesStorage' +WHITENOISE_SKIP_COMPRESS_EXTENSIONS = ['css', 'js'] +# Return 404 instead of 500 for missing files +WHITENOISE_MISSING_FILE_ERRNO = None # Default primary key field type # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field