Merge branch '104-kein-rauskicken-nach-30-sekunden' into 'master'

Resolve "kein rauskicken nach 30 Sekunden"

Closes #104 and #105

See merge request enrichment-2024/qivip!53
This commit is contained in:
Juhn-Vitus Saß
2025-04-21 11:36:28 +00:00
6 changed files with 46 additions and 170 deletions

View File

@@ -84,6 +84,13 @@ Für den Produktivbetrieb verwenden wir Daphne als ASGI-Server, der sowohl HTTP
- `-b 0.0.0.0`: Bindet den Server an alle Netzwerk-Interfaces - `-b 0.0.0.0`: Bindet den Server an alle Netzwerk-Interfaces
- `-p 8000`: Port (anpassbar) - `-p 8000`: Port (anpassbar)
4. Cronjob für Bereinigung der Spiele in der Datenbank
- Zum bearbeiten der Cronjobs:
```sh
crontab -e
```
- Dann `0 0 * * * /path/to/venv/bin/python /path/to/your_project/manage.py cleanup_games` einfügen, damit um Mitternacht alle Spiele gelöscht werden
Damit ist das Projekt erfolgreich eingerichtet und der Server kann gestartet werden! Damit ist das Projekt erfolgreich eingerichtet und der Server kann gestartet werden!
## Superuser erstellen ## Superuser erstellen

View File

@@ -0,0 +1,13 @@
from django.core.management.base import BaseCommand
from play.models import QuizGame
from django.utils import timezone
class Command(BaseCommand):
help = 'Bereinigt beendete Spiele'
def handle(self, *args, **kwargs):
expired_games = QuizGame.objects.filter(
updated_at__lt=timezone.now() - timezone.timedelta(minutes=5))
deleted_count = expired_games.count()
expired_games.delete()
self.stdout.write(f'{deleted_count} alte Spiele gelöscht.')

View File

@@ -9,75 +9,7 @@ from datetime import timedelta
from play.models import QuizGame, QuizGameParticipant, QuizAnswer from play.models import QuizGame, QuizGameParticipant, QuizAnswer
# Dictionary to track inactive check tasks per game
game_check_tasks = {}
class GameConsumer(AsyncWebsocketConsumer): 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): async def connect(self):
self.join_code = self.scope['url_route']['kwargs']['join_code'] self.join_code = self.scope['url_route']['kwargs']['join_code']
@@ -91,9 +23,6 @@ class GameConsumer(AsyncWebsocketConsumer):
await self.accept() 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): async def disconnect(self, close_code):
# Leave game group # Leave game group
await self.channel_layer.group_discard( await self.channel_layer.group_discard(
@@ -146,21 +75,6 @@ class GameConsumer(AsyncWebsocketConsumer):
pass pass
return 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': if message_type == 'submit_rating':
participant_id = text_data_json['participant_id'] participant_id = text_data_json['participant_id']
rating = text_data_json['rating'] rating = text_data_json['rating']
@@ -250,17 +164,6 @@ class GameConsumer(AsyncWebsocketConsumer):
'redirect_url': event.get('redirect_url') '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 @database_sync_to_async
def verify_host(self, host_id): def verify_host(self, host_id):
try: try:
@@ -369,20 +272,16 @@ class GameConsumer(AsyncWebsocketConsumer):
try: try:
game = QuizGame.objects.get(join_code=self.join_code) 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( active_participants = QuizGameParticipant.objects.filter(
quiz_game=game, quiz_game=game
last_heartbeat__gte=timezone.now() - timezone.timedelta(minutes=1)
).count() ).count()
# If no active participants and game is finished for more than 5 minutes, delete it if active_participants == 0:
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() game.delete()
except QuizGame.DoesNotExist: except QuizGame.DoesNotExist:
pass pass
@database_sync_to_async @database_sync_to_async
def get_participants(self): def get_participants(self):
try: try:

View File

@@ -9,47 +9,6 @@ game_check_tasks = {}
game_connections = {} game_connections = {}
class LobbyConsumer(AsyncWebsocketConsumer): 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(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(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('participant_id', 'display_name')
))()
# Send 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): async def connect(self):
self.join_code = self.scope['url_route']['kwargs']['join_code'] self.join_code = self.scope['url_route']['kwargs']['join_code']
@@ -63,9 +22,6 @@ class LobbyConsumer(AsyncWebsocketConsumer):
game_connections[self.join_code] = set() game_connections[self.join_code] = set()
game_connections[self.join_code].add(self.channel_name) 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 # Join room group
await self.channel_layer.group_add( await self.channel_layer.group_add(
self.room_group_name, self.room_group_name,
@@ -107,11 +63,6 @@ class LobbyConsumer(AsyncWebsocketConsumer):
})) }))
return return
if message_type == 'heartbeat':
participant_id = data.get('participant_id')
if participant_id:
await self.update_participant_heartbeat(participant_id)
# Don't update participants list after heartbeat - let the background task handle it
elif message_type in ['leave_game', 'kick_player']: elif message_type in ['leave_game', 'kick_player']:
participant_id = data.get('participant_id') participant_id = data.get('participant_id')
if participant_id: if participant_id:
@@ -180,26 +131,13 @@ class LobbyConsumer(AsyncWebsocketConsumer):
} }
) )
@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): async def get_participants_list(self):
try: try:
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
game = await database_sync_to_async(QuizGame.objects.get)(join_code=self.join_code) 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(seconds=20) # Consider inactive after 20 seconds (2 missed heartbeats)
participants = await database_sync_to_async(lambda: list( participants = await database_sync_to_async(lambda: list(
game.participants.filter(last_heartbeat__gte=cutoff_time) game.participants
.values('participant_id', 'display_name') .values('participant_id', 'display_name')
))() ))()

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-04-21 11:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('play', '0010_alter_quizgameparticipant_last_heartbeat'),
]
operations = [
migrations.AddField(
model_name='quizgame',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

@@ -19,6 +19,7 @@ class QuizGame(models.Model):
current_state = models.CharField(max_length=20, choices=GAME_STATES, default='lobby') current_state = models.CharField(max_length=20, choices=GAME_STATES, default='lobby')
current_question_index = models.IntegerField(default=0) current_question_index = models.IntegerField(default=0)
question_start_time = models.DateTimeField(null=True, blank=True) question_start_time = models.DateTimeField(null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.join_code: if not self.join_code: