Compare commits
9 Commits
44-zurueck
...
91-bilder-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d8e211c72 | ||
|
|
6da095c35e | ||
|
|
2dc9a077ee | ||
|
|
881bc3d6c1 | ||
|
|
b1e438229a | ||
|
|
97e4fa9505 | ||
|
|
3981dc31da | ||
|
|
7b86b0791c | ||
|
|
7faf931fb2 |
20
README.md
20
README.md
@@ -61,6 +61,13 @@ Für die Entwicklung mit WebSocket-Unterstützung verwenden wir Daphne:
|
|||||||
daphne -b 127.0.0.1 -p 8000 core.asgi:application
|
daphne -b 127.0.0.1 -p 8000 core.asgi:application
|
||||||
```
|
```
|
||||||
|
|
||||||
|
3. Starte den REDIS Server
|
||||||
|
```sh
|
||||||
|
redis-server
|
||||||
|
```
|
||||||
|
> **Hinweis:** Der Redis Server muss, wenn er nicht auf dem selben Gerät unter Standardeinstellungen läuft, in der Datei play/consumers/lobby.py und core/settings.py entsprechend umkonfiguriert werden!
|
||||||
|
|
||||||
|
|
||||||
Alternativ kannst du den Django-Entwicklungsserver verwenden, wenn du keine WebSocket-Funktionalität benötigst:
|
Alternativ kannst du den Django-Entwicklungsserver verwenden, wenn du keine WebSocket-Funktionalität benötigst:
|
||||||
```sh
|
```sh
|
||||||
python3 core/manage.py runserver
|
python3 core/manage.py runserver
|
||||||
@@ -84,6 +91,19 @@ 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
|
||||||
|
|
||||||
|
5. Starte den REDIS Server
|
||||||
|
```sh
|
||||||
|
redis-server
|
||||||
|
```
|
||||||
|
> **Hinweis:** Der Redis Server muss, wenn er nicht auf dem selben Gerät unter Standardeinstellungen läuft, in der Datei play/consumers/lobby.py und core/settings.py entsprechend umkonfiguriert 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
|
||||||
|
|||||||
@@ -79,12 +79,16 @@ WSGI_APPLICATION = 'core.wsgi.application'
|
|||||||
ASGI_APPLICATION = 'core.asgi.application'
|
ASGI_APPLICATION = 'core.asgi.application'
|
||||||
|
|
||||||
CHANNEL_LAYERS = {
|
CHANNEL_LAYERS = {
|
||||||
'default': {
|
"default": {
|
||||||
'BACKEND': 'channels.layers.InMemoryChannelLayer'
|
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||||
}
|
"CONFIG": {
|
||||||
|
"hosts": [("127.0.0.1", 6379)],
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
|
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
|
||||||
|
|
||||||
|
|||||||
13
django/library/management/commands/cleanup_games.py
Normal file
13
django/library/management/commands/cleanup_games.py
Normal 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.')
|
||||||
@@ -266,6 +266,8 @@ def copy_quiz(request, pk):
|
|||||||
new_quiz.user_id = request.user # Neuer Besitzer ist der aktuelle User
|
new_quiz.user_id = request.user # Neuer Besitzer ist der aktuelle User
|
||||||
new_quiz.name += " - Kopie" # Optional: Name anpassen
|
new_quiz.name += " - Kopie" # Optional: Name anpassen
|
||||||
new_quiz.base_quiz_id = pk
|
new_quiz.base_quiz_id = pk
|
||||||
|
new_quiz.rating_count = 0
|
||||||
|
new_quiz.average_rating = 3.0
|
||||||
#new_quiz.creator = original_quiz.creator or request.user
|
#new_quiz.creator = original_quiz.creator or request.user
|
||||||
new_quiz.save() # Speichern als neues Objekt
|
new_quiz.save() # Speichern als neues Objekt
|
||||||
# Optional: Beschreibung anpassen
|
# Optional: Beschreibung anpassen
|
||||||
|
|||||||
@@ -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']
|
||||||
@@ -90,9 +22,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
|
||||||
@@ -145,21 +74,6 @@ class GameConsumer(AsyncWebsocketConsumer):
|
|||||||
except QuizGameParticipant.DoesNotExist:
|
except QuizGameParticipant.DoesNotExist:
|
||||||
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']
|
||||||
@@ -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:
|
||||||
@@ -368,21 +271,17 @@ class GameConsumer(AsyncWebsocketConsumer):
|
|||||||
def cleanup_game(self):
|
def cleanup_game(self):
|
||||||
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':
|
game.delete()
|
||||||
if game.question_start_time and (timezone.now() - game.question_start_time).total_seconds() > 300:
|
|
||||||
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:
|
||||||
|
|||||||
@@ -3,53 +3,15 @@ import json
|
|||||||
import asyncio
|
import asyncio
|
||||||
from ..models import QuizGame, QuizGameParticipant
|
from ..models import QuizGame, QuizGameParticipant
|
||||||
from channels.db import database_sync_to_async
|
from channels.db import database_sync_to_async
|
||||||
|
import redis
|
||||||
|
|
||||||
# Dictionary to track inactive check tasks and active connections per game
|
# Dictionary to track inactive check tasks and active connections per game
|
||||||
game_check_tasks = {}
|
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())
|
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
|
||||||
|
|
||||||
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 +25,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 +66,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,36 +134,32 @@ 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')
|
||||||
))()
|
))()
|
||||||
|
|
||||||
|
|
||||||
# Send join notification for new participants
|
# Zustand laden
|
||||||
try:
|
try:
|
||||||
prev_names = set(p['display_name'] for p in self.last_participants) if hasattr(self, 'last_participants') else set()
|
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
|
||||||
|
|
||||||
|
# Alte Teilnehmer aus Redis holen
|
||||||
|
data = r.get(f"last_participants_{self.room_group_name}")
|
||||||
|
prev_names = set(json.loads(data)) if data else set()
|
||||||
|
|
||||||
|
# Aktuelle Teilnehmer aus dem Argument extrahieren
|
||||||
current_names = set(p['display_name'] for p in participants)
|
current_names = set(p['display_name'] for p in participants)
|
||||||
|
|
||||||
|
# Neue Teilnehmer bestimmen
|
||||||
new_participants = current_names - prev_names
|
new_participants = current_names - prev_names
|
||||||
|
|
||||||
|
# Benachrichtigung für neue Teilnehmer versenden
|
||||||
for name in new_participants:
|
for name in new_participants:
|
||||||
await self.channel_layer.group_send(
|
await self.channel_layer.group_send(
|
||||||
self.room_group_name,
|
self.room_group_name,
|
||||||
@@ -218,9 +168,12 @@ class LobbyConsumer(AsyncWebsocketConsumer):
|
|||||||
'player_name': name
|
'player_name': name
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Teilnehmerliste in Redis aktualisieren
|
||||||
|
r.set(f"last_participants_{self.room_group_name}", json.dumps(list(current_names)))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'Error sending join notifications: {e}')
|
print(f"Error in send_join_notifications: {e}")
|
||||||
|
|
||||||
self.last_participants = participants
|
self.last_participants = participants
|
||||||
return participants
|
return participants
|
||||||
except QuizGame.DoesNotExist:
|
except QuizGame.DoesNotExist:
|
||||||
|
|||||||
18
django/play/migrations/0011_quizgame_updated_at.py
Normal file
18
django/play/migrations/0011_quizgame_updated_at.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -51,28 +51,29 @@
|
|||||||
|
|
||||||
<div id="filterPanel" class="container mx-auto px-4">
|
<div id="filterPanel" class="container mx-auto px-4">
|
||||||
<div class="bg-white rounded-xl shadow-lg p-6 border border-gray-200">
|
<div class="bg-white rounded-xl shadow-lg p-6 border border-gray-200">
|
||||||
<form method="get" class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<form action="{% url 'library:overview_quiz' %}" method="get" class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<div class="space-y-2">
|
<input type="hidden" name="filter" value="1">
|
||||||
<label class="block text-sm font-medium text-gray-700">minimale Anzahl an Fragen</label>
|
<div class="space-y-2">
|
||||||
{{ form.min_amout_questions }}
|
<label class="block text-sm font-medium text-gray-700">minimale Anzahl an Fragen</label>
|
||||||
</div>
|
<input type="number" name="min_amout_questions" class="border-2 border-gray-300 rounded-lg p-2 w-full" min="1" id="id_min_amout_questions">
|
||||||
<div class="space-y-2">
|
</div>
|
||||||
<label class="block text-sm font-medium text-gray-700">maximale Anzahl an Fragen</label>
|
<div class="space-y-2">
|
||||||
{{ form.max_amout_questions }}
|
<label class="block text-sm font-medium text-gray-700">maximale Anzahl an Fragen</label>
|
||||||
</div>
|
<input type="number" name="max_amout_questions" class="border-2 border-gray-300 rounded-lg p-2 w-full" min="1" id="id_max_amout_questions">
|
||||||
<div class="space-y-2">
|
</div>
|
||||||
<label class="block text-sm font-medium text-gray-700">veröffentlicht von User</label>
|
<div class="space-y-2">
|
||||||
{{ form.user }}
|
<label class="block text-sm font-medium text-gray-700">veröffentlicht von User</label>
|
||||||
</div>
|
<input type="text" name="user" class="border-2 border-gray-300 rounded-lg p-2 w-full" id="id_user">
|
||||||
<div class="md:col-span-3 flex justify-end">
|
</div>
|
||||||
<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors duration-200">
|
<div class="md:col-span-3 flex justify-end">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors duration-200">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
</svg>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"></path>
|
||||||
Filtern
|
</svg>
|
||||||
</button>
|
Filtern
|
||||||
</div>
|
</button>
|
||||||
</form>
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -633,11 +634,26 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
if (urlParams.get('filter') === '1') {
|
||||||
|
document.getElementById('filterPanel').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('filterButton').addEventListener('click', function() {
|
document.getElementById('filterButton').addEventListener('click', function() {
|
||||||
document.getElementById('filterPanel').classList.toggle('show');
|
document.getElementById('filterPanel').classList.toggle('show');
|
||||||
|
|
||||||
|
// URL beim Öffnen aktualisieren
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (document.getElementById('filterPanel').classList.contains('show')) {
|
||||||
|
params.set('filter', '1');
|
||||||
|
} else {
|
||||||
|
params.delete('filter');
|
||||||
|
}
|
||||||
|
history.replaceState(null, '', `${location.pathname}?${params}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
if (window.location.pathname === "/library/") {
|
if (window.location.pathname === "/library/") {
|
||||||
window.addEventListener("beforeunload", function () {
|
window.addEventListener("beforeunload", function () {
|
||||||
localStorage.setItem("meine_seite_scroll", window.scrollY);
|
localStorage.setItem("meine_seite_scroll", window.scrollY);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Teilnehmer
|
Teilnehmer
|
||||||
</h3>
|
</h3>
|
||||||
<ul id="participants-list" class="space-y-2 max-h-60 overflow-y-auto">
|
<ul id="participants-list" class="space-y-2 max-h-60">
|
||||||
<li class="text-gray-500 p-3 bg-gray-50 rounded-lg text-center">Warte auf Teilnehmer...</li>
|
<li class="text-gray-500 p-3 bg-gray-50 rounded-lg text-center">Warte auf Teilnehmer...</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user