Compare commits

...

87 Commits

Author SHA1 Message Date
ben8
a7c42ecf13 Merge branch '108-bilderupload-verbessern' into 'master'
Resolve "Bilderupload verbessern!"

Closes #108

See merge request enrichment-2024/qivip!60
2025-04-23 15:30:51 +00:00
ben8
cc8cc9ad74 Bild_löschen 2025-04-23 16:13:14 +02:00
ben8
0c8a8d3062 sinnvolleres_speichern 2025-04-23 16:11:08 +02:00
ben8
a021e7561a Bildspeichern_DB_fix 2025-04-23 13:02:28 +02:00
ben8
9321b44056 Bild_anzeige_in_modi_fix 2025-04-23 12:16:18 +02:00
Juhn-Vitus Saß
873b50103d Revert breaking changes.
Revert "Add missing file for collect static"

This reverts commit b823909386.
2025-04-23 12:00:50 +02:00
ben8
41cecf98dc fix_Bilder 2025-04-23 11:52:32 +02:00
Juhn-Vitus Saß
ac5859a666 Move Media Root Definition 2025-04-23 10:57:14 +02:00
Juhn-Vitus Saß
779762c2a3 WhiteNoise Configuration 2025-04-23 10:50:39 +02:00
Juhn-Vitus Saß
da78060415 Serve media in Production 2025-04-23 10:45:15 +02:00
Juhn-Vitus Saß
b823909386 Add missing file for collect static 2025-04-22 22:41:40 +02:00
Juhn-Vitus Saß
82f0cd5191 Correct Package name 2025-04-22 20:05:11 +02:00
Juhn-Vitus Saß
1069dd1cad Nachricht für keine Bewertung auch bei fremden Quizzen anzeigen 2025-04-22 19:36:16 +02:00
Juhn-Vitus Saß
905da6737c Merge branch '89-pop-up-menu-fur-mobile' into 'master'
Resolve "Pop-up Menü für Mobile"

Closes #89

See merge request enrichment-2024/qivip!64
2025-04-22 17:14:14 +00:00
Juhn-Vitus Saß
6eed8107ac feat(navbar): Implementiere responsive Navbar mit mobilem Menü
- Füge Hamburger-Menü für mobile Ansichten hinzu
- Positioniere Suchleiste zentral auf dem Desktop
- Implementiere animierte Transitions beim Öffnen/Schließen des Menüs
- Entferne untere Border-Radius der Navbar bei geöffnetem Menü für nahtloses Erscheinungsbild
- Optimiere Layout für verschiedene Bildschirmgrößen
- Behalte Designsprache und Farbschema bei (blau/weiß)
2025-04-22 19:13:55 +02:00
ben8
95e4428943 fix_Bilder 2025-04-22 19:11:31 +02:00
Juhn-Vitus Saß
bad257f341 Merge branch '83-auch-bei-dem-orginalquiz-sollte-der-autor-stehen' into 'master'
Resolve "Auch bei dem Orginalquiz sollte der Autor stehen!"

Closes #83

See merge request enrichment-2024/qivip!63
2025-04-22 16:55:42 +00:00
Juhn-Vitus Saß
eaf709ed4d Verbesserte Ansicht des Ursprungs und der Editoren 2025-04-22 18:54:59 +02:00
Juhn-Vitus Saß
8ca470ad54 Merge branch '71-bewertungssystem-ueberarbeiten' into 'master'
Resolve "Bewertungssystem ueberarbeiten"

Closes #71

See merge request enrichment-2024/qivip!62
2025-04-22 16:33:02 +00:00
Juhn-Vitus Saß
af919b9ea5 README updaten 2025-04-22 18:31:51 +02:00
Juhn-Vitus Saß
154110e510 Bewertungen verstecken wenn keine vorhanden und Möglichkeit zum deaktivieren von Bewertungen 2025-04-22 18:28:50 +02:00
Juhn-Vitus Saß
a16c97c4f3 Merge branch '70-siegerpodest-am-spielende' into 'master'
Resolve "Siegerpodest am Spielende"

Closes #70 and #72

See merge request enrichment-2024/qivip!61
2025-04-22 15:57:55 +00:00
Juhn-Vitus Saß
01562c6ad8 Konfetti animation
Fix #72
2025-04-22 17:57:27 +02:00
ben8
50cd95daf1 Uploadfunktion! 2025-04-22 17:52:34 +02:00
ben8
1a4b9e0d6b Hübscheres_Bilder_Upload+Zurücksetzfunktion 2025-04-22 17:51:19 +02:00
Juhn-Vitus Saß
867c839202 Abhängigkeiten erneuern 2025-04-22 14:24:26 +02:00
Juhn-Vitus Saß
8d8e211c72 Merge branch '73-lobby-ueberarbeiten' into 'master'
Resolve "Lobby ueberarbeiten"

Closes #73

See merge request enrichment-2024/qivip!56
2025-04-22 10:30:35 +00:00
Juhn-Vitus Saß
6da095c35e Overflow entfernen 2025-04-22 12:29:44 +02:00
Juhn-Vitus Saß
2dc9a077ee Benachrichtigungen fixen mit Redis 2025-04-22 12:21:47 +02:00
Juhn-Vitus Saß
881bc3d6c1 Merge branch '79-filter-problem-der-filter-soll-sich-nicht-schliessen-wenn-man-auf-filtern-klickt' into 'master'
Resolve "Filter Problem! Der Filter soll sich nicht schließen wenn man auf Filtern klickt."

Closes #79

See merge request enrichment-2024/qivip!55
2025-04-22 09:47:05 +00:00
Juhn-Vitus Saß
b1e438229a Filter bleibt offen wenn man auf filtern drückt 2025-04-22 11:46:44 +02:00
Juhn-Vitus Saß
97e4fa9505 Merge branch '82-bei-dem-kopieren-wird-momentan-noch-die-bewertung-ubernommen' into 'master'
Resolve "Bei dem Kopieren wird momentan noch die Bewertung übernommen!"

Closes #82

See merge request enrichment-2024/qivip!54
2025-04-21 19:20:30 +00:00
Juhn-Vitus Saß
3981dc31da Bewertung und Anzahl an Bewertungen zurücksetzen 2025-04-21 21:20:07 +02:00
Juhn-Vitus Saß
7b86b0791c 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
2025-04-21 11:36:28 +00:00
Juhn-Vitus Saß
7faf931fb2 - Entfernen des automatischen Kick-Mechanismus
- Fix #105
2025-04-21 13:35:51 +02:00
Juhn-Vitus Saß
9245f928dc Fix: Error when creating question without image 2025-04-21 11:54:30 +02:00
Frank Poetzsch-Heffter
1f5a177060 typo corrected 2025-04-21 06:34:31 +00:00
Juhn-Vitus Saß
ebbe4f7193 Merge branch '23-bilder-fur-fragen-und-co' into 'master'
Resolve "Bilder für Fragen und co."

Closes #23

See merge request enrichment-2024/qivip!49
2025-04-18 19:30:15 +00:00
Juhn-Vitus Saß
acd07bfb80 Merge branch 'master' into '23-bilder-fur-fragen-und-co'
# Conflicts:
#   django/templates/play/game/question_host.html
2025-04-18 19:29:59 +00:00
Juhn-Vitus Saß
d824816be8 Bilder für Fragen 2025-04-18 21:26:40 +02:00
ben8
2c563b3471 Merge branch '107-favoritensystem-verbessern' into 'master'
Resolve "Favoritensystem verbessern"

Closes #107

See merge request enrichment-2024/qivip!52
2025-04-18 16:47:37 +00:00
ben8
4122f0631b Favorit entfernen-in_DB_löschen 2025-04-18 18:47:10 +02:00
ben8
2372cf7146 Merge branch '106-quizze-mit-null-fragen-sollen-nicht-gespielt-werden-konnen' into 'master'
Resolve "Quizze mit null Fragen sollen nicht gespielt werden können!"

Closes #106

See merge request enrichment-2024/qivip!48
2025-04-18 16:33:34 +00:00
ben8
6305af9879 Merge branch 'master' of edugit.org:enrichment-2024/qivip 2025-04-18 18:27:37 +02:00
ben8
176a3cc15c Merge branch 'master' of edugit.org:enrichment-2024/qivip 2025-04-18 18:27:27 +02:00
ben8
fa9bb45d3f Merge branch 'master' of edugit.org:enrichment-2024/qivip 2025-04-18 18:12:25 +02:00
ben8
cc449997dc Merge branch 'master' of edugit.org:enrichment-2024/qivip 2025-04-18 18:12:13 +02:00
ben8
e813660722 Merge branch 'master' of edugit.org:enrichment-2024/qivip 2025-04-18 18:11:35 +02:00
ben8
1d6fab919e Merge branch 'master' of edugit.org:enrichment-2024/qivip 2025-04-18 18:11:07 +02:00
ben8
17195a221f Merge branch 'master' of edugit.org:enrichment-2024/qivip 2025-04-18 18:10:50 +02:00
ben8
3fc08cbd4e Merge branch 'cherry-pick-d4252c12' into 'master'
Merge branch '103-verschiedene-lernmodi' into 'master'

Closes #103

See merge request enrichment-2024/qivip!51
2025-04-18 18:07:03 +02:00
ben8
4cf6f45daf Merge branch 'cherry-pick-d4252c12' into 'master'
Merge branch '103-verschiedene-lernmodi' into 'master'

Closes #103

See merge request enrichment-2024/qivip!51
2025-04-18 14:13:18 +00:00
ben8
d2ad0f041f Edit question_host.html 2025-04-18 14:11:57 +00:00
ben8
bcdfebf791 Merge branch '103-verschiedene-lernmodi' into 'master'
Resolve "verschiedene Lernmodi"

Closes #103

See merge request enrichment-2024/qivip!47

(cherry picked from commit d4252c1297)

5b4c76a1 Zwischenstand Lernmodus
4209dcbc Merge branch 'master' into 103-verschiedene-lernmodi
9fb9ab91 Lernfunktion
e22ea409 Lernmodus verbessert und Testmodus entfernt
2052a702 Merge branch 'master' into '103-verschiedene-lernmodi'

Co-authored-by: ben8 <ben_ollech@bessermailer.de>
2025-04-18 14:00:29 +00:00
ben8
937fd4a2e4 Merge branch 'revert-d4252c12' into 'master'
Revert "Merge branch '103-verschiedene-lernmodi' into 'master'"

See merge request enrichment-2024/qivip!50
2025-04-18 13:51:58 +00:00
ben8
a9343f285a Revert "Merge branch '103-verschiedene-lernmodi' into 'master'"
This reverts merge request !47
2025-04-18 13:48:23 +00:00
ben8
d4252c1297 Merge branch '103-verschiedene-lernmodi' into 'master'
Resolve "verschiedene Lernmodi"

Closes #103

See merge request enrichment-2024/qivip!47
2025-04-18 13:28:25 +00:00
ben8
2052a702f0 Merge branch 'master' into '103-verschiedene-lernmodi'
# Conflicts:
#   django/templates/play/game/question_host.html
2025-04-18 13:28:04 +00:00
ben8
a0292ce409 Startbutton-min.1Frage 2025-04-18 15:25:29 +02:00
Juhn-Vitus Saß
73cd52ea3d Merge branch '76-die-lange-der-zeit-pro-frage-anpassen' into 'master'
Resolve "Die Länge der Zeit Pro Frage anpassen."

Closes #76

See merge request enrichment-2024/qivip!36
2025-04-18 11:01:34 +00:00
Juhn-Vitus Saß
ffe636c6c7 Merge branch 'master' into '76-die-lange-der-zeit-pro-frage-anpassen'
# Conflicts:
#   django/library/forms.py
2025-04-18 11:00:52 +00:00
ben8
e22ea40910 Lernmodus verbessert und Testmodus entfernt 2025-04-18 12:48:35 +02:00
Juhn-Vitus Saß
e269264f03 Front End einbindung von Zeitauswahl 2025-04-18 12:05:50 +02:00
ben8
9fb9ab9198 Lernfunktion 2025-04-14 23:39:43 +02:00
ben8
4209dcbc2d Merge branch 'master' into 103-verschiedene-lernmodi 2025-04-14 20:26:34 +02:00
ben8
70be6396aa Merge branch '101-zwischenseite-nach-spielen-z-b-lernmodus-klassisches-moderieren' into 'master'
Resolve "Zwischenseite nach Spielen z.B. Lernmodus, klassisches Moderieren, ..."

Closes #101

See merge request enrichment-2024/qivip!45
2025-04-14 18:24:48 +00:00
ben8
5b4c76a1c8 Zwischenstand Lernmodus 2025-04-14 20:23:47 +02:00
ben8
9d9e1527f4 Merge branch '102-verbesserung-der-anti-spring-funktion-bei-library' into 'master'
Resolve "Verbesserung der "Anti-Spring" Funktion bei library!"

Closes #102

See merge request enrichment-2024/qivip!46
2025-04-14 13:12:44 +00:00
ben8
70741a5a34 #102 fix Springen 2025-04-14 15:12:16 +02:00
ben8
8022602fc5 Zwischenauswahl_Spielmodus 2025-04-14 14:44:55 +02:00
ben8
f0dcc2baad vorrübergehende_Lösung-Suchleiste 2025-04-13 18:30:45 +02:00
ben8
2221c99ad7 Merge branch '99-suchleiste-design' into 'master'
Resolve "Suchleiste design abstand"

Closes #99

