Merge branch 'master' of edugit.org:enrichment-2024/qivip
This commit is contained in:
48
README.md
48
README.md
@@ -47,16 +47,42 @@ Um das Projekt erfolgreich zu starten, folge diesen Schritten:
|
|||||||
|
|
||||||
### Entwicklungsserver starten
|
### Entwicklungsserver starten
|
||||||
|
|
||||||
Um den Server zu starten, verwende einen der folgenden Befehle:
|
Für die Entwicklung mit WebSocket-Unterstützung verwenden wir Daphne:
|
||||||
|
|
||||||
- Standardport (8000):
|
1. Aktiviere die virtuelle Umgebung:
|
||||||
```sh
|
```sh
|
||||||
python3 core/manage.py runserver
|
source .venv/bin/activate # Linux
|
||||||
```
|
.venv\Scripts\activate # Windows
|
||||||
- Bestimmten Port (z. B. 9000):
|
```
|
||||||
```sh
|
|
||||||
python3 core/manage.py runserver 9000
|
2. Starte den Daphne-Server:
|
||||||
```
|
```sh
|
||||||
|
# Im Verzeichnis 'django'
|
||||||
|
daphne -b 127.0.0.1 -p 8000 core.asgi:application
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternativ kannst du den Django-Entwicklungsserver verwenden, wenn du keine WebSocket-Funktionalität benötigst:
|
||||||
|
```sh
|
||||||
|
python3 core/manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
### Produktionsserver starten
|
||||||
|
|
||||||
|
Für den Produktivbetrieb verwenden wir Daphne als ASGI-Server, der sowohl HTTP als auch WebSocket-Verbindungen unterstützt:
|
||||||
|
|
||||||
|
1. Aktiviere die virtuelle Umgebung wie oben beschrieben
|
||||||
|
|
||||||
|
2. Sammle die statischen Dateien:
|
||||||
|
```sh
|
||||||
|
python3 core/manage.py collectstatic
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Starte den Daphne-Server:
|
||||||
|
```sh
|
||||||
|
daphne -b 0.0.0.0 -p 8000 core.asgi:application
|
||||||
|
```
|
||||||
|
- `-b 0.0.0.0`: Bindet den Server an alle Netzwerk-Interfaces
|
||||||
|
- `-p 8000`: Port (anpassbar)
|
||||||
|
|
||||||
Damit ist das Projekt erfolgreich eingerichtet und der Server kann gestartet werden!
|
Damit ist das Projekt erfolgreich eingerichtet und der Server kann gestartet werden!
|
||||||
|
|
||||||
@@ -100,10 +126,10 @@ npx @tailwindcss/cli -i <INPUT-DATEI> -o <OUTPUT-DATEI> --watch --minify
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# unter Linux mit Tailwind Binärdatei:
|
# unter Linux mit Tailwind Binärdatei:
|
||||||
tailwindcss -i static/css/t-input.css -o static/css/t-style.css --watch --minify
|
npx tailwindcss -i static/css/t-input.css -o static/css/t-style.css --watch --minify
|
||||||
|
|
||||||
# unter Windows mit NPM (in bash mit '/' statt '\'):
|
# unter Windows mit NPM (in bash mit '/' statt '\'):
|
||||||
npx @tailwindcss/cli -i .\static\css\t-input.css -o .\static\css\t-style.css --watch --minify
|
npx @tailwindcss/cli -i ./static/css/t-input.css -o ./static/css/t-style.css --watch --minify
|
||||||
```
|
```
|
||||||
|
|
||||||
## Issue - Merge Request - Merge
|
## Issue - Merge Request - Merge
|
||||||
|
|||||||
@@ -8,9 +8,21 @@ https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
|
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||||
|
from channels.auth import AuthMiddlewareStack
|
||||||
|
import django
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
django.setup()
|
||||||
|
|
||||||
application = get_asgi_application()
|
from play.routing import websocket_urlpatterns
|
||||||
|
|
||||||
|
application = ProtocolTypeRouter({
|
||||||
|
'http': get_asgi_application(),
|
||||||
|
'websocket': AuthMiddlewareStack(
|
||||||
|
URLRouter(
|
||||||
|
websocket_urlpatterns
|
||||||
|
)
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|||||||
@@ -42,10 +42,12 @@ INSTALLED_APPS = [
|
|||||||
'django.contrib.sessions',
|
'django.contrib.sessions',
|
||||||
'django.contrib.messages',
|
'django.contrib.messages',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
|
'channels',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
@@ -73,6 +75,13 @@ TEMPLATES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
WSGI_APPLICATION = 'core.wsgi.application'
|
WSGI_APPLICATION = 'core.wsgi.application'
|
||||||
|
ASGI_APPLICATION = 'core.asgi.application'
|
||||||
|
|
||||||
|
CHANNEL_LAYERS = {
|
||||||
|
'default': {
|
||||||
|
'BACKEND': 'channels.layers.InMemoryChannelLayer'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
@@ -85,6 +94,12 @@ DATABASES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
STORAGES = {
|
||||||
|
"staticfiles": {
|
||||||
|
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
|
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
|
||||||
@@ -121,6 +136,7 @@ USE_TZ = True
|
|||||||
# https://docs.djangoproject.com/en/5.1/howto/static-files/
|
# https://docs.djangoproject.com/en/5.1/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = '/static/'
|
STATIC_URL = '/static/'
|
||||||
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
STATICFILES_DIRS = [BASE_DIR / 'static']
|
STATICFILES_DIRS = [BASE_DIR / 'static']
|
||||||
|
|
||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ Including another URLconf
|
|||||||
1. Import the include() function: from django.urls import include, path
|
1. Import the include() function: from django.urls import include, path
|
||||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
"""
|
"""
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib import admin # type: ignore
|
from django.contrib import admin # type: ignore
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
|
|
||||||
@@ -25,3 +27,6 @@ urlpatterns = [
|
|||||||
path('library/', include('library.urls')),
|
path('library/', include('library.urls')),
|
||||||
path('components/', include('components.urls'))
|
path('components/', include('components.urls'))
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
@@ -16,6 +16,4 @@ urlpatterns = [
|
|||||||
path('question/delete/<int:pk>/', views.delete_question, name='delete_question'),
|
path('question/delete/<int:pk>/', views.delete_question, name='delete_question'),
|
||||||
path('detail/copy/<int:pk>/', views.copy_quiz, name='copy_quiz'),
|
path('detail/copy/<int:pk>/', views.copy_quiz, name='copy_quiz'),
|
||||||
|
|
||||||
]# Nur für die Entwicklungsumgebung
|
]
|
||||||
if settings.DEBUG:
|
|
||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
|
||||||
|
|||||||
@@ -130,16 +130,17 @@ def delete_quiz(request, pk):
|
|||||||
def detail_quiz(request, pk):
|
def detail_quiz(request, pk):
|
||||||
#quiz = get_object_or_404(QivipQuiz, pk=pk, user_id=request.user)
|
#quiz = get_object_or_404(QivipQuiz, pk=pk, user_id=request.user)
|
||||||
quiz = get_object_or_404(QivipQuiz, pk=pk)
|
quiz = get_object_or_404(QivipQuiz, pk=pk)
|
||||||
|
show_answers = request.GET.get('show_answers', 'false').lower() == 'true'
|
||||||
questions = QivipQuestion.objects.filter(quiz_id=quiz)
|
questions = QivipQuestion.objects.filter(quiz_id=quiz)
|
||||||
|
|
||||||
# Parse JSON data for each question
|
# Parse JSON data for each question
|
||||||
for question in questions:
|
for question in questions:
|
||||||
if question.data:
|
if question.data:
|
||||||
question.data = json.loads(question.data)
|
question.data = json.loads(question.data)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'quiz': quiz,
|
'quiz': quiz,
|
||||||
'questions': questions
|
'questions': questions,
|
||||||
|
'detail':show_answers,
|
||||||
}
|
}
|
||||||
return render(request, 'library/detail_quiz.html', context)
|
return render(request, 'library/detail_quiz.html', context)
|
||||||
"""
|
"""
|
||||||
|
|||||||
117
django/play/consumers.py
Normal file
117
django/play/consumers.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||||
|
from channels.db import database_sync_to_async
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
from .models import QuizGame, QuizGameParticipant
|
||||||
|
|
||||||
|
class LobbyConsumer(AsyncWebsocketConsumer):
|
||||||
|
async def connect(self):
|
||||||
|
self.join_code = self.scope['url_route']['kwargs']['join_code']
|
||||||
|
self.room_group_name = f'game_{self.join_code}'
|
||||||
|
self.heartbeat_task = None
|
||||||
|
|
||||||
|
# Join room group
|
||||||
|
await self.channel_layer.group_add(
|
||||||
|
self.room_group_name,
|
||||||
|
self.channel_name
|
||||||
|
)
|
||||||
|
await self.accept()
|
||||||
|
|
||||||
|
# Start heartbeat check
|
||||||
|
self.heartbeat_task = asyncio.create_task(self.check_participants_heartbeat())
|
||||||
|
|
||||||
|
# Send current participants list
|
||||||
|
participants = await self.get_participants()
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'participant_list',
|
||||||
|
'participants': participants
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
# Cancel heartbeat task
|
||||||
|
if self.heartbeat_task:
|
||||||
|
self.heartbeat_task.cancel()
|
||||||
|
try:
|
||||||
|
await self.heartbeat_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Leave room group
|
||||||
|
await self.channel_layer.group_discard(
|
||||||
|
self.room_group_name,
|
||||||
|
self.channel_name
|
||||||
|
)
|
||||||
|
|
||||||
|
async def receive(self, text_data):
|
||||||
|
text_data_json = json.loads(text_data)
|
||||||
|
message_type = text_data_json['type']
|
||||||
|
|
||||||
|
if message_type == 'heartbeat':
|
||||||
|
# Update participant's last activity timestamp
|
||||||
|
participant_id = text_data_json.get('participant_id')
|
||||||
|
if participant_id:
|
||||||
|
await self._update_participant_heartbeat(participant_id)
|
||||||
|
|
||||||
|
elif message_type == 'update_participants':
|
||||||
|
participants = await self.get_participants()
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
self.room_group_name,
|
||||||
|
{
|
||||||
|
'type': 'participant_list_update',
|
||||||
|
'participants': participants
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def participant_list_update(self, event):
|
||||||
|
participants = event['participants']
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'participant_list',
|
||||||
|
'participants': participants
|
||||||
|
}))
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def get_participants(self):
|
||||||
|
game = QuizGame.objects.get(join_code=self.join_code)
|
||||||
|
participants = QuizGameParticipant.objects.filter(quiz_game=game)
|
||||||
|
return [{'id': str(p.participant_id), 'name': p.display_name} for p in participants]
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _update_participant_heartbeat(self, participant_id):
|
||||||
|
try:
|
||||||
|
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
|
||||||
|
participant.save() # This will update last_heartbeat due to auto_now=True
|
||||||
|
except QuizGameParticipant.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _remove_inactive_participants(self):
|
||||||
|
# Remove participants who haven't sent a heartbeat in the last 30 seconds
|
||||||
|
timeout = timezone.now() - timedelta(seconds=30)
|
||||||
|
quiz_game = QuizGame.objects.get(join_code=self.join_code)
|
||||||
|
QuizGameParticipant.objects.filter(
|
||||||
|
quiz_game=quiz_game,
|
||||||
|
last_heartbeat__lt=timeout
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
async def check_participants_heartbeat(self):
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(10) # Check every 10 seconds
|
||||||
|
await self._remove_inactive_participants()
|
||||||
|
|
||||||
|
# Send updated participant list
|
||||||
|
participants = await self.get_participants()
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
self.room_group_name,
|
||||||
|
{
|
||||||
|
'type': 'participant_list_update',
|
||||||
|
'participants': participants
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Error in heartbeat check: {e}')
|
||||||
|
await asyncio.sleep(10) # Wait before retrying
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2025-04-05 10:56
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('play', '0003_quizgameparticipant_participant_id_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='quizgame',
|
||||||
|
name='host_user',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='quizgame',
|
||||||
|
name='host_id',
|
||||||
|
field=models.CharField(default=None, max_length=200, unique=True),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2025-04-05 12:23
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('play', '0004_remove_quizgame_host_user_quizgame_host_id'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='quizgameparticipant',
|
||||||
|
name='last_heartbeat',
|
||||||
|
field=models.DateTimeField(auto_now=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -6,13 +6,13 @@ import random, string
|
|||||||
|
|
||||||
# Create your models here.
|
# Create your models here.
|
||||||
class QuizGame(models.Model):
|
class QuizGame(models.Model):
|
||||||
host_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='host_user')
|
host_id = models.CharField(max_length=200, unique=True)
|
||||||
join_code = models.CharField(max_length=6, unique=True, blank=True)
|
join_code = models.CharField(max_length=6, unique=True, blank=True)
|
||||||
quiz_id = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE)
|
quiz_id = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.join_code:
|
if not self.join_code:
|
||||||
self.join_code = self.generate_unique_code()
|
self.join_code = self.generate_unique_code()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def generate_unique_code(self):
|
def generate_unique_code(self):
|
||||||
@@ -21,12 +21,19 @@ class QuizGame(models.Model):
|
|||||||
if not QuizGame.objects.filter(join_code=new_code).exists():
|
if not QuizGame.objects.filter(join_code=new_code).exists():
|
||||||
return new_code
|
return new_code
|
||||||
|
|
||||||
|
def generate_unique_id(self):
|
||||||
|
for i in range(10):
|
||||||
|
new_id = ''.join(random.choices(string.digits + string.ascii_lowercase, k=60))
|
||||||
|
if not QuizGameParticipant.objects.filter(participant_id=new_id).exists():
|
||||||
|
return new_id
|
||||||
|
|
||||||
class QuizGameParticipant(models.Model):
|
class QuizGameParticipant(models.Model):
|
||||||
participant_id = models.CharField(max_length=200, unique=True)
|
participant_id = models.CharField(max_length=200, unique=True)
|
||||||
display_name = models.CharField(verbose_name="Anzeigename", max_length=15)
|
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="participant")
|
||||||
score = models.IntegerField(verbose_name="Punkte", default=0)
|
score = models.IntegerField(verbose_name="Punkte", default=0)
|
||||||
avatar = models.CharField(max_length=200, blank=True, default="")
|
avatar = models.CharField(max_length=200, blank=True, default="")
|
||||||
|
last_heartbeat = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.participant_id:
|
if not self.participant_id:
|
||||||
|
|||||||
6
django/play/routing.py
Normal file
6
django/play/routing.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.urls import re_path
|
||||||
|
from . import consumers
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
re_path(r'ws/game/(?P<join_code>\w+)/$', consumers.LobbyConsumer.as_asgi()),
|
||||||
|
]
|
||||||
@@ -4,9 +4,10 @@ from . import views
|
|||||||
app_name = 'play'
|
app_name = 'play'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path('game/new/<int:quiz_id>', views.create_game, name='create_game'),
|
||||||
path('game/<str:join_code>', views.lobby, name='lobby'),
|
path('game/<str:join_code>', views.lobby, name='lobby'),
|
||||||
path('game/<str:join_code>/participate', views.create_participant, name='create_participant'),
|
path('game/<str:join_code>/participate', views.create_participant, name='create_participant'),
|
||||||
path('join', views.join_game, name='join_game'),
|
path('join', views.join_game, name='join_game'),
|
||||||
path('game/<str:join_code>/<int:question_id>', views.question, name='question'),
|
path('game/<str:join_code>/<int:question_id>', views.question, name='question'),
|
||||||
path('game/<str:join_code>/<int:question_id>/participant', views.question_participant, name='question_participant'),
|
path('game/<str:join_code>/<int:question_id>/participant', views.question_participant, name='question_participant'),
|
||||||
]
|
]
|
||||||
@@ -2,34 +2,52 @@ from django.shortcuts import render
|
|||||||
from django.shortcuts import render, redirect, get_object_or_404, reverse
|
from django.shortcuts import render, redirect, get_object_or_404, reverse
|
||||||
from play.models import QuizGame, QuizGameParticipant
|
from play.models import QuizGame, QuizGameParticipant
|
||||||
from library.models import QivipQuiz, QivipQuestion
|
from library.models import QivipQuiz, QivipQuestion
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect, HttpResponseNotFound
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
# Create your views here.
|
# Create your views here.
|
||||||
def lobby(request, join_code):
|
def lobby(request, join_code):
|
||||||
if not "participant_id" in request.COOKIES:
|
|
||||||
return redirect('play:create_participant', join_code=join_code)
|
|
||||||
participant_id = request.COOKIES['participant_id']
|
|
||||||
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
|
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
|
||||||
quiz = get_object_or_404(QivipQuiz, pk=quiz_game.quiz_id.id)
|
quiz = get_object_or_404(QivipQuiz, pk=quiz_game.quiz_id.id)
|
||||||
participant = get_object_or_404(QuizGameParticipant, participant_id=participant_id)
|
|
||||||
|
|
||||||
if not participant.quiz_game.join_code == join_code:
|
|
||||||
participant.quiz_game = quiz_game
|
|
||||||
participant.save()
|
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'debug': "test",
|
|
||||||
'participant': participant,
|
|
||||||
'quiz': quiz,
|
'quiz': quiz,
|
||||||
|
'quiz_game': quiz_game,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if "host_id" in request.COOKIES:
|
||||||
|
host_id = request.COOKIES['host_id']
|
||||||
|
if host_id == quiz_game.host_id:
|
||||||
|
context['host_id'] = host_id
|
||||||
|
return render(request, 'play/lobby.html', context=context)
|
||||||
|
|
||||||
|
if not "participant_id" in request.COOKIES:
|
||||||
|
return redirect('play:create_participant', join_code=join_code)
|
||||||
|
|
||||||
|
participant_id = request.COOKIES['participant_id']
|
||||||
|
try:
|
||||||
|
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
|
||||||
|
if not participant.quiz_game.join_code == join_code: # Teilnehmer dem richtigen Spiel hinzufügen
|
||||||
|
participant.quiz_game = quiz_game
|
||||||
|
participant.save()
|
||||||
|
except QuizGameParticipant.DoesNotExist:
|
||||||
|
return redirect('play:create_participant', join_code=join_code)
|
||||||
|
|
||||||
|
context['participant'] = participant
|
||||||
return render(request, 'play/lobby.html', context=context)
|
return render(request, 'play/lobby.html', context=context)
|
||||||
|
|
||||||
def create_participant(request, join_code):
|
def create_participant(request, join_code):
|
||||||
if "participant_id" in request.COOKIES: # Umleiten, falls Teilnehmer bereits erstellt
|
if "participant_id" in request.COOKIES:
|
||||||
return redirect('play:lobby', join_code=join_code)
|
# Prüfe ob der Teilnehmer noch existiert
|
||||||
|
participant_id = request.COOKIES['participant_id']
|
||||||
|
try:
|
||||||
|
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
|
||||||
|
return redirect('play:lobby', join_code=join_code)
|
||||||
|
except QuizGameParticipant.DoesNotExist:
|
||||||
|
# Cookie löschen wenn Teilnehmer nicht mehr existiert
|
||||||
|
response = HttpResponseRedirect(request.path)
|
||||||
|
response.delete_cookie('participant_id')
|
||||||
|
return response
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
display_name = request.POST.get('display_name')
|
display_name = request.POST.get('display_name')
|
||||||
join_code = request.POST.get('join_code')
|
join_code = request.POST.get('join_code')
|
||||||
@@ -50,6 +68,22 @@ def create_participant(request, join_code):
|
|||||||
|
|
||||||
return render(request, 'play/initialize_participant.html', {'join_code': join_code})
|
return render(request, 'play/initialize_participant.html', {'join_code': join_code})
|
||||||
|
|
||||||
|
def create_game(request, quiz_id):
|
||||||
|
try:
|
||||||
|
quiz = QivipQuiz.objects.get(id=quiz_id)
|
||||||
|
game = QuizGame()
|
||||||
|
game.quiz_id = quiz
|
||||||
|
game.host_id = game.generate_unique_id()
|
||||||
|
game.save()
|
||||||
|
|
||||||
|
response = HttpResponseRedirect(reverse('play:lobby', kwargs={'join_code': game.join_code}))
|
||||||
|
response.set_cookie('host_id', game.host_id, max_age=3600)
|
||||||
|
|
||||||
|
return response
|
||||||
|
except QivipQuiz.DoesNotExist:
|
||||||
|
return HttpResponseNotFound("Quiz nicht gefunden")
|
||||||
|
|
||||||
|
|
||||||
def join_game(request):
|
def join_game(request):
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
join_code = request.POST.get('game_code')
|
join_code = request.POST.get('game_code')
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
|
|
||||||
<div class="container mx-auto px-4">
|
<div class="container mx-auto px-4">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h1 class="text-2xl font-bold">{{ quiz.name }}</h1>
|
<h1 class="text-2xl font-bold">{{ quiz.name }}</h1>
|
||||||
@@ -28,7 +27,33 @@
|
|||||||
{% if quiz.description %}
|
{% if quiz.description %}
|
||||||
<p class="text-gray-600 mb-4">{{ quiz.description }}</p>
|
<p class="text-gray-600 mb-4">{{ quiz.description }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="flex justify-end mb-4">
|
||||||
|
{% if detail == True %}
|
||||||
|
|
||||||
|
<a href="{% url 'library:detail_quiz' quiz.id %}?show_answers=false">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||||
|
</svg>
|
||||||
|
Antworten verstecken
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<a href="{% url 'library:detail_quiz' quiz.id %}?show_answers=true">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||||
|
</svg>
|
||||||
|
Antworten anzeigen
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow-md border-blue-600 border-2 rounded-xl shadow-md">
|
<div class="bg-white rounded-lg shadow-md border-blue-600 border-2 rounded-xl shadow-md">
|
||||||
@@ -69,34 +94,41 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
|
{% if detail %}
|
||||||
|
|
||||||
{% if question.data.type == 'multiple_choice' %}
|
{% if question.data.type == 'multiple_choice' %}
|
||||||
<ul class="space-y-1 ">
|
<ul class="space-y-1 ">
|
||||||
{% for option in question.data.options %}
|
{% for option in question.data.options %}
|
||||||
<li class="flex items-center text-sm">
|
<li class="flex items-center text-sm">
|
||||||
{% if option.is_correct %}
|
{% if option.is_correct %}
|
||||||
<svg class="h-4 w-4 text-green-500 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-4 w-4 text-green-500 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="font-medium text-green-700">{{ option.value }}</span>
|
<span class="font-medium text-green-700">{{ option.value }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<svg class="h-4 w-4 text-gray-400 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-4 w-4 text-gray-400 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 12H6"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 12H6"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-gray-600">{{ option.value }}</span>
|
<span class="text-gray-600">{{ option.value }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for option in question.data.options %}
|
{% for option in question.data.options %}
|
||||||
{% if option.is_correct %}
|
{% if option.is_correct %}
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
<span class="text-gray-500">Richtige Antwort:</span>
|
<span class="text-gray-500">Richtige Antwort:</span>
|
||||||
<span class="ml-1 font-medium {% if option.value == "Wahr" %} text-green-700 {% else %}text-red-700{% endif %}">{{ option.value }}</span>
|
<span class="ml-1 font-medium {% if option.value == "Wahr" %} text-green-700 {% else %}text-red-700{% endif %}">{{ option.value }}</span>
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -130,6 +162,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
{% if quiz.base_quiz %}
|
||||||
|
<p class="text-gray-600 mt-2 ">Bearbeitung:<span class="font-bold"> {{ quiz.user_id}}</span></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
{% if quiz.base_quiz %}
|
{% if quiz.base_quiz %}
|
||||||
<p class="text-gray-600 mt-2 ">Kopie von: <a href="{% url 'library:detail_quiz' quiz.base_quiz.id %}"><span class="font-bold">{{ quiz.base_quiz}}</span></a></p>
|
<p class="text-gray-600 mt-2 ">Kopie von: <a href="{% url 'library:detail_quiz' quiz.base_quiz.id %}"><span class="font-bold">{{ quiz.base_quiz}}</span></a></p>
|
||||||
|
|||||||
@@ -5,7 +5,16 @@
|
|||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p }}
|
{{ form.as_p }}
|
||||||
<button type="submit">Speichern</button>
|
<div class="flex justify-center space-x-3">
|
||||||
|
<a href="{% if object.quiz_id %}{% url 'library:detail_quiz' object.quiz_id.id %}{% else %}{% url 'library:overview_quiz' %}{% endif %}"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
Abbrechen
|
||||||
|
</a>
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-white">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
{% for quiz in filter_my_quizzes %}
|
{% for quiz in filter_my_quizzes %}
|
||||||
<div class="swiper-slide">
|
<div class="swiper-slide">
|
||||||
<div class="relative h-80 bg-white bg-cover text-white w-full max-w-md rounded-lg p-4 shadow-md border-blue-600 border-2 rounded-xl flex flex-col justify-between"
|
<div class="relative h-80 bg-white bg-cover text-white w-full max-w-md rounded-lg p-4 shadow-md border-blue-600 border-2 rounded-xl flex flex-col justify-between"
|
||||||
{% if quiz.image %} style="background-image: url('http://127.0.0.1:8000/library{{ quiz.image.url }}');" {% endif %}>
|
{% if quiz.image %} style="background-image: url('http://127.0.0.1:8000/{{ quiz.image.url }}');" {% endif %}>
|
||||||
<div class="absolute inset-0 bg-gray-900/60 bg-opacity-50 rounded-lg"></div>
|
<div class="absolute inset-0 bg-gray-900/60 bg-opacity-50 rounded-lg"></div>
|
||||||
|
|
||||||
<h2 class=" break-words truncate text-white drop-shadow-lg custom-outline"> <a href="{% url 'library:detail_quiz' quiz.id %}">{{ quiz.name }}</a></h2>
|
<h2 class=" break-words truncate text-white drop-shadow-lg custom-outline"> <a href="{% url 'library:detail_quiz' quiz.id %}">{{ quiz.name }}</a></h2>
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
|
|
||||||
<!-- Buttons bleiben am unteren Rand -->
|
<!-- Buttons bleiben am unteren Rand -->
|
||||||
<div class="flex justify-between items-center gap-2 ">
|
<div class="flex justify-between items-center gap-2 ">
|
||||||
<a href="#" class="qp-a-button-small border-2 border-blue-600 text-white font-bold text-white drop-shadow-lg custom-outline">Spiel starten</a>
|
<a href="{% url 'play:create_game' quiz.id %}" class="qp-a-button-small border-2 border-blue-600 text-white font-bold text-white drop-shadow-lg custom-outline">Spiel starten</a>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="{% url 'library:detail_quiz' quiz.id %}" class="qp-a-button-small border-2 border-blue-600 text-white font-bold drop-shadow-lg custom-outline">
|
<a href="{% url 'library:detail_quiz' quiz.id %}" class="qp-a-button-small border-2 border-blue-600 text-white font-bold drop-shadow-lg custom-outline">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7">
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
|
|
||||||
<!-- Buttons bleiben am unteren Rand -->
|
<!-- Buttons bleiben am unteren Rand -->
|
||||||
<div class="flex justify-between items-center gap-2 ">
|
<div class="flex justify-between items-center gap-2 ">
|
||||||
<a href="#" class="qp-a-button-small border-2 border-blue-600 text-white font-bold text-white drop-shadow-lg custom-outline">Spiel starten</a>
|
<a href="{% url 'play:create_game' quiz.id %}" class="qp-a-button-small border-2 border-blue-600 text-white font-bold text-white drop-shadow-lg custom-outline">Spiel starten</a>
|
||||||
<a href="{% url 'library:detail_quiz' quiz.id %}" class="qp-a-button-small border-2 border-blue-600 text-white text-white font-bold text-white drop-shadow-lg custom-outline">
|
<a href="{% url 'library:detail_quiz' quiz.id %}" class="qp-a-button-small border-2 border-blue-600 text-white text-white font-bold text-white drop-shadow-lg custom-outline">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not filter_other_quizzes and not filter_my_quizzes %}
|
{% if not filter_other_quizzes and not filter_my_quizzes %}
|
||||||
<div class="grid text-center font-bold items-center">Es wurde kein Quiz passendes gefunden!</div>
|
<div class="grid text-center font-bold items-center">Es wurde kein passendes Quiz gefunden!</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,73 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto px-4">
|
<div class="container mx-auto px-4">
|
||||||
<h1>{{ quiz.name }}</h1>
|
<h3>{{ quiz.name }}</h3>
|
||||||
|
<h1 class="text-center text-4xl font-bold mb-4">{{ quiz_game.join_code }}</h1>
|
||||||
|
{% if participant %}
|
||||||
<div class="mt-4 mb-4 text-center">
|
<div class="mt-4 mb-4 text-center">
|
||||||
<h3>Sichtbar als <b>{{ participant.display_name }}</b></h3>
|
<h3>Sichtbar als <b>{{ participant.display_name }}</b></h3>
|
||||||
</div>
|
</div>
|
||||||
<div id="player-list">
|
{% endif %}
|
||||||
<ul>
|
<div class="mb-4">
|
||||||
<li>Noch keine Spieler gefunden.</li>
|
<h3 class="font-bold mb-2">Teilnehmer:</h3>
|
||||||
|
<ul id="participants-list" class="space-y-2">
|
||||||
|
<li class="text-gray-500">Warte auf Teilnehmer...</li>
|
||||||
</ul>
|
</ul>
|
||||||
{{debug}}
|
|
||||||
</div>
|
</div>
|
||||||
<button class="w-full p-3 rounded-full bg-blue-600 text-white font-black hover:scale-105 transition duration-200" type="submit">Starten</button>
|
{% if host_id %}
|
||||||
|
<button id="start-button" class="w-full p-3 rounded-full bg-blue-600 text-white font-black hover:scale-105 transition duration-200" type="button">Starten</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
// TODO: Websocket Verbindung aufbauen und Teilnehmerliste bei beitreten updaten
|
const joinCode = '{{ quiz_game.join_code }}';
|
||||||
|
const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const lobbySocket = new WebSocket(
|
||||||
|
wsScheme + '://' + window.location.host + '/ws/game/' + joinCode + '/'
|
||||||
|
);
|
||||||
|
|
||||||
|
lobbySocket.onmessage = function(e) {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.type === 'participant_list' || data.type === 'participant_list_update') {
|
||||||
|
updateParticipantsList(data.participants);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
lobbySocket.onclose = function(e) {
|
||||||
|
console.error('Lobby socket closed unexpectedly');
|
||||||
|
};
|
||||||
|
|
||||||
|
{% if participant %}
|
||||||
|
// Send heartbeat every 10 seconds
|
||||||
|
setInterval(() => {
|
||||||
|
if (lobbySocket.readyState === WebSocket.OPEN) {
|
||||||
|
lobbySocket.send(JSON.stringify({
|
||||||
|
'type': 'heartbeat',
|
||||||
|
'participant_id': '{{ participant.participant_id }}'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
function updateParticipantsList(participants) {
|
||||||
|
const list = document.getElementById('participants-list');
|
||||||
|
if (participants.length === 0) {
|
||||||
|
list.innerHTML = '<li class="text-gray-500">Noch keine Teilnehmer...</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = participants
|
||||||
|
.map(p => `<li class="p-2 bg-gray-100 rounded">${p.name}</li>`)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request initial participants list
|
||||||
|
lobbySocket.onopen = function(e) {
|
||||||
|
lobbySocket.send(JSON.stringify({
|
||||||
|
'type': 'update_participants'
|
||||||
|
}));
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
# Core dependencies
|
||||||
django~=5.1.4
|
django~=5.1.4
|
||||||
channels~=4.2.0
|
channels~=4.2.0 # WebSocket support
|
||||||
daphne~=4.1.2
|
daphne~=4.1.2 # ASGI server
|
||||||
pillow~=11.1.0
|
pillow~=11.1.0 # Image handling
|
||||||
|
whitenoise~=6.6.0 # Static file serving
|
||||||
Reference in New Issue
Block a user