See merge request enrichment-2024/qivip!43
2025-04-13 16:17:21 +00:00
ben8
d84b44252e Darstellung Suchleiste 2025-04-13 18:16:58 +02:00
ben8
5a719de8ee Merge branch '55-quiz-zu-deinen-favoriten' into 'master'
Resolve "Quiz zu deinen Favoriten"

Closes #55

See merge request enrichment-2024/qivip!39
2025-04-13 15:33:20 +00:00
ben8
7c8c142e34 Richtige Sortierung! 2025-04-13 17:31:38 +02:00
ben8
093b0a7f83 Merge branch 'master' into 55-quiz-zu-deinen-favoriten 2025-04-13 17:07:11 +02:00
ben8
a4442fe12e Merge branch '98-betreten-form-reparieren' into 'master'
Resolve "Betreten form reparieren!"

Closes #98

See merge request enrichment-2024/qivip!42
2025-04-13 15:01:52 +00:00
ben8
a59df8dd62 #98 plus besser Darstellung der Suchleiste 2025-04-13 17:01:28 +02:00
ben8
80390de6dc Merge branch '96-suchleiste-uberall-anzeigen' into 'master'
Resolve "Suchleiste überall anzeigen"

Closes #96

See merge request enrichment-2024/qivip!41
2025-04-13 11:10:27 +00:00
ben8
5f05964b1b Suchleiste_überall #96 2025-04-13 13:09:39 +02:00
ben8
d53240c710 Favoriten_nur_wenn_angemeldet! 2025-04-11 20:29:55 +02:00
ben8
1f3b551b94 Favoriten 2025-04-11 19:31:12 +02:00
ben8
2a7e39bbd0 Favoritenfunktion! #55 2025-04-11 18:18:34 +02:00
ben8
04ce8104fe Fehler_behonben 2025-04-10 20:29:28 +02:00
ben8
7ce0e6e9dd Zwischenstand_Favoriten 2025-04-10 19:55:11 +02:00
ben8
77714d22a9 Zeit_bisher_im_Backend #76 2025-04-10 14:43:43 +02:00
nik8
f405016f96 zwischenstand 2025-04-09 14:22:46 +02:00
51 changed files with 1720 additions and 346 deletions

View File

@@ -7,6 +7,7 @@
3. [Superuser erstellen](#superuser-erstellen) 3. [Superuser erstellen](#superuser-erstellen)
4. [Tailwind-Nutzung](#tailwind-nutzung) 4. [Tailwind-Nutzung](#tailwind-nutzung)
5. [Issue - Merge Request - Merge](#issue-mergerequest-merge) 5. [Issue - Merge Request - Merge](#issue-mergerequest-merge)
6. [Konfiguration](#konfiguration)
## Initialisierung des Projekts ## Initialisierung des Projekts
@@ -61,6 +62,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 +92,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
@@ -138,6 +159,14 @@ Hier der vereinbarte Arbeitsablauf:
1. Issue erstellen - das geht am einfachsten über die Webseite. Der Titel beschreibt kurz und klar, was angestrebt wird. 1. Issue erstellen - das geht am einfachsten über die Webseite. Der Titel beschreibt kurz und klar, was angestrebt wird.
2. Merge Request anlegen - dadurch wird der Bransch automatisch angelegt. Dieser kann mit `git pull` geholt und mit `git checkout <branch>` bearbeitet werden. 2. Merge Request anlegen - dadurch wird der Branch automatisch angelegt. Dieser kann mit `git pull` geholt und mit `git checkout <branch>` bearbeitet werden.
3. Nach dem Hochladen kann der Merge in den Master durchgeführt werden. 3. Nach dem Hochladen kann der Merge in den Master durchgeführt werden.
## Konfiguration
Die Konfiguration des Projekts befindet sich im `config/qivip_config.json`.
| Variable | Bedeutung | Optionen |
| --- | --- | --- |
| ENABLE_RATING_SYSTEM | Soll das Bewertungssystem aktiviert werden? | true [Standard], false |

View File

@@ -0,0 +1,3 @@
{
"ENABLE_RATING_SYSTEM": true
}

View File

@@ -10,6 +10,7 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.1/ref/settings/ https://docs.djangoproject.com/en/5.1/ref/settings/
""" """
import json
from pathlib import Path from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
@@ -27,6 +28,9 @@ DEBUG = True
ALLOWED_HOSTS = ['127.0.0.1', 'localhost', '*'] ALLOWED_HOSTS = ['127.0.0.1', 'localhost', '*']
# Konfigurationsdatei für Projekt
with open(BASE_DIR / 'config' / 'qivip_config.json') as config_file:
QIVIP_CONFIG = json.load(config_file)
# Application definition # Application definition
@@ -43,6 +47,7 @@ INSTALLED_APPS = [
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'channels', 'channels',
'django_cleanup',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -78,12 +83,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

View File

@@ -1,5 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import QivipQuiz, QivipQuestion, QuizCategory from .models import QivipQuiz, QivipQuestion, QivipQuizFavorite, QuestionImage, QuizCategory, QuizImage
# Für das QivipQuiz Modell # Für das QivipQuiz Modell
class QivipQuizAdmin(admin.ModelAdmin): class QivipQuizAdmin(admin.ModelAdmin):
@@ -18,13 +18,22 @@ class QuizCategoryAdmin(admin.ModelAdmin):
search_fields = ('name',) search_fields = ('name',)
prepopulated_fields = {'slug': ('name',)} # Erstelle automatisch den Slug basierend auf dem Namen prepopulated_fields = {'slug': ('name',)} # Erstelle automatisch den Slug basierend auf dem Namen
# Für das Tag Modell
class TagAdmin(admin.ModelAdmin):
list_display = ('name',) # Zeige nur den Namen des Tags class QivipQuizFavoriteAdmin(admin.ModelAdmin):
search_fields = ('name',) list_display = ('user', 'favorite', 'quiz') # Welche Felder sollen in der Übersicht angezeigt werden
class QuizImageAdmin(admin.ModelAdmin):
list_display = ('image',)
class QuestionImageAdmin(admin.ModelAdmin):
list_display = ('image',)
# Registrierung der Modelle im Admin # Registrierung der Modelle im Admin
admin.site.register(QivipQuiz, QivipQuizAdmin) admin.site.register(QivipQuiz, QivipQuizAdmin)
admin.site.register(QivipQuestion, QivipQuestionAdmin) admin.site.register(QivipQuestion, QivipQuestionAdmin)
admin.site.register(QuizCategory, QuizCategoryAdmin) admin.site.register(QuizCategory, QuizCategoryAdmin)
admin.site.register(QivipQuizFavorite, QivipQuizFavoriteAdmin)
admin.site.register(QuizImage, QuizImageAdmin)
admin.site.register(QuestionImage, QuestionImageAdmin)

View File

@@ -4,12 +4,12 @@ from .models import QivipQuiz, QivipQuestion
class QuizForm(forms.ModelForm): class QuizForm(forms.ModelForm):
class Meta: class Meta:
model = QivipQuiz model = QivipQuiz
fields = ['name', 'description','image', 'status', 'category', "difficulty","credits"] fields = ['name', 'description', 'status', 'category','difficulty','credits']
class QuestionForm(forms.ModelForm): class QuestionForm(forms.ModelForm):
class Meta: class Meta:
model = QivipQuestion model = QivipQuestion
fields = ['quiz_id', 'data'] fields = ['quiz_id', 'data','time_per_question']
from django import forms from django import forms

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

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-04-08 14:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0032_qivipquiz_average_rating_qivipquiz_rating_count_and_more'),
]
operations = [
migrations.AddField(
model_name='qivipquestion',
name='time_per_question',
field=models.CharField(choices=[('10', 10), ('15', 15), ('30', 30), ('60', 60), ('90', 90), ('120', 120), ('180', 180), ('nicht gesetzt', 'nicht gesetzt')], default=30, help_text='Zeit pro Frage in Sekunden', max_length=13),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-04-08 14:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0033_qivipquestion_time_per_question'),
]
operations = [
migrations.AlterField(
model_name='qivipquestion',
name='time_per_question',
field=models.CharField(choices=[('10', 10), ('15', 15), ('30', 30), ('60', 60), ('90', 90), ('120', 120), ('180', 180)], default=30, help_text='Zeit pro Frage in Sekunden', max_length=13),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-04-08 14:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0034_alter_qivipquestion_time_per_question'),
]
operations = [
migrations.AlterField(
model_name='qivipquestion',
name='time_per_question',
field=models.CharField(choices=[('10', 10), ('15', 15), ('30', 30), ('60', 60), ('90', 90), ('120', 120), ('180', 180)], default=30, help_text='Zeit pro Frage in Sekunden', max_length=3),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-04-10 14:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0034_alter_qivipquiz_status'),
]
operations = [
migrations.AddField(
model_name='qivipquiz',
name='favorite',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,32 @@
# Generated by Django 5.1.7 on 2025-04-10 16:02
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0035_qivipquiz_favorite'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveField(
model_name='qivipquiz',
name='favorite',
),
migrations.CreateModel(
name='QuizFavorite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('favorite', models.BooleanField(default=False)),
('quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='library.qivipquiz')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'quiz')},
},
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.1.7 on 2025-04-10 16:05
from django.conf import settings
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('library', '0036_remove_qivipquiz_favorite_quizfavorite'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RenameModel(
old_name='QuizFavorite',
new_name='QivipQuizFavorite',
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.1.7 on 2025-04-10 17:08
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0037_rename_quizfavorite_qivipquizfavorite'),
]
operations = [
migrations.AlterField(
model_name='qivipquizfavorite',
name='quiz',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorites', to='library.qivipquiz'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-04-11 12:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0038_alter_qivipquizfavorite_quiz'),
]
operations = [
migrations.AddField(
model_name='qivipquizfavorite',
name='favorite_date',
field=models.DateTimeField(auto_now=True),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.1.7 on 2025-04-11 13:15
import library.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0039_qivipquizfavorite_favorite_date'),
]
operations = [
migrations.AlterField(
model_name='qivipquiz',
name='description',
field=models.TextField(default='In dem Quiz geht es um ...', max_length=200, validators=[library.models.QivipQuiz.validate_description], verbose_name='Beschreibung'),
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.1.7 on 2025-04-11 13:21
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0040_alter_qivipquiz_description'),
]
operations = [
migrations.AlterField(
model_name='qivipquiz',
name='category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quiz', to='library.quizcategory', verbose_name='Kategorie'),
),
migrations.AlterField(
model_name='qivipquiz',
name='difficulty',
field=models.CharField(choices=[('1', '1'), ('2', '2'), ('3', '3'), ('4', '4'), ('5', '5'), ('nicht gesetzt', 'nicht gesetzt')], default='nicht gesetzt', help_text='1: niedrigste Schwierigkeit und 5: höchste Schwierigkeit', max_length=13, verbose_name='Schwierigkeit'),
),
migrations.AlterField(
model_name='qivipquiz',
name='image',
field=models.ImageField(blank=True, max_length=256, null=True, upload_to='quiz_images/', verbose_name='Bild'),
),
migrations.AlterField(
model_name='qivipquiz',
name='name',
field=models.CharField(max_length=75, verbose_name='Name des Quizzes'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.1.7 on 2025-04-11 13:43
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0041_alter_qivipquiz_category_alter_qivipquiz_difficulty_and_more'),
]
operations = [
migrations.AddField(
model_name='qivipquiz',
name='favorite',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='quizzes', to='library.qivipquizfavorite'),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.1.7 on 2025-04-11 15:25
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('library', '0042_qivipquiz_favorite'),
]
operations = [
migrations.RemoveField(
model_name='qivipquiz',
name='favorite',
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.1.7 on 2025-04-11 16:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0043_remove_qivipquiz_favorite'),
]
operations = [
migrations.RemoveField(
model_name='qivipquizfavorite',
name='favorite_date',
),
migrations.AddField(
model_name='qivipquiz',
name='favorite_date',
field=models.DateTimeField(auto_now=True),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.1.7 on 2025-04-11 16:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0044_remove_qivipquizfavorite_favorite_date_and_more'),
]
operations = [
migrations.RemoveField(
model_name='qivipquiz',
name='favorite_date',
),
migrations.AddField(
model_name='qivipquizfavorite',
name='favorite_date',
field=models.DateTimeField(auto_now=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-04-11 16:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0045_remove_qivipquiz_favorite_date_and_more'),
]
operations = [
migrations.AlterField(
model_name='qivipquizfavorite',
name='favorite_date',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,14 @@
# Generated by Django 5.1.7 on 2025-04-18 11:34
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('library', '0035_alter_qivipquestion_time_per_question'),
('library', '0046_alter_qivipquizfavorite_favorite_date'),
]
operations = [
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-04-18 11:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0047_merge_20250418_1334'),
]
operations = [
migrations.AddField(
model_name='qivipquestion',
name='image',
field=models.ImageField(blank=True, height_field=900, max_length=200, null=True, upload_to='question_images/', verbose_name='Bild', width_field=1000),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-04-18 19:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0048_qivipquestion_image'),
]
operations = [
migrations.AlterField(
model_name='qivipquestion',
name='image',
field=models.ImageField(blank=True, null=True, upload_to='question_images/', verbose_name='Bild'),
),
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.1.7 on 2025-04-22 16:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0049_alter_qivipquestion_image'),
]
operations = [
migrations.CreateModel(
name='QuestionImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(blank=True, null=True, upload_to='question_images/', verbose_name='Bild')),
],
),
migrations.CreateModel(
name='QuizImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(blank=True, max_length=256, null=True, upload_to='quiz_images/', verbose_name='Bild')),
],
),
migrations.AlterField(
model_name='qivipquestion',
name='image',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='questions', to='library.questionimage'),
),
migrations.AlterField(
model_name='qivipquiz',
name='image',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='quizzes', to='library.QuizImage'),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.1.7 on 2025-04-23 12:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0050_questionimage_quizimages_alter_qivipquestion_image_and_more'),
]
operations = [
migrations.AlterField(
model_name='qivipquestion',
name='image',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='library.questionimage'),
),
migrations.AlterField(
model_name='qivipquiz',
name='image',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='quizzes', to='library.quizimage'),
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.1.7 on 2025-04-23 12:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0051_alter_qivipquestion_image_alter_qivipquiz_image'),
]
operations = [
migrations.AlterField(
model_name='qivipquestion',
name='image',
field=models.ImageField(blank=True, null=True, upload_to='question_images/', verbose_name='Bild'),
),
migrations.AlterField(
model_name='qivipquiz',
name='image',
field=models.ImageField(blank=True, max_length=256, null=True, upload_to='quiz_images/', verbose_name='Bild'),
),
migrations.DeleteModel(
name='QuestionImage',
),
migrations.DeleteModel(
name='QuizImage',
),
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.1.7 on 2025-04-23 13:15
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0052_alter_qivipquestion_image_alter_qivipquiz_image_and_more'),
]
operations = [
migrations.CreateModel(
name='QuestionImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(blank=True, null=True, upload_to='question_images/', verbose_name='Bild')),
],
),
migrations.CreateModel(
name='QuizImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(blank=True, max_length=256, null=True, upload_to='quiz_images/', verbose_name='Bild')),
],
),
migrations.AlterField(
model_name='qivipquestion',
name='image',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='questions', to='library.questionimage'),
),
migrations.AlterField(
model_name='qivipquiz',
name='image',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='quizzes', to='library.quizimage'),
),
]

View File

@@ -4,6 +4,15 @@ from django.utils.text import slugify
import uuid import uuid
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
class QuizImage(models.Model):
image = models.ImageField(max_length=256,verbose_name="Bild", upload_to='quiz_images/', blank=True, null=True) # Bild speichern in media/quiz_images/
class QuestionImage(models.Model):
image = models.ImageField(verbose_name="Bild", upload_to="question_images/", blank=True, null=True)
class QivipQuiz(models.Model): class QivipQuiz(models.Model):
STATUS_VALUES = { STATUS_VALUES = {
"öffentlich": "Öffentlich", "öffentlich": "Öffentlich",
@@ -19,12 +28,23 @@ class QivipQuiz(models.Model):
"nicht gesetzt":"nicht gesetzt", "nicht gesetzt":"nicht gesetzt",
} }
def validate_description(value): def validate_description(value):
if value.strip() == "In dem Quiz geht es um ...": if value.strip() == "In dem Quiz geht es um ...":
raise ValidationError("Bitte gib eine aussagekräftige Beschreibung ein.") raise ValidationError("Bitte gib eine aussagekräftige Beschreibung ein.")
image = models.ImageField(max_length=256, upload_to='quiz_images/', blank=True, null=True) # Bild speichern in media/quiz_images/ image = models.ForeignKey(
QuizImage,
null=True,
blank=True,
on_delete=models.PROTECT,
related_name="quizzes"
)
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
user_id = models.ForeignKey(User, on_delete=models.CASCADE, related_name='quiz') user_id = models.ForeignKey(User, on_delete=models.CASCADE, related_name='quiz')
#creator = models.ForeignKey(User, on_delete=models.SET_NULL,blank=True, null=True, related_name='creator_quiz') #creator = models.ForeignKey(User, on_delete=models.SET_NULL,blank=True, null=True, related_name='creator_quiz')
@@ -32,10 +52,10 @@ class QivipQuiz(models.Model):
creation_date = models.DateTimeField(auto_now_add=True) creation_date = models.DateTimeField(auto_now_add=True)
update_date = models.DateTimeField(auto_now=True) update_date = models.DateTimeField(auto_now=True)
status = models.CharField(max_length=10, choices=list(STATUS_VALUES.items()), default="öffentlich") status = models.CharField(max_length=10, choices=list(STATUS_VALUES.items()), default="öffentlich")
category = models.ForeignKey('QuizCategory', related_name='quiz', on_delete=models.CASCADE) category = models.ForeignKey('QuizCategory',verbose_name="Kategorie", related_name='quiz', on_delete=models.CASCADE)
name = models.CharField(max_length=75) name = models.CharField(max_length=75,verbose_name="Name des Quizzes")
description = models.TextField(validators=[validate_description],max_length=200,blank=False, default="In dem Quiz geht es um ...") description = models.TextField(validators=[validate_description],max_length=200,blank=False, default="In dem Quiz geht es um ...", verbose_name="Beschreibung")
difficulty= models.CharField(max_length=13, choices=list(DIFFICULTY_VALUES.items()), default="nicht gesetzt", help_text="1: niedrigste Schwierigkeit und 5: höchste Schwierigkeit") difficulty= models.CharField(verbose_name="Schwierigkeit", 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) credits=models.TextField(blank=True, editable=True)
average_rating = models.FloatField(default=3.0) average_rating = models.FloatField(default=3.0)
rating_count = models.IntegerField(default=0) rating_count = models.IntegerField(default=0)
@@ -43,15 +63,41 @@ class QivipQuiz(models.Model):
return self.name return self.name
class QivipQuizFavorite(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE) # Verknüpfung zu User
quiz = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE, related_name='favorites') # Verknüpfung zu Quiz
favorite = models.BooleanField(default=False) # Favoritenstatus
favorite_date = models.DateTimeField(null=True, blank=True)
class Meta:
unique_together = ('user', 'quiz') # Sicherstellen, dass es nur ein Favorit pro User und Quiz gibt
# Create your models here. # Create your models here.
class QivipQuestion(models.Model): class QivipQuestion(models.Model):
TIME_VALUES = {
"10": 10,
"15": 15,
"30": 30,
"60": 60,
"90": 90,
"120": 120,
"180": 180,
}
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
quiz_id = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE, related_name='questions') quiz_id = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE, related_name='questions')
creation_date = models.DateTimeField(auto_now_add=True) creation_date = models.DateTimeField(auto_now_add=True)
update_date = models.DateTimeField(auto_now=True) update_date = models.DateTimeField(auto_now=True)
data = models.TextField() data = models.TextField()
time_per_question = models.CharField(max_length=3, choices=list(TIME_VALUES.items()), default=30, help_text="Zeit pro Frage in Sekunden")
image = models.ForeignKey(
QuestionImage,
null=True,
blank=True,
on_delete=models.PROTECT,
related_name="questions"
)
def __str__(self): def __str__(self):
return self.data[:50] return self.data[:50]

View File

@@ -15,5 +15,6 @@ urlpatterns = [
path('question/edit/<int:pk>/', views.edit_question, name='edit_question'), path('question/edit/<int:pk>/', views.edit_question, name='edit_question'),
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'),
path('favorite_quiz/<int:pk>/', views.favorite_quiz, name='favorite_quiz'),
] ]

View File

@@ -1,10 +1,12 @@
from django.utils import timezone
import uuid import uuid
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.urls import reverse from django.urls import reverse
from django.contrib import messages from django.contrib import messages
from .models import QivipQuiz, QivipQuestion from .models import QivipQuiz, QivipQuestion, QivipQuizFavorite, QuestionImage, QuizImage
from .forms import QuizForm, QuestionForm from .forms import QuizForm, QuestionForm
import json import json
from django.core.paginator import Paginator from django.core.paginator import Paginator
@@ -17,12 +19,23 @@ from django.contrib.auth.models import User
def overview_quiz(request): def overview_quiz(request):
# Filter # Filter
form = QuizFilterForm(request.GET) form = QuizFilterForm(request.GET)
try:
favorite_quizzes_data = QivipQuizFavorite.objects.filter(user=request.user, favorite=True).order_by("-favorite_date")
favorite_quiz_ids = favorite_quizzes_data.values_list('quiz', flat=True)
favorite_quizzes = QivipQuiz.objects.filter(id__in=favorite_quiz_ids)
except:
favorite_quizzes_data = QivipQuizFavorite.objects.none()
favorite_quizzes = QivipQuizFavorite.objects.none()
favorite_quiz_ids = favorite_quizzes.none()
favorite_quizzes = QivipQuiz.objects.none()
# Initialize querysets # Initialize querysets
if request.user.is_authenticated: if request.user.is_authenticated:
filter_my_quizzes = QivipQuiz.objects.filter(user_id=request.user).annotate(max_amout_questions=Count('questions')) filter_my_quizzes = QivipQuiz.objects.filter(user_id=request.user).annotate(max_amout_questions=Count('questions'))
favorite_quizzes = favorite_quizzes.filter(id__in=favorite_quiz_ids).annotate(max_amout_questions=Count('questions'))
filter_other_quizzes = QivipQuiz.objects.exclude(user_id=request.user) filter_other_quizzes = QivipQuiz.objects.exclude(user_id=request.user)
else: else:
favorite_quizzes = QivipQuiz.objects.none()
filter_my_quizzes = QivipQuiz.objects.none() filter_my_quizzes = QivipQuiz.objects.none()
filter_other_quizzes = QivipQuiz.objects.all() filter_other_quizzes = QivipQuiz.objects.all()
@@ -30,11 +43,49 @@ def overview_quiz(request):
filter_other_quizzes = filter_other_quizzes.annotate(max_amout_questions=Count('questions')) 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') filter_other_quizzes = filter_other_quizzes.filter(max_amout_questions__gte=1, status='öffentlich')
if form.is_valid(): if form.is_valid():
search = form.cleaned_data.get('search') search = form.cleaned_data.get('search')
username= form.cleaned_data.get('user') username= form.cleaned_data.get('user')
max_amout_questions= form.cleaned_data.get('max_amout_questions') max_amout_questions= form.cleaned_data.get('max_amout_questions')
min_amout_questions= form.cleaned_data.get('min_amout_questions') min_amout_questions= form.cleaned_data.get('min_amout_questions')
filters = {}
if username:
try:
user = User.objects.get(username=username)
filters['user_id'] = user.id
except User.DoesNotExist:
favorite_quizzes = QivipQuiz.objects.none()
filter_my_quizzes = QivipQuiz.objects.none()
filter_other_quizzes=QivipQuiz.objects.none()
if search:
filters['name__icontains'] = search
if min_amout_questions:
filters['max_amout_questions__gte'] = min_amout_questions
if max_amout_questions:
filters['max_amout_questions__lte'] = max_amout_questions
try:
filter_other_quizzes = filter_other_quizzes.filter(**filters)
except:
filter_other_quizzes=QivipQuiz.objects.none()
try:
filter_my_quizzes = filter_my_quizzes.filter(**filters)
except:
filter_my_quizzes=QivipQuiz.objects.none()
try:
favorite_quizzes = favorite_quizzes.filter(**filters)
except:
favorite_quizzes=QivipQuiz.objects.none()
"""
if search: # Suche nach Namen if search: # Suche nach Namen
filter_other_quizzes = filter_other_quizzes.filter(name__icontains=search) filter_other_quizzes = filter_other_quizzes.filter(name__icontains=search)
filter_my_quizzes = filter_my_quizzes.filter(name__icontains=search) filter_my_quizzes = filter_my_quizzes.filter(name__icontains=search)
@@ -63,9 +114,9 @@ def overview_quiz(request):
filter_other_quizzes=filter_other_quizzes.exclude(user_id=request.user) filter_other_quizzes=filter_other_quizzes.exclude(user_id=request.user)
except: except:
filter_other_quizzes=filter_other_quizzes filter_other_quizzes=filter_other_quizzes
"""
# Pagination for my quizzes # Pagination for my quizzes
my_quizzes_paginator = Paginator(filter_my_quizzes.order_by('-creation_date'), 8) # 8 quizzes per page my_quizzes_paginator = Paginator(filter_my_quizzes.order_by('-creation_date'), 8) # 8 quizzes per page
try: try:
@@ -80,29 +131,89 @@ def overview_quiz(request):
except: except:
other_quizzes = other_quizzes_paginator.page(1) other_quizzes = other_quizzes_paginator.page(1)
try:
favorite_quizzes = favorite_quizzes.filter(
favorites__user=request.user, # Filtere nach dem User
favorites__favorite=True # Filtere nach favorisierten Quizzes
).order_by('-favorites__favorite_date')
except:
pass
# Pagination for favorite_quizzes
favorite_quizzes_paginator = Paginator(favorite_quizzes, 8) # 8 quizzes per page
try:
favorite_quizzes = favorite_quizzes_paginator.page(request.GET.get('my_page', 1))
except:
favorite_quizzes = favorite_quizzes_paginator.page(1)
context = { context = {
'show_search': True, 'show_search': True,
'favorite_quiz_ids': favorite_quiz_ids,
'favorite_quizzes':favorite_quizzes,
'my_quizzes': my_quizzes, 'my_quizzes': my_quizzes,
'other_quizzes': other_quizzes, 'other_quizzes': other_quizzes,
'form': form, 'form': form,
} }
return render(request, 'library/overview_quiz.html', context) return render(request, 'library/overview_quiz.html', context)
# Neues Quiz erstellen
@login_required
def favorite_quiz(request, pk):
quiz= get_object_or_404(QivipQuiz, pk=pk)
favorite = QivipQuizFavorite.objects.filter(user=request.user, quiz=quiz).first()
if favorite:
favorite.favorite=not favorite.favorite
if favorite.favorite==True:
favorite.favorite_date = timezone.now()
statement="Das Quiz '"+ favorite.quiz.name+ "' wurde erfolgreich zu den Favoriten hinzugefügt!"
messages.success(request,statement )
favorite.save()
else:
favorite.favorite_date = None
favorite.delete()
statement="Das Quiz '"+ favorite.quiz.name+ "' wurde erfolgreich von den Favoriten entfernt!"
messages.error(request, statement)
else:
QivipQuizFavorite.objects.create(user=request.user, quiz=quiz, favorite=True, favorite_date=timezone.now())
return redirect(request.META.get('HTTP_REFERER', 'library:overview_quiz'))
# Neues Quiz erstellen # Neues Quiz erstellen
@login_required @login_required
def new_quiz(request): def new_quiz(request):
if request.method == 'POST': if request.method == 'POST':
form = QuizForm(request.POST, request.FILES) form = QuizForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
quiz = form.save(commit=False) quiz = form.save(commit=False)
try:
quiz_image=request.FILES['quiz_image']
imagequiz=QuizImage.objects.create(image=quiz_image)
quiz.image=imagequiz
except:
pass
quiz.user_id = request.user quiz.user_id = request.user
#quiz.creator = request.user #quiz.creator = request.user
quiz.save() quiz.save()
form.save_m2m() # Speichert die Many-to-Many Beziehungen (Tags) #form.save_m2m() # Speichert die Many-to-Many Beziehungen (Tags)
return redirect('library:detail_quiz', pk=quiz.pk) return redirect('library:detail_quiz', pk=quiz.pk)
else: else:
@@ -116,8 +227,28 @@ def edit_quiz(request, pk):
if request.method == 'POST': if request.method == 'POST':
form = QuizForm(request.POST, instance=quiz, files=request.FILES) form = QuizForm(request.POST, instance=quiz, files=request.FILES)
if form.is_valid(): if form.is_valid():
if 'reset_image' in request.POST:
quiz.image = None
try:
quiz_image=request.FILES['quiz_image']
imagequiz=QuizImage.objects.create(image=quiz_image)
quiz.image=imagequiz
except:
pass
form.save() form.save()
for img in QuizImage.objects.all():
try:
img.delete()
img.image.delete(save=False)
except:
pass
return redirect('library:detail_quiz', pk=pk) return redirect('library:detail_quiz', pk=pk)
#return modified(request, pk) #return modified(request, pk)
@@ -131,6 +262,19 @@ def delete_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)
if request.method == 'POST': if request.method == 'POST':
quiz.delete() quiz.delete()
for img in QuizImage.objects.all():
try:
img.delete()
img.image.delete(save=False)
except:
pass
for img in QuestionImage.objects.all():
try:
img.delete()
img.image.delete(save=False)
except:
pass
return redirect('library:overview_quiz') return redirect('library:overview_quiz')
return render(request, 'library/delete_confirmation.html', {'object': quiz}) return render(request, 'library/delete_confirmation.html', {'object': quiz})
@@ -151,37 +295,12 @@ def detail_quiz(request, pk):
'detail':show_answers, 'detail':show_answers,
} }
return render(request, 'library/detail_quiz.html', context) return render(request, 'library/detail_quiz.html', context)
"""
def modified(request, pk):
original_quiz = get_object_or_404(QivipQuiz, pk=pk)
words=original_quiz.modified_description.split()
word_exist=0
print(original_quiz.creator.username)
print(request.user.username)
for word in words:
if word==request.user.username or word+","==request.user.username:
word_exist=1
if word_exist==0 and len(words)!=0 and request.user.username!=original_quiz.creator.username:
original_quiz.modified_description +=", "+ request.user.username
elif word_exist==0 and request.user.username!=original_quiz.creator.username:
original_quiz.modified_description +=" "+ request.user.username
original_quiz.save()
return redirect('library:detail_quiz', pk=pk)
"""
@login_required @login_required
def copy_quiz(request, pk): def copy_quiz(request, pk):
original_quiz = get_object_or_404(QivipQuiz, pk=pk) original_quiz = get_object_or_404(QivipQuiz, pk=pk)
# Quiz kopieren (ohne ID, damit ein neues Objekt erstellt wird) # Quiz kopieren (ohne ID, damit ein neues Objekt erstellt wird)
@@ -192,9 +311,10 @@ def copy_quiz(request, pk):
new_quiz.pk = None # Setzt die ID auf None, damit Django es als neues Objekt erkennt new_quiz.pk = None # Setzt die ID auf None, damit Django es als neues Objekt erkennt
new_quiz.uuid = uuid.uuid4() # Neue UUID generieren new_quiz.uuid = uuid.uuid4() # Neue UUID generieren
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"
new_quiz.base_quiz_id = pk new_quiz.base_quiz_id = pk
#new_quiz.creator = original_quiz.creator or request.user new_quiz.rating_count = 0
new_quiz.average_rating = 3.0
new_quiz.save() # Speichern als neues Objekt new_quiz.save() # Speichern als neues Objekt
# Optional: Beschreibung anpassen # Optional: Beschreibung anpassen
# Alle zugehörigen Fragen kopieren # Alle zugehörigen Fragen kopieren
@@ -204,6 +324,7 @@ def copy_quiz(request, pk):
question.pk = None # Setze pk auf None, damit es als neues Objekt gespeichert wird question.pk = None # Setze pk auf None, damit es als neues Objekt gespeichert wird
question.quiz_id = original_quiz # Verknüpfe mit dem neuen Quiz question.quiz_id = original_quiz # Verknüpfe mit dem neuen Quiz
question.data = question.data # `data`-Feld wird explizit übernommen # Verknüpfe mit dem neuen Quiz question.data = question.data # `data`-Feld wird explizit übernommen # Verknüpfe mit dem neuen Quiz
question.save() question.save()
messages.success(request, "Quiz wurde erfolgreich kopiert!") messages.success(request, "Quiz wurde erfolgreich kopiert!")
@@ -241,7 +362,7 @@ def new_question(request):
if question_type not in ['multiple_choice', 'true_false']: if question_type not in ['multiple_choice', 'true_false']:
base_url = reverse('library:new_question') base_url = reverse('library:new_question')
return redirect(f'{base_url}?type=multiple_choice&quiz_id={quiz_id}') return redirect(f'{base_url}?type=multiple_choice&quiz_id={quiz_id}')
if request.method == 'POST': if request.method == 'POST':
question_text = request.POST.get('question', '') question_text = request.POST.get('question', '')
@@ -283,6 +404,15 @@ def new_question(request):
question = QivipQuestion() question = QivipQuestion()
question.data = json.dumps(json_data) question.data = json.dumps(json_data)
question.quiz_id = quiz question.quiz_id = quiz
try:
question_image=request.FILES['question_image']
imagequestion=QuestionImage.objects.create(image=question_image)
question.image=imagequestion
except:
pass
question.time_per_question = request.POST.get('time_per_question', '30')
question.save() question.save()
#return modified(request, pk=quiz.pk) #return modified(request, pk=quiz.pk)
return redirect('library:detail_quiz', pk=quiz.pk) return redirect('library:detail_quiz', pk=quiz.pk)
@@ -297,6 +427,7 @@ def new_question(request):
{'value': 'Falsch', 'is_correct': False} {'value': 'Falsch', 'is_correct': False}
] ]
} }
standard_time_per_question = 30
elif question_type == 'multiple_choice': elif question_type == 'multiple_choice':
question_data = { question_data = {
'type': question_type, 'type': question_type,
@@ -306,12 +437,14 @@ def new_question(request):
{'value': '', 'is_correct': False} {'value': '', 'is_correct': False}
] ]
} }
standard_time_per_question = 30
template_name = f'library/question/question_{question_type}.html' template_name = f'library/question/question_{question_type}.html'
context = { context = {
'question': question_data, 'question': question_data,
'quiz_id': quiz_id, 'quiz_id': quiz_id,
'quiz': quiz 'quiz': quiz,
'standard_time_per_question': standard_time_per_question,
} }
return render(request, template_name, context) return render(request, template_name, context)
@@ -320,6 +453,8 @@ def new_question(request):
@login_required @login_required
def edit_question(request, pk): def edit_question(request, pk):
question = get_object_or_404(QivipQuestion, pk=pk, quiz_id__user_id=request.user) question = get_object_or_404(QivipQuestion, pk=pk, quiz_id__user_id=request.user)
if 'reset_ques_image' in request.POST:
question.image = None
if question.quiz_id.user_id != request.user: if question.quiz_id.user_id != request.user:
return redirect('library:question_list') return redirect('library:question_list')
@@ -327,6 +462,7 @@ def edit_question(request, pk):
try: try:
question_data = json.loads(question.data) if question.data else {} question_data = json.loads(question.data) if question.data else {}
question_type = question_data.get('type', 'multiple_choice') question_type = question_data.get('type', 'multiple_choice')
question_image = question.image
# For true/false questions, get the correct answer from the options # For true/false questions, get the correct answer from the options
if question_type == 'true_false': if question_type == 'true_false':
@@ -371,7 +507,13 @@ def edit_question(request, pk):
if request.method == 'POST': if request.method == 'POST':
question_text = request.POST.get('question', '') question_text = request.POST.get('question', '')
try:
question_image=request.FILES['question_image']
imagequestion=QuestionImage.objects.create(image=question_image)
question.image=imagequestion
except Exception:
pass
# Handle different question types # Handle different question types
if question_type == 'true_false': if question_type == 'true_false':
correct_answer = request.POST.get('correct_answer') == 'true' correct_answer = request.POST.get('correct_answer') == 'true'
@@ -412,7 +554,16 @@ def edit_question(request, pk):
} }
question.data = json.dumps(json_data) question.data = json.dumps(json_data)
question.time_per_question = request.POST.get('time_per_question', '30')
question.save() question.save()
for img in QuestionImage.objects.all():
try:
img.delete()
img.image.delete(save=False)
except:
pass
#return modified(request, question.quiz_id.pk) #return modified(request, question.quiz_id.pk)
return redirect('library:detail_quiz', pk=question.quiz_id.pk) return redirect('library:detail_quiz', pk=question.quiz_id.pk)
@@ -421,7 +572,9 @@ def edit_question(request, pk):
context = { context = {
'question': {'data': question_data}, # Wrap in data structure expected by template 'question': {'data': question_data}, # Wrap in data structure expected by template
'quiz_id': question.quiz_id.pk, 'quiz_id': question.quiz_id.pk,
'quiz': question.quiz_id 'quiz': question.quiz_id,
'time_per_question': question.time_per_question,
'image': question_image,
} }
return render(request, template_name, context) return render(request, template_name, context)
@@ -438,5 +591,11 @@ def delete_question(request, pk):
if request.method == 'POST': if request.method == 'POST':
question.delete() question.delete()
for img in QuestionImage.objects.all():
try:
img.delete()
img.image.delete(save=False)
except:
pass
return redirect('library:detail_quiz', pk=question.quiz_id.pk) return redirect('library:detail_quiz', pk=question.quiz_id.pk)
return render(request, 'library/delete_confirmation.html', {'object': question}) return render(request, 'library/delete_confirmation.html', {'object': question})

View File

@@ -4,80 +4,10 @@ from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async from channels.db import database_sync_to_async
from django.utils import timezone from django.utils import timezone
from django.urls import reverse from django.urls import reverse
from django.db import models
from datetime import timedelta
from play.models import QuizGame, QuizGameParticipant, QuizAnswer from play.models import QuizGame, QuizGameParticipant, QuizAnswer
from django.conf import settings
# 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 +20,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 +72,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 +162,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:
@@ -341,6 +242,8 @@ class GameConsumer(AsyncWebsocketConsumer):
@database_sync_to_async @database_sync_to_async
def save_rating(self, participant_id, rating): def save_rating(self, participant_id, rating):
if not settings.QIVIP_CONFIG['ENABLE_RATING_SYSTEM']:
return False
from library.models import QuizRating from library.models import QuizRating
try: try:
game = QuizGame.objects.get(join_code=self.join_code) game = QuizGame.objects.get(join_code=self.join_code)
@@ -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:

View File

@@ -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:

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:

View File

@@ -12,4 +12,6 @@ urlpatterns = [
path('game/<str:join_code>/waiting', views.waiting_room, name='waiting'), path('game/<str:join_code>/waiting', views.waiting_room, name='waiting'),
path('game/<str:join_code>/scores', views.scores, name='scores'), path('game/<str:join_code>/scores', views.scores, name='scores'),
path('game/<str:join_code>/finished', views.finished, name='finished'), path('game/<str:join_code>/finished', views.finished, name='finished'),
path('select_mode/<str:join_code>', views.select_mode, name='select_mode'),
path('selected_mode/<str:join_code>', views.selected_mode, name='selected_mode'),
] ]

View File

@@ -1,14 +1,20 @@
from django.utils import timezone
from django.shortcuts import render 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
from django.http import HttpResponseRedirect, HttpResponseNotFound from django.http import HttpResponseRedirect, HttpResponseNotFound
from django.contrib import messages from django.contrib import messages
from django.conf import settings
import json import json
# Create your views here. # Create your views here.
def lobby(request, join_code): def lobby(request, join_code):
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)
context = { context = {
@@ -16,11 +22,19 @@ def lobby(request, join_code):
'quiz_game': quiz_game, 'quiz_game': quiz_game,
} }
mode = request.GET.get('mode','default')
request.session['mode'] = mode
if "host_id" in request.COOKIES: if "host_id" in request.COOKIES:
host_id = request.COOKIES['host_id'] host_id = request.COOKIES['host_id']
if host_id == quiz_game.host_id: if host_id == quiz_game.host_id:
context['host_id'] = host_id context['host_id'] = host_id
return render(request, 'play/lobby.html', context=context) return render(request, 'play/lobby.html', context=context)
elif mode=="learn":
return redirect('play:create_participant', join_code=join_code)
if not "participant_id" in request.COOKIES: if not "participant_id" in request.COOKIES:
return redirect('play:create_participant', join_code=join_code) return redirect('play:create_participant', join_code=join_code)
@@ -38,10 +52,17 @@ def lobby(request, join_code):
context['participant'] = participant 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 select_mode(request,join_code):
quiz_game = get_object_or_404(QuizGame, join_code=join_code) return render(request, 'play/select_mode.html', {'join_code': join_code})
if "participant_id" in request.COOKIES:
def create_participant(request, join_code):
mode = request.GET.get('mode','default')
request.session['mode'] = mode
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
if mode!="learn":
if "participant_id" in request.COOKIES:
# Prüfe ob der Teilnehmer noch existiert # Prüfe ob der Teilnehmer noch existiert
participant_id = request.COOKIES['participant_id'] participant_id = request.COOKIES['participant_id']
try: try:
@@ -57,7 +78,7 @@ def create_participant(request, join_code):
response.delete_cookie('participant_id') response.delete_cookie('participant_id')
return response return response
if request.method == 'POST': if request.method == 'POST':
display_name = request.POST.get('display_name') display_name = request.POST.get('display_name')
if not display_name: if not display_name:
return render(request, 'play/initialize_participant.html', { return render(request, 'play/initialize_participant.html', {
@@ -75,9 +96,12 @@ def create_participant(request, join_code):
response.set_cookie('participant_id', participant.participant_id, max_age=3600) response.set_cookie('participant_id', participant.participant_id, max_age=3600)
return response return response
return render(request, 'play/initialize_participant.html', {'join_code': join_code}) return render(request, 'play/initialize_participant.html', {'join_code': join_code})
else:
messages.error(request, "Beitritt nicht möglich.")
return render(request, 'play/join_game.html')
def create_game(request, quiz_id): def create_game(request, quiz_id):
try: try:
quiz = QivipQuiz.objects.get(id=quiz_id) quiz = QivipQuiz.objects.get(id=quiz_id)
game = QuizGame() game = QuizGame()
@@ -85,7 +109,8 @@ def create_game(request, quiz_id):
game.host_id = game.generate_unique_id() game.host_id = game.generate_unique_id()
game.save() game.save()
response = HttpResponseRedirect(reverse('play:lobby', kwargs={'join_code': game.join_code}))
response = HttpResponseRedirect(reverse('play:select_mode', kwargs={'join_code': game.join_code}))
response.set_cookie('host_id', game.host_id, max_age=3600) response.set_cookie('host_id', game.host_id, max_age=3600)
return response return response
@@ -120,7 +145,8 @@ def finished(request, join_code):
context = { context = {
'join_code': join_code, 'join_code': join_code,
'is_host': str(game.host_id) == str(request.session.get('host_id')), 'is_host': str(game.host_id) == str(request.session.get('host_id')),
'participant_id': participant_id if participant else None 'participant_id': participant_id if participant else None,
'rating_enabled': settings.QIVIP_CONFIG['ENABLE_RATING_SYSTEM']
} }
return render(request, 'play/game/finished.html', context) return render(request, 'play/game/finished.html', context)
except QuizGame.DoesNotExist: except QuizGame.DoesNotExist:
@@ -128,7 +154,7 @@ def finished(request, join_code):
def scores(request, join_code): def scores(request, join_code):
quiz_game = get_object_or_404(QuizGame, join_code=join_code) quiz_game = get_object_or_404(QuizGame, join_code=join_code)
# Verify game state # Verify game state
if quiz_game.current_state != 'scores': if quiz_game.current_state != 'scores':
return redirect('play:lobby', join_code=join_code) return redirect('play:lobby', join_code=join_code)
@@ -146,9 +172,18 @@ def scores(request, join_code):
current_question = questions[quiz_game.current_question_index] current_question = questions[quiz_game.current_question_index]
question_data = json.loads(current_question.data) question_data = json.loads(current_question.data)
try:
my_participant = request.session.get('my_participant-participant_id')
participant = QuizGameParticipant.objects.get(participant_id=my_participant)
my_participant=participant
except:
participant = QuizGameParticipant.objects.none()
return render(request, 'play/game/score_overview.html', { return render(request, 'play/game/score_overview.html', {
'quiz_game': quiz_game, 'quiz_game': quiz_game,
'participants': participants, 'participants': participants,
'participant': participant,
'my_participant':my_participant,
'is_host': is_host, 'is_host': is_host,
'host_id': quiz_game.host_id if is_host else None, 'host_id': quiz_game.host_id if is_host else None,
'is_last_question': quiz_game.current_question_index + 1 >= len(questions), 'is_last_question': quiz_game.current_question_index + 1 >= len(questions),
@@ -210,10 +245,12 @@ def question(request, join_code):
return render(request, 'play/game/question_host.html', { return render(request, 'play/game/question_host.html', {
'quiz_game': quiz_game, 'quiz_game': quiz_game,
'question_data': question_data, 'question_data': question_data,
'current_question':current_question,
'host_id': quiz_game.host_id, 'host_id': quiz_game.host_id,
'start_time': int(quiz_game.question_start_time.timestamp() * 1000), 'start_time': int(quiz_game.question_start_time.timestamp() * 1000),
'question_index': quiz_game.current_question_index, 'question_index': quiz_game.current_question_index,
'total_questions': len(questions) 'total_questions': len(questions),
'question_image': current_question.image
}) })
# Handle participant view # Handle participant view
@@ -223,16 +260,20 @@ def question(request, join_code):
participant_id = request.COOKIES['participant_id'] participant_id = request.COOKIES['participant_id']
try: try:
participant = QuizGameParticipant.objects.get(participant_id=participant_id) participant = QuizGameParticipant.objects.get(participant_id=participant_id)
except QuizGameParticipant.DoesNotExist: except QuizGameParticipant.DoesNotExist:
return redirect('play:create_participant', join_code=join_code) return redirect('play:create_participant', join_code=join_code)
request.session['my_participant-participant_id'] = participant.participant_id
return render(request, 'play/game/question_participant.html', { return render(request, 'play/game/question_participant.html', {
'quiz_game': quiz_game, 'quiz_game': quiz_game,
'question_data': question_data, 'question_data': question_data,
'current_question':current_question,
'participant': participant, 'participant': participant,
'start_time': int(quiz_game.question_start_time.timestamp() * 1000), 'start_time': int(quiz_game.question_start_time.timestamp() * 1000),
'question_index': quiz_game.current_question_index, 'question_index': quiz_game.current_question_index,
'total_questions': len(questions) 'total_questions': len(questions),
}) })
def waiting_room(request, join_code): def waiting_room(request, join_code):
@@ -255,4 +296,43 @@ def waiting_room(request, join_code):
return render(request, 'play/game/wait_for_other_players.html', { return render(request, 'play/game/wait_for_other_players.html', {
'quiz_game': quiz_game, 'quiz_game': quiz_game,
'participant': participant 'participant': participant
}) })
def selected_mode(request, join_code):
mode = request.GET.get('mode','default')
request.session['mode'] = mode
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
quiz = get_object_or_404(QivipQuiz, pk=quiz_game.quiz_id.id)
QuizGameParticipant.objects.filter(quiz_game=quiz_game).delete()
context = {
'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
if mode=="learn":
participant = QuizGameParticipant()
if request.user.is_authenticated:
participant.display_name = request.user.username
else:
participant.display_name = "SIE"
participant.participant_id=host_id
participant.quiz_game = quiz_game
participant.score = 0 # Initialize score
participant.save()
quiz_game.current_state = "question"
quiz_game.question_start_time = timezone.now()
quiz_game.save()
return redirect('play:question',join_code)
return render(request, 'play/lobby.html', context=context)

View File

@@ -7,6 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{% static 'css/t-style.css' %}"> <link rel="stylesheet" href="{% static 'css/t-style.css' %}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/@tsparticles/confetti@3.0.3/tsparticles.confetti.bundle.min.js"></script>
<title>qivip</title> <title>qivip</title>
</head> </head>
<body> <body>
@@ -66,6 +67,8 @@
}, duration); }, duration);
} }
}; };
if (window.location.pathname !== "/library/") {
localStorage.setItem("meine_seite_scroll", 0);}
</script> </script>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
</body> </body>

View File

@@ -22,8 +22,9 @@
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /> <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg> </svg>
</a> </a>
</div>
{% endif %} {% endif %}
</div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<a href="{% url 'library:copy_quiz' quiz.id %}" <a href="{% url 'library:copy_quiz' quiz.id %}"
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors duration-200"> class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors duration-200">
@@ -33,7 +34,8 @@
</svg> </svg>
Quiz kopieren Quiz kopieren
</a> </a>
{% if quiz.questions.count != 0 %}
<a href="{% url 'play:create_game' quiz.id %}" <a href="{% url 'play:create_game' quiz.id %}"
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors duration-200"> class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors duration-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -43,7 +45,8 @@
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
Spielen Spielen
</a> </a>
{% endif %}
</div> </div>
</div> </div>
@@ -190,24 +193,26 @@
<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 ">Bearbeitung:<span class="font-bold"> {{ quiz.user_id}}</span></p> <p class="text-gray-600 mt-2 ">Bearbeitung:<span class="text-blue-600 font-bold"> <a href="{% url 'library:overview_quiz' %}?filter=1&user={{ quiz.user_id }}">{{ quiz.user_id }}</a></span></p>
{% else %}
<p class="text-gray-600 mt-2 ">Autor:<span class="text-blue-600 font-bold"> <a href="{% url 'library:overview_quiz' %}?filter=1&user={{ quiz.user_id }}">{{ quiz.user_id }}</a></span></p>
{% endif %} {% endif %}
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
{% if quiz.base_quiz %} {% if quiz.base_quiz %}
<p class="text-gray-600 ">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 ">Kopie von: <a href="{% url 'library:detail_quiz' quiz.base_quiz.id %}"><span class="text-blue-600 font-bold">{{ quiz.base_quiz}}</span></a></p>
{% endif %} {% endif %}
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
{% if quiz.base_quiz %} {% if quiz.base_quiz %}
<p class="text-gray-600 ">Autor:<span class="font-bold"> {{ quiz.base_quiz.user_id}}</span></p> <p class="text-gray-600 ">Autor:<span class="text-blue-600 font-bold"> <a href="{% url 'library:overview_quiz' %}?filter=1&user={{ quiz.base_quiz.user_id }}">{{ quiz.base_quiz.user_id }}</a></span></p>
{% endif %} {% endif %}
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
{% if quiz.credits %} {% if quiz.credits %}
<p class="text-gray-600 mb-2">Credits:<span class="font-bold"> {{ quiz.credits }}</span></p> <p class="text-gray-600 mb-2">Credits:<span class="text-blue-600 font-bold"> {{ quiz.credits }}</span></p>
{% endif %} {% endif %}
</div> </div>

View File

@@ -5,6 +5,21 @@
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
{% if form.instance.image %}
<img src="{{ form.instance.image.image.url }}" style="max-width: 100px;" alt="Bild der Frage">
<label for="reset_image">
<input type="checkbox" name="reset_image" id="reset_image">
Bild zurücksetzen
</label>
{% endif %}
<div>
<label class="font-bold" for="quiz_image">Bild:</label>
<input class="bg-blue-200 p-2 m-2 rounded-lg border-2 border-blue-400" type="file" name="quiz_image" id="quiz_image">
</div>
<div class="flex justify-center space-x-3"> <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 %}" <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"> 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">

View File

@@ -51,32 +51,34 @@
<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 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" value="{{ form.min_amout_questions.value|default_if_none:'' }}">
<div class="space-y-2"> </div>
<label class="block text-sm font-medium text-gray-700">Maximale 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" value="{{ form.max_amout_questions.value|default_if_none:'' }}">
<div class="space-y-2"> </div>
<label class="block text-sm font-medium text-gray-700">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" value="{{ form.user.value|default_if_none:'' }}">
<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>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<div class="container mx-auto px-4 sm:px-6 lg:px-8 mt-8 mb-6"> <div class="container mx-auto px-4 sm:px-6 lg:px-8 mt-8 mb-6">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -92,13 +94,42 @@
<!-- Image Section (Top) --> <!-- Image Section (Top) -->
<div class="h-48 w-full relative flex-shrink-0"> <div class="h-48 w-full relative flex-shrink-0">
{% if quiz.image %} {% if quiz.image %}
<div class="absolute inset-0 bg-cover bg-center" style="background-image: url({{ quiz.image.url }});"></div> <div class="relative w-full h-full inset-0 bg-cover bg-center">
<!-- Hintergrundbild -->
<div class="absolute inset-0 bg-cover bg-center"
style="background-image: url({{ quiz.image.image.url }});">
</div>
</div>
{% else %} {% else %}
<div class="h-full bg-gradient-to-r from-blue-500 to-blue-600"></div> <div class="h-full bg-gradient-to-r from-blue-500 to-blue-600"></div>
{% endif %} {% endif %}
{% if user.is_authenticated %}
{% if quiz.id not in favorite_quiz_ids %}
<div class="absolute top-4 right-4 bg-white bg-opacity-60 text-white px-4 py-2 rounded-lg shadow-lg">
<a href="{% url 'library:favorite_quiz' quiz.pk %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="text-black size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</a>
</div>
{% else %}
<div class="absolute top-4 right-4 bg-white bg-opacity-60 text-white px-4 py-2 rounded-lg shadow-lg">
<a href="{% url 'library:favorite_quiz' quiz.pk %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="text-yellow-400 size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</a>
</div>
{% endif %}
{% endif %}
</div> </div>
<!-- Content Section (Bottom) --> <!-- Content Section (Bottom) -->
<div class="flex-grow bg-white p-4 flex flex-col gap-3"> <div class="flex-grow bg-white p-4 flex flex-col gap-3">
<!-- Title --> <!-- Title -->
@@ -136,6 +167,7 @@
<!-- Rating --> <!-- Rating -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
{% if quiz.rating_count > 0 %}
{% for i in '12345'|make_list %} {% for i in '12345'|make_list %}
{% if forloop.counter <= quiz.average_rating|floatformat:0|add:0 %} {% if forloop.counter <= quiz.average_rating|floatformat:0|add:0 %}
<span class="text-yellow-500 text-base"></span> <span class="text-yellow-500 text-base"></span>
@@ -144,6 +176,9 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<span class="text-gray-600 text-sm">({{ quiz.rating_count }})</span> <span class="text-gray-600 text-sm">({{ quiz.rating_count }})</span>
{% else %}
<span class="text-gray-600 text-sm">Noch keine Bewertungen</span>
{% endif %}
</div> </div>
</div> </div>
@@ -163,6 +198,7 @@
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex justify-between items-center gap-2 mt-3 border-t pt-3"> <div class="flex justify-between items-center gap-2 mt-3 border-t pt-3">
{% if quiz.questions.count != 0 %}
<a href="{% url 'play:create_game' quiz.id %}" <a href="{% url 'play:create_game' quiz.id %}"
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors duration-200 flex items-center gap-2"> class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors duration-200 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -171,6 +207,9 @@
</svg> </svg>
Spielen Spielen
</a> </a>
{% else %}
Fürs Spielen ist min. 1 Frage nötig!
{% endif %}
<div class="flex gap-1"> <div class="flex gap-1">
<a href="{% url 'library:detail_quiz' quiz.id %}" <a href="{% url 'library:detail_quiz' quiz.id %}"
class="p-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors duration-200"> class="p-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors duration-200">
@@ -179,7 +218,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg> </svg>
</a> </a>
{% if quiz.user == request.user %} {% if quiz.user_id == request.user %}
<a href="{% url 'library:edit_quiz' quiz.id %}" <a href="{% url 'library:edit_quiz' quiz.id %}"
class="p-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors duration-200"> class="p-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors duration-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -228,6 +267,207 @@
</div> </div>
{% endif %} {% endif %}
{% if user.is_authenticated %}
<div class="container mx-auto px-4 sm:px-6 lg:px-8 mt-8 mb-6">
<div class="flex items-center gap-3">
<h2 id="my-quizzes" class="text-2xl font-bold text-gray-900">Meine Favoriten</h2>
<div class="h-0.5 flex-grow bg-gradient-to-r from-blue-600 to-transparent rounded-full"></div>
</div>
</div>
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 auto-rows-fr">
{% for quiz in favorite_quizzes %}
<article class="relative min-h-[20rem] rounded-xl shadow-lg overflow-hidden bg-white flex flex-col">
<!-- Image Section (Top) -->
<div class="h-48 w-full relative flex-shrink-0">
{% if quiz.image %}
<div class="relative w-full h-full inset-0 bg-cover bg-center">
<!-- Hintergrundbild -->
<div class="absolute inset-0 bg-cover bg-center"
style="background-image: url({{ quiz.image.image.url }});">
</div>
</div>
{% else %}
<div class="h-full bg-gradient-to-r from-blue-500 to-blue-600"></div>
{% endif %}
{% if user.is_authenticated %}
{% if quiz.favorite == False %}
<div class="absolute top-4 right-4 bg-white bg-opacity-60 text-white px-4 py-2 rounded-lg shadow-lg">
<a href="{% url 'library:favorite_quiz' quiz.pk %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="text-black size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</a>
</div>
{% else %}
<div class="absolute top-4 right-4 bg-white bg-opacity-60 text-white px-4 py-2 rounded-lg shadow-lg">
<a href="{% url 'library:favorite_quiz' quiz.pk %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="text-yellow-400 size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</a>
</div>
{% endif %}
{% endif %}
</div>
<!-- Content Section (Bottom) -->
<div class="flex-grow bg-white p-4 flex flex-col gap-3">
<!-- Title -->
<h2 class="text-xl font-bold text-gray-900 break-words mb-2">
<a href="{% url 'library:detail_quiz' quiz.id %}" class="hover:text-blue-600 transition-colors">{{ quiz.name }}</a>
</h2>
<!-- Description -->
<p class="text-sm text-gray-700 break-words mb-3">{{ quiz.description }}</p>
<!-- Quiz Info Grid -->
<div class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<!-- Difficulty -->
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="text-gray-800">{{ quiz.difficulty }}</span>
</div>
<!-- Questions Count -->
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-gray-800">{{ quiz.questions.count }} Fragen</span>
</div>
<!-- Status -->
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-gray-800">{{ quiz.status }}</span>
</div>
<!-- Rating -->
<div class="flex items-center gap-1">
{% if quiz.rating_count > 0 %}
{% for i in '12345'|make_list %}
{% if forloop.counter <= quiz.average_rating|floatformat:0|add:0 %}
<span class="text-yellow-500 text-base"></span>
{% else %}
<span class="text-gray-300 text-base"></span>
{% endif %}
{% endfor %}
<span class="text-gray-600 text-sm">({{ quiz.rating_count }})</span>
{% else %}
<span class="text-gray-600 text-sm">Noch keine Bewertungen</span>
{% endif %}
</div>
</div>
<!-- Authors -->
{% if quiz.authors.all %}
<div class="flex items-center gap-2 text-sm mt-2">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span class="text-gray-600">
{% for author in quiz.authors.all %}
{{ author.username }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</span>
</div>
{% endif %}
<!-- Action Buttons -->
<div class="flex justify-between items-center gap-2 mt-3 border-t pt-3">
{% if quiz.questions.count != 0 %}
<a href="{% url 'play:create_game' quiz.id %}"
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors duration-200 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Spielen
</a>
{% else %}
Fürs Spielen ist min. 1 Frage nötig!
{% endif %}
<div class="flex gap-1">
<a href="{% url 'library:detail_quiz' quiz.id %}"
class="p-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors duration-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</a>
{% if quiz.user_id == request.user %}
<a href="{% url 'library:edit_quiz' quiz.id %}"
class="p-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors duration-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</a>
<a href="{% url 'library:delete_quiz' quiz.id %}"
class="p-1.5 bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors duration-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</a>
{% endif %}
</div>
</div>
</div>
</article>
{% endfor %}
</div>
<!-- Pagination für eigene Quiz -->
{% if favorite_quizzes.paginator.num_pages > 1 %}
<div class="flex justify-center space-x-2 mt-6 mb-8">
{% if favorite_quizzes.has_previous %}
<a href="?my_page={{ my_quizzes.previous_page_number }}{% if request.GET.other_page %}&other_page={{ request.GET.other_page }}{% endif %}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
class="px-3 py-1 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</a>
{% endif %}
<span class="px-3 py-1 text-gray-600">Seite {{ favorite_quizzes.number }} von {{ favorite_quizzes.paginator.num_pages }}</span>
{% if my_quizzes.has_next %}
<a href="?my_page={{ my_quizzes.next_page_number }}{% if request.GET.other_page %}&other_page={{ request.GET.other_page }}{% endif %}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
class="px-3 py-1 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</a>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
<!-- Quiz von anderen Nutzern --> <!-- Quiz von anderen Nutzern -->
<div class="container mx-auto px-4 sm:px-6 lg:px-8 mt-8 mb-6"> <div class="container mx-auto px-4 sm:px-6 lg:px-8 mt-8 mb-6">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -244,11 +484,39 @@
<!-- Image Section (Top) --> <!-- Image Section (Top) -->
<div class="h-48 w-full relative flex-shrink-0"> <div class="h-48 w-full relative flex-shrink-0">
{% if quiz.image %} {% if quiz.image %}
<div class="absolute inset-0 bg-cover bg-center" style="background-image: url({{ quiz.image.url }});"></div> <div class="relative w-full h-full inset-0 bg-cover bg-center">
<!-- Hintergrundbild -->
<div class="absolute inset-0 bg-cover bg-center"
style="background-image: url({{ quiz.image.image.url }});">
</div>
</div>
{% else %} {% else %}
<div class="h-full bg-gradient-to-r from-blue-500 to-blue-600"></div> <div class="h-full bg-gradient-to-r from-blue-500 to-blue-600"></div>
{% endif %} {% endif %}
{% if user.is_authenticated %}
{% if quiz.id not in favorite_quiz_ids %}
<div class="absolute top-4 right-4 bg-white bg-opacity-60 text-white px-4 py-2 rounded-lg shadow-lg">
<a href="{% url 'library:favorite_quiz' quiz.pk %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="text-black size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</a>
</div>
{% else %}
<div class="absolute top-4 right-4 bg-white bg-opacity-60 text-white px-4 py-2 rounded-lg shadow-lg">
<a href="{% url 'library:favorite_quiz' quiz.pk %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="text-yellow-400 size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</a>
</div>
{% endif %}
{% endif %}
</div> </div>
<!-- Content Section (Bottom) --> <!-- Content Section (Bottom) -->
@@ -288,6 +556,7 @@
<!-- Rating --> <!-- Rating -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
{% if quiz.rating_count > 0 %}
{% for i in '12345'|make_list %} {% for i in '12345'|make_list %}
{% if forloop.counter <= quiz.average_rating|floatformat:0|add:0 %} {% if forloop.counter <= quiz.average_rating|floatformat:0|add:0 %}
<span class="text-yellow-500 text-base"></span> <span class="text-yellow-500 text-base"></span>
@@ -296,6 +565,9 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<span class="text-gray-600 text-sm">({{ quiz.rating_count }})</span> <span class="text-gray-600 text-sm">({{ quiz.rating_count }})</span>
{% else %}
<span class="text-gray-600 text-sm">Noch keine Bewertungen</span>
{% endif %}
</div> </div>
</div> </div>
@@ -315,6 +587,7 @@
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex justify-between items-center gap-2 mt-3 border-t pt-3"> <div class="flex justify-between items-center gap-2 mt-3 border-t pt-3">
{% if quiz.questions.count != 0 %}
<a href="{% url 'play:create_game' quiz.id %}" <a href="{% url 'play:create_game' quiz.id %}"
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors duration-200 flex items-center gap-2"> class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors duration-200 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -323,6 +596,9 @@
</svg> </svg>
Spielen Spielen
</a> </a>
{% else %}
Fürs Spielen ist min. 1 Frage nötig!
{% endif %}
<div class="flex gap-1"> <div class="flex gap-1">
<a href="{% url 'library:detail_quiz' quiz.id %}" <a href="{% url 'library:detail_quiz' quiz.id %}"
class="p-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors duration-200"> class="p-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors duration-200">
@@ -370,9 +646,39 @@
{% 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/") {
window.addEventListener("beforeunload", function () {
localStorage.setItem("meine_seite_scroll", window.scrollY);
});
window.addEventListener("load", function () {
const scrollPos = localStorage.getItem("meine_seite_scroll");
if (scrollPos !== null) {
window.scrollTo(0, parseInt(scrollPos));
}
});
}
</script> </script>
{% endblock content %} {% endblock content %}

View File

@@ -9,9 +9,8 @@
Zurück zum Quiz Zurück zum Quiz
</a> </a>
</div> </div>
<div class="bg-white rounded-lg shadow-md p-6 border-blue-600 border-2 rounded-xl shadow-md"> <div class="bg-white rounded-lg shadow-md p-6 border-blue-600 border-2 rounded-xl shadow-md">
<form method="post" class="space-y-6"> <form method="post" class="space-y-6" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<!-- Question Text --> <!-- Question Text -->
@@ -24,6 +23,19 @@
</div> </div>
</div> </div>
{% if image %}
<img src="{{ image.image.url }}" style="max-width: 500px;" alt="Bild der Frage">
<label for="reset_image">
<input type="checkbox" name="reset_ques_image" id="reset_ques_image">
Bild zurücksetzen
</label>
{% endif %}
<div>
<label class="font-bold" for="question_image">Bild:</label>
<input value="image.url" class="bg-blue-200 p-2 m-2 rounded-lg border-2 border-blue-400" type="file" name="question_image" id="question_image">
</div>
<!-- Options --> <!-- Options -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Antwortmöglichkeiten</label> <label class="block text-sm font-medium text-gray-700 mb-2">Antwortmöglichkeiten</label>
@@ -92,6 +104,19 @@
</button> </button>
</div> </div>
<div class="p-4">
<label class="font-bold" for="time_per_question">Zeit pro Frage:</label>
<select name="time_per_question" id="time_per_question">
<option value="10" {% if standard_time_per_question == "10" or time_per_question == "10" %}selected{% endif %}>10 Sekunden</option>
<option value="20" {% if standard_time_per_question == "20" or time_per_question == "20" %}selected{% endif %}>20 Sekunden</option>
<option value="30" {% if standard_time_per_question == "30" or time_per_question == "30" %}selected{% endif %}>30 Sekunden</option>
<option value="45" {% if standard_time_per_question == "45" or time_per_question == "45" %}selected{% endif %}>45 Sekunden</option>
<option value="60" {% if standard_time_per_question == "60" or time_per_question == "60" %}selected{% endif %}>1 Minute</option>
<option value="120" {% if standard_time_per_question == "120" or time_per_question == "120" %}selected{% endif %}>2 Minuten</option>
<option value="300" {% if standard_time_per_question == "300" or time_per_question == "300" %}selected{% endif %}>5 Minuten</option>
</select>
</div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<a href="{% url 'library:detail_quiz' quiz.id %}" <a href="{% url 'library:detail_quiz' quiz.id %}"
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"> 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">

View File

@@ -11,7 +11,7 @@
</div> </div>
<div class="bg-white rounded-lg shadow-md p-6 border-blue-600 border-2 rounded-xl shadow-md"> <div class="bg-white rounded-lg shadow-md p-6 border-blue-600 border-2 rounded-xl shadow-md">
<form method="post" class="space-y-6"> <form method="post" class="space-y-6" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<!-- Question Text --> <!-- Question Text -->
@@ -24,6 +24,18 @@
</div> </div>
</div> </div>
{% if image %}
<img src="{{ image.image.url }}" style="max-width: 500px;" alt="Bild der Frage">
<label for="reset_image">
<input type="checkbox" name="reset_ques_image" id="reset_ques_image">
Bild zurücksetzen
</label>
{% endif %}
<div>
<label class="font-bold" for="question_image">Bild:</label>
<input class="bg-blue-200 p-2 m-2 rounded-lg border-2 border-blue-400" type="file" name="question_image" id="question_image">
</div>
<!-- True/False Selection --> <!-- True/False Selection -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Richtige Antwort</label> <label class="block text-sm font-medium text-gray-700 mb-2">Richtige Antwort</label>
@@ -43,6 +55,19 @@
</div> </div>
</div> </div>
<div class="p-4">
<label class="font-bold" for="time_per_question">Zeit pro Frage:</label>
<select name="time_per_question" id="time_per_question">
<option value="10" {% if standard_time_per_question == "10" or time_per_question == "10" %}selected{% endif %}>10 Sekunden</option>
<option value="20" {% if standard_time_per_question == "20" or time_per_question == "20" %}selected{% endif %}>20 Sekunden</option>
<option value="30" {% if standard_time_per_question == "30" or time_per_question == "30" %}selected{% endif %}>30 Sekunden</option>
<option value="45" {% if standard_time_per_question == "45" or time_per_question == "45" %}selected{% endif %}>45 Sekunden</option>
<option value="60" {% if standard_time_per_question == "60" or time_per_question == "60" %}selected{% endif %}>1 Minute</option>
<option value="120" {% if standard_time_per_question == "120" or time_per_question == "120" %}selected{% endif %}>2 Minuten</option>
<option value="300" {% if standard_time_per_question == "300" or time_per_question == "300" %}selected{% endif %}>5 Minuten</option>
</select>
</div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<a href="{% url 'library:detail_quiz' quiz.id %}" <a href="{% url 'library:detail_quiz' quiz.id %}"
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"> 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">

View File

@@ -1,34 +1,121 @@
<nav class="flex justify-between items-center bg-blue-600 h-12 px-4 sm:px-6 lg:px-8 rounded-lg m-2 shadow-md overflow-x-auto whitespace-nowrap"> <nav id="main-nav" class="relative bg-blue-600 rounded-lg mt-1 m-2 shadow-md transition-all duration-300">
<div class="flex items-center flex-shrink-0"> <!-- Desktop Navigation Bar -->
<h2 class="text-xl font-bold text-white hover:scale-110 transition duration-200"> <div class="flex justify-between items-center h-12 px-4 sm:px-6 overflow-x-auto whitespace-nowrap">
<a href="{% url 'homepage:home' %}">qivip</a> <!-- Logo / Left section -->
</h2> <div class="flex items-center flex-shrink-0 md:w-32">
<h2 class="text-xl font-bold text-white hover:scale-110 transition duration-200">
<a href="{% url 'homepage:home' %}">qivip</a>
</h2>
</div>
<!-- Desktop Search (centered on screen) - Hidden on mobile -->
<div class="hidden md:flex justify-center items-center absolute left-1/2 transform -translate-x-1/2 min-w-40">
<form method="get" action="{% url 'library:overview_quiz' %}" class="mr-2 ml-2 flex items-center justify-center w-screen max-w-sm bg-white rounded-full px-4 py-1 shadow-md">
<input type="text" name="search" placeholder="Suche ..." value="{{ request.GET.search }}"
class="text-sm w-full px-3 py-1 text-black bg-transparent focus:outline-none lg:text-base">
<button class="py-1 text-blue-600">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5 md:size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
</button>
</form>
</div>
<!-- Desktop Navigation Links - Hidden on mobile -->
<div class="hidden md:flex items-center space-x-4 md:w-40">
<ul class="flex space-x-4 qp-nav-list">
{% if show_search %}
<li><a href="{% url 'library:overview_quiz' %}">Bibliothek</a></li>
{% else%}
<li><a href="{% url 'library:overview_quiz' %}">Bibliothek</a></li>
{% endif %}
{% if user.is_authenticated %}
<li><a href="{% url 'accounts:home' %}">Konto</a></li>
{% else %}
<li><a href="{% url 'accounts:login' %}">Anmelden</a></li>
{% endif %}
</ul>
</div>
<!-- Mobile menu button - Only visible on mobile -->
<div class="flex md:hidden items-center">
<button id="mobile-menu-button" class="text-white focus:outline-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</div> </div>
{% if show_search %} <!-- Mobile Navigation Menu - Hidden by default - FULLWIDTH DROPDOWN -->
<form method="get" class="mr-2 ml-2 flex items-center w-full max-w-sm bg-white rounded-full px-4 py-1 shadow-md"> <div id="mobile-menu" class="hidden absolute w-full z-50 md:hidden opacity-0 transform -translate-y-2 transition-all duration-300">
<input type="text" name="search" placeholder="Suche ..." value="{{ request.GET.search }}" <div class="bg-blue-500 pt-3 pb-4 rounded-b-lg shadow-inner">
class="w-full px-3 py-1 text-black bg-transparent focus:outline-none"> <!-- Search in mobile menu -->
<button type="submit" class=" py-1 text-blue-600"> <form method="get" action="{% url 'library:overview_quiz' %}" class="px-4 mb-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> <div class="flex items-center justify-center bg-white rounded-full px-4 py-2 shadow-md">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /> <input type="text" name="search" placeholder="Suche ..." value="{{ request.GET.search }}"
</svg> class="w-full text-sm px-2 text-black bg-transparent focus:outline-none">
</button> <button class="text-blue-600">
</form> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
{% endif %} stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
</button>
</div>
</form>
<div class="flex items-center space-x-4"> <!-- Mobile Navigation Links -->
<ul class="flex space-x-4 qp-nav-list"> <div class="space-y-2 px-4">
{% if show_search %} <a href="{% url 'library:overview_quiz' %}" class="block py-2 text-white font-medium rounded-lg hover:bg-blue-600 transition duration-200">
<li class="hidden md:flex"><a href="{% url 'library:overview_quiz' %}">Bibliothek</a></li> Bibliothek
{% else%} </a>
<li><a href="{% url 'library:overview_quiz' %}">Bibliothek</a></li> {% if user.is_authenticated %}
{% endif %} <a href="{% url 'accounts:home' %}" class="block py-2 text-white font-medium rounded-lg hover:bg-blue-600 transition duration-200">
{% if user.is_authenticated %} Konto
<li><a href="{% url 'accounts:home' %}">Konto</a></li> </a>
{% else %} {% else %}
<li><a href="{% url 'accounts:login' %}">Anmelden</a></li> <a href="{% url 'accounts:login' %}" class="block py-2 text-white font-medium rounded-lg hover:bg-blue-600 transition duration-200">
{% endif %} Anmelden
</ul> </a>
{% endif %}
</div>
</div>
</div> </div>
</nav> </nav>
<!-- Mobile Menu Script -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const mobileMenuButton = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
const mainNav = document.getElementById('main-nav');
mobileMenuButton.addEventListener('click', function() {
// Toggle menu visibility
if (mobileMenu.classList.contains('hidden')) {
// Show menu
mobileMenu.classList.remove('hidden');
// Entferne unten abgerundete Ecken der Navbar wenn Menü offen
mainNav.classList.remove('rounded-lg');
mainNav.classList.add('rounded-t-lg');
// Animation einfahren
setTimeout(() => {
mobileMenu.classList.remove('opacity-0', 'transform', '-translate-y-2');
mobileMenu.classList.add('opacity-100');
}, 50);
} else {
// Ausfahren Animation
mobileMenu.classList.add('opacity-0', 'transform', '-translate-y-2');
mobileMenu.classList.remove('opacity-100');
// Nach der Animation verstecken
setTimeout(() => {
mobileMenu.classList.add('hidden');
// Stelle die abgerundeten Ecken wieder her
mainNav.classList.remove('rounded-t-lg');
mainNav.classList.add('rounded-lg');
}, 300);
}
});
});
</script>

View File

@@ -7,10 +7,11 @@
<div class="bg-white rounded-lg shadow-md p-8 mb-6"> <div class="bg-white rounded-lg shadow-md p-8 mb-6">
<div class="text-center mb-8"> <div class="text-center mb-8">
<h1 class="text-4xl font-bold text-blue-600 mb-4">Quiz beendet!</h1> <h1 class="text-4xl font-bold text-blue-600 mb-4">Quiz beendet!</h1>
<p class="text-xl text-gray-600">Vielen Dank fürs Mitspielen!</p> <p class="text-xl text-gray-600">Vielen Dank fürs Spielen!</p>
</div> </div>
{% if not is_host %} {% if not is_host %}
{% if rating_enabled %}
<div class="bg-blue-50 rounded-lg p-6 mb-8"> <div class="bg-blue-50 rounded-lg p-6 mb-8">
<h2 class="text-2xl font-bold text-blue-800 mb-4 text-center">Wie hat dir das Quiz gefallen?</h2> <h2 class="text-2xl font-bold text-blue-800 mb-4 text-center">Wie hat dir das Quiz gefallen?</h2>
<div class="stars flex justify-center space-x-4 mb-4"> <div class="stars flex justify-center space-x-4 mb-4">
@@ -22,6 +23,9 @@
</div> </div>
<p id="rating-message" class="text-center text-blue-600 font-medium"></p> <p id="rating-message" class="text-center text-blue-600 font-medium"></p>
</div> </div>
{% else %}
<p class="text-center text-blue-600 font-medium mb-8">Bewertungen sind deaktiviert</p>
{% endif %}
{% endif %} {% endif %}
<div class="text-center"> <div class="text-center">

View File

@@ -1,46 +1,74 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
{% if request.session.mode == "learn" %}
<div class="container mx-auto px-4"> <div class="container mx-auto px-4">
<div class="flex justify-end mb-4">
<button onclick="leaveGame()" class="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded">
Spiel verlassen
</button>
</div>{% endif %}
<div class="bg-white rounded-lg shadow-md p-6 mb-6"> <div class="bg-white rounded-lg shadow-md p-6 mb-6">
<p class="text-gray-700 font-bold text-xl mb-4">Frage {{ question_index|add:1 }} von {{ total_questions }}</p> <p class="text-gray-700 font-bold text-xl mb-4">Frage {{ question_index|add:1 }} von {{ total_questions }}</p>
<h1 class="text-3xl font-bold mb-6">{{ question_data.question }}</h1> <h1 class="text-3xl font-bold mb-6">{{ question_data.question }}</h1>
{% if question_image %}
<div class="flex justify-center">
<img class="m-4" src="{{ question_image.image.url }}" style="max-width: 500px;" alt="Bild der Frage">
</div>
{% endif %}
{% if request.session.mode == "learn" %}
<div class="grid grid-cols-1 gap-4 mb-6">
{% else %}
<div class="grid grid-cols-2 gap-4 mb-6"> <div class="grid grid-cols-2 gap-4 mb-6">
{% endif %}
{% if request.session.mode != "learn" %}
<div class="p-4 bg-blue-50 rounded-lg"> <div class="p-4 bg-blue-50 rounded-lg">
<p class="text-blue-600 text-xl mb-2">Verbleibende Zeit</p> <p class="text-blue-600 text-xl mb-2">Verbleibende Zeit </p>
<p id="remaining-time" class="text-6xl font-bold text-blue-700">30</p> <p id="remaining-time" class="text-6xl font-bold text-blue-700">30</p>
</div> </div>
{% endif %}
<div class="p-4 bg-blue-50 rounded-lg"> <div class="p-4 bg-blue-50 rounded-lg">
<p class="text-blue-600 text-xl mb-2">Antworten</p> <p class="text-blue-600 text-xl mb-2">Antworten</p>
<p id="answer-count" class="text-6xl font-bold text-blue-700">0/0</p> <p id="answer-count" class="text-6xl font-bold text-blue-700">0/0</p>
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4" id="options-container">
{% for option in question_data.options %} {% for option in question_data.options %}
<div class="p-4 bg-gray-100 rounded-lg" data-correct="{{ option.is_correct }}"> <button
class="p-4 bg-gray-100 hover:bg-blue-100 rounded-lg transition-colors duration-200 answer-option"
data-index="{{ forloop.counter0 }}"
{% if request.session.mode == "learn" %}
onclick="submitAnswer({{ forloop.counter0 }});" {% endif %}>
<p class="text-lg">{{ option.value }}</p> <p class="text-lg">{{ option.value }}</p>
<p id="option-count-{{ forloop.counter0 }}" class="text-gray-600 hidden">0 Antworten</p> </button>
</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
{% include 'play/game/common_scripts.html' %} {% include 'play/game/common_scripts.html' %}
<script> <script>
const joinCode = '{{ quiz_game.join_code }}'; const joinCode = '{{ quiz_game.join_code }}';
const hostId = '{{ host_id }}'; const hostId = '{{ host_id }}';
const questionStartTime = {{ start_time }}; const questionStartTime = {{ start_time }};
const questionDuration = 30000; // 30 seconds in milliseconds const questionDuration = '{{ current_question.time_per_question }}'*1000; //Zeit pro Frage (indviduell eingestellt)
const gameSocket = initializeGameSocket(joinCode); const gameSocket = initializeGameSocket(joinCode);
let answeredParticipants = 0; let answeredParticipants = 0;
let totalParticipants = 0; let totalParticipants = 0;
const optionCounts = {}; const optionCounts = {};
gameSocket.onmessage = function(e) { gameSocket.onmessage = function(e) {
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
if (data.type === 'participant_answer') { if (data.type === 'participant_answer') {
@@ -85,7 +113,7 @@
// Show correct answers and answer counts // Show correct answers and answer counts
var correctOptions = document.querySelectorAll('[data-correct="True"]'); var correctOptions = document.querySelectorAll('[data-correct="True"]');
for (var i = 0; i < correctOptions.length; i++) { for (var i = 0; i < correctOptions.length; i++) {
correctOptions[i].classList.add('border-2', 'border-green-500'); correctOptions[i].classList.add('border-2', 'border-blue-500');
} }
// Show answer counts // Show answer counts
@@ -104,6 +132,7 @@
} }
// Timer // Timer
{% if request.session.mode != "learn" %}
function updateTimer() { function updateTimer() {
const now = Date.now(); const now = Date.now();
const elapsed = now - questionStartTime; const elapsed = now - questionStartTime;
@@ -118,6 +147,9 @@
} }
} }
{% endif %}
// Request initial participants list // Request initial participants list
gameSocket.onopen = function(e) { gameSocket.onopen = function(e) {
gameSocket.send(JSON.stringify({ gameSocket.send(JSON.stringify({
@@ -125,5 +157,51 @@
})); }));
updateTimer(); updateTimer();
}; };
let hasAnswered = false;
const participantId = '{{ host_id }}'; // HIER IST DER HOST DER SPIELER
function leaveGame() {
if (confirm('Möchtest du das Spiel wirklich verlassen?')) {
gameSocket.send(JSON.stringify({
type: 'leave_game',
participant_id: participantId
}));
window.location.href = '/';
gameSocket.close();
}
}
function submitAnswer(optionIndex) {
if (hasAnswered) return;
hasAnswered = true;
const now = Date.now();
const timeRemaining = Math.max(0, questionDuration - (now - questionStartTime));
// Disable all options and highlight selected
const options = document.getElementsByClassName('answer-option');
for (let option of options) {
option.disabled = true;
option.classList.remove('hover:bg-blue-100');
if (parseInt(option.dataset.index) === optionIndex) {
option.classList.remove('bg-gray-100');
option.classList.add('bg-blue-600', 'text-white');
} else {
option.classList.add('opacity-50');
}
}
// Send answer to server
gameSocket.send(JSON.stringify({
type: 'submit_answer',
participant_id: participantId,
answer: optionIndex,
time_remaining: timeRemaining
}));
}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -37,7 +37,7 @@
const joinCode = '{{ quiz_game.join_code }}'; const joinCode = '{{ quiz_game.join_code }}';
const participantId = '{{ participant.participant_id }}'; const participantId = '{{ participant.participant_id }}';
const questionStartTime = {{ start_time }}; const questionStartTime = {{ start_time }};
const questionDuration = 30000; // 30 seconds in milliseconds const questionDuration = '{{ current_question.time_per_question }}'*1000; //Zeit pro Frage (indviduell eingestellt)
const gameSocket = initializeGameSocket(joinCode); const gameSocket = initializeGameSocket(joinCode);
gameSocket.onmessage = function(e) { gameSocket.onmessage = function(e) {

View File

@@ -10,7 +10,7 @@
<p class="text-lg mb-2">{{ question_data.question }}</p> <p class="text-lg mb-2">{{ question_data.question }}</p>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
{% for option in question_data.options %} {% for option in question_data.options %}
<div class="p-4 rounded-lg {% if option.is_correct %}bg-green-100 border-2 border-green-500{% else %}bg-gray-100{% endif %}"> <div class="p-4 rounded-lg {% if option.is_correct %}bg-green-100 border-2 border-green-500 {% else %} bg-gray-100 {% endif %}">
<p class="text-lg">{{ option.value }}</p> <p class="text-lg">{{ option.value }}</p>
<p class="text-gray-600" id="option-count-{{ forloop.counter0 }}">0 Antworten</p> <p class="text-gray-600" id="option-count-{{ forloop.counter0 }}">0 Antworten</p>
</div> </div>
@@ -18,41 +18,108 @@
</div> </div>
</div> </div>
{% if request.session.mode != "learn" %}
<div class="mb-6"> <div class="mb-6">
<h2 class="text-xl font-bold mb-4">Punktestand</h2> <h2 class="text-xl font-bold mb-4">Punktestand</h2>
<div class="space-y-2"> <div id="scoreboard" class="space-y-2">
{% for participant in participants %} {% for participant in participants %}
<div class="p-4 {% if forloop.first %}bg-yellow-100 border-2 border-yellow-500{% else %}bg-gray-100{% endif %} rounded-lg flex justify-between items-center"> {% if participant == my_participant or is_host %}
<div> <div class="p-4 bg-gray-100 rounded-lg flex justify-between items-center border {% if forloop.counter <= 3 %}border-yellow-500{% else %}border-transparent{% endif %}">
<p class="text-lg font-bold">{{ participant.display_name }}</p> <div>
<p class="text-gray-600">{{ participant.score }} Punkte</p> <p class="text-lg font-bold">
{% if forloop.counter == 1 %}
🥇
{% elif forloop.counter == 2 %}
🥈
{% elif forloop.counter == 3 %}
🥉
{% endif %}
{{ participant.display_name }}
</p>
<p class="text-gray-600">{{ participant.score }} Punkte</p>
</div>
{% if participant.last_answer_correct %}
<span class="text-green-500"></span>
{% elif participant.last_answer_correct == False %}
<span class="text-red-500"></span>
{% endif %}
</div> </div>
{% if participant.last_answer_correct %} {% endif %}
<span class="text-green-500"></span>
{% elif participant.last_answer_correct == False %}
<span class="text-red-500"></span>
{% endif %}
</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
{% endif %}
{% if is_host %} {% if is_host %}
{% if is_last_question %} {% if is_last_question %}
<button id="finish-button" class="w-full p-3 rounded-full bg-blue-600 text-white font-bold hover:bg-blue-700 transition-colors duration-200"> <button id="finish-button" class="w-full p-3 rounded-full bg-blue-600 text-white font-bold hover:bg-blue-700 transition-colors duration-200">
Quiz beenden Quiz beenden
</button> </button>
{% else %} {% else %}
<button id="next-button" class="w-full p-3 rounded-full bg-blue-600 text-white font-bold hover:bg-blue-700 transition-colors duration-200"> <button id="next-button" class="w-full p-3 rounded-full bg-blue-600 text-white font-bold hover:bg-blue-700 transition-colors duration-200">
Nächste Frage Nächste Frage
</button> </button>
{% endif %}
{% endif %} {% endif %}
{% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
{% if is_last_question %}
<script>
// Hilfsfunktion für Pausen
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
document.addEventListener('DOMContentLoaded', async function() {
const items = document.querySelectorAll('#scoreboard div');
const top3 = Array.from(items).slice(0, 3); // Nur die ersten 3 animieren
// Sofort Startzustände für Top3 setzen, die anderen sofort sichtbar
items.forEach((el, index) => {
if (index >= 3) {
el.style.opacity = '1';
el.style.transform = 'translateY(0)';
} else {
el.style.opacity = '0';
el.style.transform = 'translateY(20px)';
}
});
// Warten bevor Animation startet
await sleep(1500); // <<< hier die 5 Sekunden Pause
// Transition-Eigenschaft setzen nach Render-Zyklus
requestAnimationFrame(() => {
top3.forEach(el => {
el.style.transition = 'all 0.8s ease';
});
// Animation für Top 3 starten von Platz 3 zu 1
top3.reverse().forEach((el, index) => {
setTimeout(() => {
el.style.opacity = '1';
el.style.transform = 'translateY(0)';
if (index === top3.length - 1) {
triggerConfetti();
}
}, index * 1500); // Zeitversetzt aufdecken
});
});
});
function triggerConfetti() {
const end = Date.now() + 15 * 1000;
(function frame() {
confetti({ particleCount: 2, angle: 60, spread: 55, origin: { x: 0 } });
confetti({ particleCount: 2, angle: 120, spread: 55, origin: { x: 1 } });
if (Date.now() < end) requestAnimationFrame(frame);
})();
}
</script>
{% endif %}
{% include 'play/game/common_scripts.html' %} {% include 'play/game/common_scripts.html' %}
<script> <script>
const joinCode = '{{ quiz_game.join_code }}'; const joinCode = '{{ quiz_game.join_code }}';
@@ -112,7 +179,8 @@
if (stats.hasOwnProperty(optionIndex)) { if (stats.hasOwnProperty(optionIndex)) {
var element = document.getElementById('option-count-' + optionIndex); var element = document.getElementById('option-count-' + optionIndex);
if (element) { if (element) {
element.textContent = stats[optionIndex] + ' Antworten';
element.textContent = stats[optionIndex] + ' Antwort(en)';
} }
} }
} }

View File

@@ -7,11 +7,13 @@
<!-- Quiz Info --> <!-- Quiz Info -->
<div class="text-center mb-8"> <div class="text-center mb-8">
<h3 class="text-xl text-gray-600 mb-2">{{ quiz.name }}</h3> <h3 class="text-xl text-gray-600 mb-2">{{ quiz.name }}</h3>
{% if request.session.mode != "learn" %}
<div class="bg-blue-100 rounded-lg p-4 mb-4"> <div class="bg-blue-100 rounded-lg p-4 mb-4">
<h1 class="text-4xl font-bold text-blue-800">{{ quiz_game.join_code }}</h1> <h1 class="text-4xl font-bold text-blue-800">{{ quiz_game.join_code }}</h1>
<p class="text-sm text-blue-600 mt-1">Spiel-Code</p> <p class="text-sm text-blue-600 mt-1">Spiel-Code</p>
</div> </div>
</div> </div>
{% endif %}
<!-- Participant Info --> <!-- Participant Info -->
{% if participant %} {% if participant %}
@@ -31,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>

View File

@@ -0,0 +1,41 @@
{% extends 'base.html' %}
{% block content %}
<div class="grid place-content-center md:h-screen h-150 w-full -z-50 top-0 p-8 ">
<div class="rounded-md rounded-xl shadow-lg border-3 border-blue-100 p-10">
<span class="flex justify-center font-bold p-4 text-center text-1xl lg:text-2xl">Bitte wählen Sie einen Spielmodus aus:</span>
<div class="grid sm:grid-cols-2 place-items-stretch text-center gap-4 p-4">
<!-- Moderieren -->
<a class="qp-a-button bg-green-500 text-white flex justify-center items-center relative group h-20 gap-2" href="{% url 'play:selected_mode' join_code %}?mode=classic">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />
</svg>
Moderiermodus
<!-- Tooltip -->
<span class="absolute w-full h-1/2 top-1/4 left-0 hidden group-hover:flex items-center justify-center text-white bg-black bg-opacity-90 text-xs rounded px-2 z-10 pointer-events-none">
Klassischer Spielmodus zum Moderieren
</span>
</a>
<!-- Lernmodus -->
<a class="qp-a-button bg-indigo-500 text-white flex justify-center items-center relative group h-20 gap-2" href="{% url 'play:selected_mode' join_code %}?mode=learn">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
Lernmodus
<!-- Tooltip -->
<span class="absolute w-full h-1/2 top-1/4 left-0 hidden group-hover:flex items-center justify-center text-white bg-black bg-opacity-90 text-xs rounded px-2 z-10 pointer-events-none">
Modus zum selbständigen Üben und Lernen
</span>
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -3,4 +3,7 @@ django~=5.1.4
channels~=4.2.0 # WebSocket support channels~=4.2.0 # WebSocket support
daphne~=4.1.2 # ASGI server daphne~=4.1.2 # ASGI server
pillow~=11.1.0 # Image handling pillow~=11.1.0 # Image handling
whitenoise~=6.6.0 # Static file serving whitenoise~=6.6.0 # Static file serving
django-cleanup
redis
channels_redis